diff --git a/.hypothesis-machine.example.yaml b/.hypothesis-machine.example.yaml index a4170b2..3838163 100644 --- a/.hypothesis-machine.example.yaml +++ b/.hypothesis-machine.example.yaml @@ -8,10 +8,10 @@ agent_timeout_seconds: 1800 # Maximum number of subagent sessions streaming at the same time. Lower it if # the main chat stalls while subagents run on the same model backend. agent_concurrency: 3 -# Route subagents to a different model/backend so they never contend with the -# main session, e.g. "ollama/gemma4:e4b" or "deepseek/deepseek-v4-flash". -# "inherit" (unset) uses the caller's model. -# subagent_model: "ollama/gemma4:e4b" +# Every subagent uses Pi's direct DeepSeek API integration by default, rather +# than inheriting the Supervisor model. Store the key with `/login deepseek` or +# set DEEPSEEK_API_KEY. Override only to deliberately use another Pi model. +subagent_model: "deepseek/deepseek-v4-flash" allow_recursive_spawning: true searxng_url: http://127.0.0.1:8888 firecrawl_url: http://127.0.0.1:3002 diff --git a/CHANGELOG.md b/CHANGELOG.md index 63879a7..6f4c925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- New `/agent [filter]` OpenCode-style chat inspector: a full TUI panel shows the + agent tree on the left and the selected agent's live chat transcript on the + right (`↑↓` select, `enter`/`tab` open chat, `t` toggle thinking, `o` open the + full session, `esc` close). Transcripts are parsed from the agent session + JSONL (user/assistant/tool-call/tool-result/thinking blocks) and refresh every + 1.5s while an agent is streaming; the tree shows status glyphs, elapsed time, + and per-agent tasks. `/agent-open` keeps the previous session-switching flow + (`Subagent Actions → Open`), with `/back` returning to the main session. + - New `/agent [filter]` command opens a subagent's full chat session in the TUI (OpenCode-style `Subagent Actions → Open`): a selector lists the run's agents with status glyphs, elapsed time, and task; picking one switches the session diff --git a/README.md b/README.md index bf5a4c3..962e70e 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,12 @@ Pi remains the same chat interface, model selector, credential store, session UI and streaming runtime. Do not install third-party subagent extensions for this package; Hypothesis Machine has its own recursive implementation. -Pi 0.78 passes its official `ModelRegistry` directly to children. Pi 0.83+ -uses the newer shared `ModelRuntime`. Neither path reads or copies keys in the -extension. After updating this local package, restart Pi or run `/reload`. +Every subagent is pinned to `deepseek/deepseek-v4-flash`, using Pi's built-in +Direct DeepSeek API provider rather than inheriting the Supervisor's LLM. Add the +key once through `/login deepseek` (or set `DEEPSEEK_API_KEY`); the extension never +reads or copies it. Pi 0.83+ uses its official `ModelRuntime`; Pi 0.78 uses the +provided `ModelRegistry`. Override `subagent_model` only to deliberately choose a +different Pi model. After updating this local package, restart Pi or run `/reload`. Optional project configuration: @@ -89,6 +92,9 @@ background branches are controlled with `agent_control`. ## Commands - `/team` — tree, tasks, and statuses; +- `/agent [filter]` — OpenCode-style inspector: agent tree on the left, live chat transcript of the selected agent on the right (`↑↓` select, `enter` open chat, `t` toggle thinking, `o` open the full session, `esc` close); +- `/agent-open [filter]` — open a subagent's full chat as its own session; +- `/back` — return to the main session after `/agent-open`; - `/research ` — start the explicit bounded loop; - `/research-status`, `/research-pause`, `/research-resume`, `/research-stop`; - `/findings`, `/hypotheses`. diff --git a/docs/pi-capabilities.md b/docs/pi-capabilities.md index 36c6b30..da87ee0 100644 --- a/docs/pi-capabilities.md +++ b/docs/pi-capabilities.md @@ -15,9 +15,10 @@ dispatch, lifecycle events, tool rendering, and model/provider execution. Each child is created by the official SDK `createAgentSession()`. It receives: - a persistent `SessionManager` in the research run's `sessions/` directory; -- the parent's selected `Model` and thinking level; -- one shared official model/auth service: `ModelRuntime` on Pi 0.83+, or the - parent context's `ModelRegistry` on Pi 0.78; +- the pinned `deepseek/deepseek-v4-flash` model (not the parent's selected LLM) + and the parent's thinking level; +- one official model/auth service: `ModelRuntime` on Pi 0.83+, or the parent + context's `ModelRegistry` on Pi 0.78. Pi resolves the direct DeepSeek API key; - a `DefaultResourceLoader` that keeps project context but disables extension rediscovery for the child (the recursive tools are injected explicitly); - only its allowed read-only built-ins and Hypothesis Machine custom tools. diff --git a/src/agent-chat-panel.ts b/src/agent-chat-panel.ts new file mode 100644 index 0000000..036a15f --- /dev/null +++ b/src/agent-chat-panel.ts @@ -0,0 +1,368 @@ +import { readFileSync, statSync } from "node:fs"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; +import type { AgentRecord, AgentStatus } from "./types.js"; + +/** One-character status glyphs (OpenCode-style checklist markers). */ +export const AGENT_STATUS_GLYPH: Record = { + created: "·", running: "•", waiting: "·", completed: "✓", failed: "✖", cancelled: "·", interrupted: "!", archived: "·", +}; + +export function shortAgentName(id: string): string { return id.replace(/-[a-f0-9]{8}$/, ""); } + +// --------------------------------------------------------------------------- +// Transcript parsing (pure, testable) +// --------------------------------------------------------------------------- + +export interface TranscriptEntry { + role: "user" | "assistant" | "tool" | "thinking"; + label: string; + /** Full text; the renderer wraps/truncates to the available width. */ + text: string; +} + +interface ParsedContent { + text: string; + tools: Array<{ name: string; args: string }>; + thinking: string[]; +} + +function parseContentParts(parts: unknown): ParsedContent { + const text: string[] = []; const tools: ParsedContent["tools"] = []; const thinking: string[] = []; + if (!Array.isArray(parts)) return { text: "", tools, thinking }; + for (const part of parts) { + if (!part || typeof part !== "object") continue; + const record = part as Record; + switch (record.type) { + case "text": if (typeof record.text === "string" && record.text.trim()) text.push(record.text); break; + case "thinking": if (typeof record.thinking === "string" && record.thinking.trim()) thinking.push(record.thinking); break; + case "toolCall": { + const name = typeof record.name === "string" ? record.name : "tool"; + let args = typeof record.arguments === "string" ? record.arguments : JSON.stringify(record.arguments ?? ""); + try { args = JSON.stringify(JSON.parse(args), null, 1); } catch { /* keep the raw string */ } + tools.push({ name, args }); + break; + } + } + } + return { text: text.join("\n\n"), tools, thinking }; +} + +function parseToolResult(parsed: Record): TranscriptEntry | undefined { + const message = parsed.message as Record | undefined; + if (!message || typeof message !== "object") return undefined; + const name = typeof message.toolName === "string" ? message.toolName : "tool"; + const parts = Array.isArray(message.content) ? message.content : []; + const text = parts + .map((part): string => (part && typeof part === "object" && typeof (part as Record).text === "string" ? (part as Record).text as string : "")) + .join("\n").trim(); + return { role: "tool", label: `← ${name}`, text: text || "(no text result)" }; +} + +/** Return the actual provider/model recorded by Pi in an agent session, if any. */ +export function parseAgentModel(sessionFile: string | undefined): string | undefined { + if (!sessionFile) return undefined; + let raw: string; + try { raw = readFileSync(sessionFile, "utf8"); } catch { return undefined; } + let model: string | undefined; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + let parsed: Record; + try { parsed = JSON.parse(line) as Record; } catch { continue; } + if (parsed.type !== "model_change") continue; + const provider = parsed.provider; const modelId = parsed.modelId; + if (typeof provider === "string" && typeof modelId === "string") model = `${provider}/${modelId}`; + } + return model; +} + +/** + * Read a Pi agent session JSONL and reduce it to a renderable transcript. + * Entries that fail to parse are skipped; a missing/unreadable file falls back + * to the agent's task so the inspector still shows something useful. + */ +export function parseAgentTranscript(sessionFile: string | undefined, fallbackText?: string): TranscriptEntry[] { + const fallback = (): TranscriptEntry[] => (fallbackText ? [{ role: "assistant", label: "task", text: fallbackText }] : []); + if (!sessionFile) return fallback(); + let raw: string; + try { raw = readFileSync(sessionFile, "utf8"); } catch { return fallback(); } + const entries: TranscriptEntry[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + let parsed: Record; + try { parsed = JSON.parse(line) as Record; } catch { continue; } + if (parsed.type !== "message") continue; + const message = parsed.message as Record | undefined; + if (!message || typeof message !== "object") continue; + const role = message.role; + if (role === "user") { + const content = parseContentParts(message.content); + if (content.text) entries.push({ role: "user", label: "user", text: content.text }); + } else if (role === "assistant") { + const content = parseContentParts(message.content); + if (content.text) entries.push({ role: "assistant", label: "assistant", text: content.text }); + for (const t of content.thinking) entries.push({ role: "thinking", label: "thinking", text: t }); + for (const tool of content.tools) entries.push({ role: "tool", label: `→ ${tool.name}`, text: tool.args }); + } else if (role === "toolResult") { + const result = parseToolResult(parsed); + if (result) entries.push(result); + } + } + if (entries.length === 0 && fallbackText) entries.push({ role: "assistant", label: "task", text: fallbackText }); + return entries; +} + +// --------------------------------------------------------------------------- +// Agent tree flattening (pure, testable) +// --------------------------------------------------------------------------- + +export interface AgentTreeRow { + id: string; + depth: number; + status: AgentStatus; + name: string; + task: string; + elapsed: string; + hasSession: boolean; +} + +export function agentElapsed(record: AgentRecord, now: number): string { + if (!record.startedAt) return ""; + const start = Date.parse(record.startedAt); + const end = record.finishedAt ? Date.parse(record.finishedAt) : record.status === "running" || record.status === "waiting" ? now : undefined; + if (!end) return ""; + const span = Math.max(0, Math.round((end - start) / 1000)); + return `${String(Math.floor(span / 60)).padStart(2, "0")}:${String(span % 60).padStart(2, "0")}`; +} + +/** Depth-first flatten of the agent tree, root first, preserving hierarchy order. */ +export function buildTreeRows(agents: AgentRecord[], rootId: string, now: number): AgentTreeRow[] { + const byId = new Map(agents.map((agent) => [agent.id, agent])); + const rows: AgentTreeRow[] = []; + const walk = (id: string, depth: number): void => { + const agent = byId.get(id); + if (!agent) return; + rows.push({ + id, depth, status: agent.status, name: shortAgentName(agent.id), + task: agent.task.replace(/\s+/g, " ").trim(), + elapsed: agentElapsed(agent, now), hasSession: Boolean(agent.sessionFile), + }); + for (const child of agent.children) walk(child, depth + 1); + }; + walk(rootId, 0); + return rows; +} + +// --------------------------------------------------------------------------- +// Chat rendering (pure, testable) +// --------------------------------------------------------------------------- + +export interface RenderedChatLine { + kind: "separator" | "content"; + role?: TranscriptEntry["role"]; + text: string; +} + +const SEPARATOR_MIN_WIDTH = 14; + +/** Flatten a transcript into wrapped, width-safe lines with role-aware separators. */ +export function renderTranscriptLines(entries: TranscriptEntry[], width: number): RenderedChatLine[] { + const lines: RenderedChatLine[] = []; + const contentWidth = Math.max(10, width - 2); + for (const entry of entries) { + const dashCount = Math.max(1, SEPARATOR_MIN_WIDTH - entry.label.length); + const sep = `── ${entry.label} ${"─".repeat(dashCount)}`; + lines.push({ kind: "separator", role: entry.role, text: truncateToWidth(sep, width, "") }); + for (const wrapped of wrapTextWithAnsi(entry.text, contentWidth)) { + for (const sub of wrapped.split("\n")) lines.push({ kind: "content", role: entry.role, text: truncateToWidth(sub, width, "") }); + } + } + return lines; +} + +// --------------------------------------------------------------------------- +// Interactive inspector component (OpenCode-style agent chat tracking) +// --------------------------------------------------------------------------- + +export type AgentChatPanelResult = { action: "openSession"; agentId: string } | { action: "close" }; + +export interface AgentChatPanelOptions { + getAgents: () => AgentRecord[]; + rootId: string; + runId: string; + goal: string; + theme: Theme; + /** Optional lowercase query; non-matching agents are dimmed in the tree. */ + filter?: string; +} + +/** Result carrying the agent id when the user asks to open the full session. */ +export function openSessionResult(agentId: string): AgentChatPanelResult { return { action: "openSession", agentId }; } +export function closeResult(): AgentChatPanelResult { return { action: "close" }; } + +export class AgentChatPanel { + /** Index into the flattened tree; shared by both panes. */ + private selected = 0; + /** Agent whose chat is shown in the right pane. */ + private viewing: string | null = null; + /** True when focus is on the chat pane. */ + private chatFocused = false; + /** Scroll offset in wrapped lines (0 = newest at bottom). */ + private chatScroll = 0; + private thinkingVisible = false; + private readonly parseCache = new Map(); + private cachedWidth: number | undefined; + private cachedLines: string[] | undefined; + + /** Resolves the awaiting `ctx.ui.custom` promise; set by the UI factory. */ + onDone: (result: AgentChatPanelResult) => void = closeResult; + + constructor(private readonly options: AgentChatPanelOptions) {} + + invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; } + + private rows(now = Date.now()): AgentTreeRow[] { return buildTreeRows(this.options.getAgents(), this.options.rootId, now); } + + private transcriptOf(record: AgentRecord | undefined): TranscriptEntry[] { + const fallback = record?.task; + if (!record?.sessionFile) return fallback ? [{ role: "assistant", label: "task", text: fallback }] : []; + const file = record.sessionFile; + try { + const stat = statSync(file); + const cached = this.parseCache.get(file); + if (cached && cached.mtimeMs === stat.mtimeMs) return cached.entries; + const entries = parseAgentTranscript(file); + this.parseCache.set(file, { mtimeMs: stat.mtimeMs, entries }); + return entries; + } catch { + return this.parseCache.get(file)?.entries ?? parseAgentTranscript(file); + } + } + + private viewportRows(): number { + const rows = typeof process.stdout.rows === "number" && process.stdout.rows > 0 ? process.stdout.rows : 24; + return Math.max(10, rows - 5); + } + + handleInput(data: string): void { + const rows = this.rows(); + if (this.chatFocused && this.viewing) { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.left) || matchesKey(data, "h")) { this.chatFocused = false; } + else if (matchesKey(data, "q")) { this.onDone(closeResult()); } + else if (matchesKey(data, Key.up) || matchesKey(data, "k")) { this.chatScroll++; } + else if (matchesKey(data, Key.down) || matchesKey(data, "j")) { this.chatScroll = Math.max(0, this.chatScroll - 1); } + else if (matchesKey(data, Key.pageUp) || matchesKey(data, "ctrl+u")) { this.chatScroll += Math.max(5, Math.floor(this.viewportRows() / 2)); } + else if (matchesKey(data, Key.pageDown) || matchesKey(data, "ctrl+d")) { this.chatScroll = Math.max(0, this.chatScroll - Math.max(5, Math.floor(this.viewportRows() / 2))); } + else if (matchesKey(data, "t")) { this.thinkingVisible = !this.thinkingVisible; this.chatScroll = 0; } + else if (matchesKey(data, "o")) { this.onDone(openSessionResult(this.viewing)); } + return; + } + if (matchesKey(data, Key.escape) || matchesKey(data, "q")) { this.onDone(closeResult()); return; } + if (matchesKey(data, Key.tab) || matchesKey(data, Key.right) || matchesKey(data, "l")) { + const row = rows[this.selected]; if (row?.hasSession || row) { this.viewing = row.id; this.chatFocused = true; this.chatScroll = 0; } return; + } + if (matchesKey(data, Key.enter)) { + const row = rows[this.selected]; if (row) { this.viewing = row.id; this.chatFocused = true; this.chatScroll = 0; } return; + } + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { this.selected = Math.max(0, this.selected - 1); return; } + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { this.selected = Math.min(rows.length - 1, this.selected + 1); return; } + if (matchesKey(data, Key.home)) { this.selected = 0; return; } + if (matchesKey(data, Key.end)) { this.selected = Math.max(0, rows.length - 1); return; } + } + + private treeLines(rows: AgentTreeRow[], width: number): string[] { + const theme = this.options.theme; + const filter = (this.options.filter ?? "").trim().toLowerCase(); + const selected = this.chatFocused ? -1 : this.selected; + return rows.map((row, index) => { + const indent = " ".repeat(Math.min(row.depth, 8)); + const marker = index === selected ? theme.fg("accent", "▸") : " "; + const color = row.status === "completed" ? "success" : row.status === "failed" ? "error" : "accent"; + const glyph = theme.fg(color, AGENT_STATUS_GLYPH[row.status]); + const status = row.status === "running" ? theme.fg("warning", "running") : theme.fg("dim", row.status); + const time = row.elapsed ? theme.fg("dim", row.elapsed) : ""; + const noSession = row.hasSession ? "" : theme.fg("dim", "· no chat yet"); + const line = `${indent}${marker} ${glyph} ${theme.bold(row.name)} ${status} ${time} ${noSession}`; + const task = truncateToWidth(row.task, Math.max(0, width - visibleWidth(line) - 1), ""); + const rendered = truncateToWidth(`${line} ${task}`.replace(/\s+$/, ""), width, ""); + if (filter && !row.id.toLowerCase().includes(filter) && !row.name.toLowerCase().includes(filter) && !row.task.toLowerCase().includes(filter)) { + return theme.fg("dim", rendered); + } + return rendered; + }); + } + + private chatLines(record: AgentRecord | undefined, width: number): string[] { + const theme = this.options.theme; + if (!record) return [theme.fg("dim", "Select an agent in the left pane.")]; + const glyph = AGENT_STATUS_GLYPH[record.status]; + const statusColor = record.status === "failed" ? "error" : record.status === "completed" ? "success" : "accent"; + const row = this.rows().find((candidate) => candidate.id === record.id); + const model = parseAgentModel(record.sessionFile); + const head = `${glyph} ${shortAgentName(record.id)} [${record.status}]${row?.elapsed ? ` ${theme.fg("dim", row.elapsed)}` : ""}${model ? ` · ${theme.fg("dim", model)}` : ""}`; + const lines: string[] = [truncateToWidth(theme.fg(statusColor, head), width, "")]; + const task = truncateToWidth(record.task.replace(/\s+/g, " ").trim(), Math.max(0, width - 4), ""); + if (task) lines.push(theme.fg("muted", task)); + lines.push(theme.fg("dim", "─".repeat(Math.max(1, width)))); + const entries = this.transcriptOf(record); + const visible = this.thinkingVisible ? entries : entries.filter((entry) => entry.role !== "thinking"); + if (visible.length === 0) { + lines.push(theme.fg("dim", "No chat transcript yet.")); + return lines; + } + const rendered = renderTranscriptLines(visible, width); + const height = this.viewportRows() - 4; + const start = Math.max(0, rendered.length - height - this.chatScroll); + const end = Math.min(rendered.length, start + height + 1); + const slice = rendered.slice(start, end); + for (const line of slice) { + if (line.kind === "separator") lines.push(theme.fg("dim", line.text)); + else if (line.role === "user") lines.push(theme.fg("userMessageText", line.text)); + else if (line.role === "assistant") lines.push(theme.fg("text", line.text)); + else if (line.role === "thinking") lines.push(theme.fg("thinkingMedium", line.text)); + else lines.push(theme.fg("toolOutput", line.text)); + } + const scrolled = rendered.length - height > 0; + if (scrolled) { + const pos = Math.max(0, rendered.length - height - this.chatScroll); + lines.push(truncateToWidth(theme.fg("dim", `… ${pos + 1}–${end} of ${rendered.length} lines · pgup/pgdn scroll`), width, "")); + } + return lines; + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; + const theme = this.options.theme; + const rows = this.rows(); + const agents = this.options.getAgents(); + const active = agents.filter((agent) => agent.status === "running" || agent.status === "waiting").length; + const goal = truncateToWidth(this.options.goal.replace(/\s+/g, " ").trim(), Math.max(0, width - 44), ""); + const lines: string[] = []; + lines.push(truncateToWidth(theme.fg("accent", `◆ ${this.options.runId} · ${goal} · ${agents.length} agents · ${active} active`), width, "")); + lines.push(theme.fg("dim", "─".repeat(Math.max(1, width)))); + lines.push(truncateToWidth(theme.fg("dim", `↑↓ select · enter/tab open chat · t thinking · o full session · esc close ${this.thinkingVisible ? "· thinking ON" : ""}`), width, "")); + lines.push(theme.fg("dim", "─".repeat(Math.max(1, width)))); + + const twoPane = width >= 60; + const treeWidth = twoPane ? Math.max(30, Math.min(width - 1, Math.floor(width * 0.32))) : 0; + const chatWidth = twoPane ? Math.max(10, width - treeWidth - 1) : width; + const height = this.viewportRows(); + const viewing = agents.find((agent) => agent.id === this.viewing); + const tree = twoPane || !viewing ? this.treeLines(rows, twoPane ? treeWidth : width) : []; + const chat = twoPane || viewing ? this.chatLines(viewing, chatWidth) : []; + const rowCount = Math.max(tree.length, chat.length, height); + const paddedTree = tree.length < rowCount ? [...tree, ...Array(rowCount - tree.length).fill("")] : tree; + const paddedChat = chat.length < rowCount ? [...chat, ...Array(rowCount - chat.length).fill("")] : chat; + for (let index = 0; index < rowCount; index++) { + const left = paddedTree[index] ?? ""; + const right = paddedChat[index] ?? ""; + if (!twoPane) { lines.push(left || right); continue; } + const leftPad = left + " ".repeat(Math.max(0, treeWidth - visibleWidth(left))); + const sep = theme.fg("borderMuted", "│"); + lines.push(leftPad + sep + right); + } + lines.push(theme.fg("dim", "─".repeat(Math.max(1, width)))); + this.cachedWidth = width; this.cachedLines = lines; + return lines; + } +} diff --git a/src/config.ts b/src/config.ts index a1951c6..e23c8b3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,8 @@ import { resolve } from "node:path"; import YAML from "yaml"; import type { ResearchLimits } from "./types.js"; +export const DEFAULT_SUBAGENT_MODEL = "deepseek/deepseek-v4-flash"; + export interface HypothesisMachineConfig extends ResearchLimits { state_dir: string; searxng_url: string; @@ -10,8 +12,8 @@ export interface HypothesisMachineConfig extends ResearchLimits { browser_use_url?: string; web_timeout_ms: number; max_download_bytes: number; - /** Route spawned subagents to a different model/backend, e.g. "ollama/gemma4:e4b", so they do not contend with the main session. "inherit" (default) uses the caller's model. */ - subagent_model?: string; + /** Pi model used for every spawned agent. Defaults to DeepSeek V4 Flash through Pi's built-in direct DeepSeek provider. */ + subagent_model: string; experiment: { image: string; cpus: number; memory_mb: number; timeout_seconds: number }; } @@ -30,6 +32,7 @@ export const DEFAULT_CONFIG: HypothesisMachineConfig = { firecrawl_url: "http://127.0.0.1:3002", web_timeout_ms: 45_000, max_download_bytes: 10 * 1024 * 1024, + subagent_model: DEFAULT_SUBAGENT_MODEL, experiment: { image: "python:3.12-slim", cpus: 1, memory_mb: 1024, timeout_seconds: 300 }, }; @@ -37,5 +40,7 @@ export function loadConfig(cwd: string): HypothesisMachineConfig { const file = resolve(cwd, DEFAULT_CONFIG.state_dir, "config.yaml"); if (!existsSync(file)) return structuredClone(DEFAULT_CONFIG); const value = YAML.parse(readFileSync(file, "utf8")) as Partial; - return { ...DEFAULT_CONFIG, ...value, experiment: { ...DEFAULT_CONFIG.experiment, ...value.experiment } }; + const config = { ...DEFAULT_CONFIG, ...value, experiment: { ...DEFAULT_CONFIG.experiment, ...value.experiment } }; + // "inherit" was the old default; never permit it to re-enable parent-model routing. + return config.subagent_model === "inherit" ? { ...config, subagent_model: DEFAULT_SUBAGENT_MODEL } : config; } diff --git a/src/index.ts b/src/index.ts index 853da2f..a5da746 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,8 @@ export default function hypothesisMachine(pi: ExtensionAPI): void { pi.on("session_shutdown", async () => { await supervisor.shutdown(); }); pi.registerCommand("team", { description: "Show the recursive research team", handler: async (_args, ctx) => { ctx.ui.notify(supervisor.team(), "info"); } }); - pi.registerCommand("agent", { description: "Open a subagent's full chat (like OpenCode)", handler: async (args, ctx) => { await supervisor.showAgentChat(ctx, args.trim() || undefined); } }); + pi.registerCommand("agent", { description: "Open the OpenCode-style subagent chat inspector (tree + live transcripts)", handler: async (args, ctx) => { await supervisor.showAgentChatPanel(ctx, args.trim() || undefined); } }); + pi.registerCommand("agent-open", { description: "Open a subagent's full chat as a separate session (like OpenCode)", handler: async (args, ctx) => { await supervisor.showAgentChat(ctx, args.trim() || undefined); } }); pi.registerCommand("back", { description: "Return to the main session from an agent chat", handler: async (_args, ctx) => { const main = supervisor.mainSessionFile; if (!main) { ctx.ui.notify("Main session is not recorded", "warning"); return; } if (ctx.sessionManager.getSessionFile() === main) { ctx.ui.notify("Already on the main session", "info"); return; } await ctx.switchSession(main); } }); pi.registerCommand("research", { description: "Start a bounded research run", handler: async (args, ctx) => { const goal = args.trim(); if (!goal) { ctx.ui.notify("Usage: /research ", "warning"); return; } const state = supervisor.loop; if (!state || !supervisor.tree) throw new Error("Not initialized"); supervisor.tree.setGoal(goal); state.setGoal(goal); state.start(); const prompt = `Research goal: ${goal}\nUse research_control and the recursive agent tools. Create specialized children only when useful. Record each iteration and stop on the coded conditions. Report important progress without flooding the chat.`; if (ctx.isIdle()) pi.sendUserMessage(prompt); else pi.sendUserMessage(prompt, { deliverAs: "followUp" }); } }); pi.registerCommand("research-status", { description: "Show research loop state", handler: async (_args, ctx) => { ctx.ui.notify(JSON.stringify(supervisor.loop?.snapshot() ?? {}, null, 2), "info"); } }); @@ -22,6 +23,7 @@ export default function hypothesisMachine(pi: ExtensionAPI): void { pi.registerCommand("hypotheses", { description: "List stored hypotheses", handler: async (_args, ctx) => { ctx.ui.notify(JSON.stringify(supervisor.findings("hypothesis"), null, 2), "info"); } }); } +export * from "./agent-chat-panel.js"; export * from "./agent-tree.js"; export * from "./agent-spec.js"; export * from "./research-memory.js"; diff --git a/src/pi-runtime.ts b/src/pi-runtime.ts index b0d03b9..80adcd5 100644 --- a/src/pi-runtime.ts +++ b/src/pi-runtime.ts @@ -31,7 +31,7 @@ class PiRuntime implements AgentRuntime { dispose(): void { this.session.dispose(); } } -export interface PiRuntimeDependencies { cwd: string; config: HypothesisMachineConfig; store: RunStore; memory: ResearchMemory; web: WebGateway; experiments: ExperimentRunner; modelRuntime?: ModelRuntime; modelRegistry?: ModelRegistry; model?: any; thinkingLevel?: any } +export interface PiRuntimeDependencies { cwd: string; config: HypothesisMachineConfig; store: RunStore; memory: ResearchMemory; web: WebGateway; experiments: ExperimentRunner; modelRuntime?: ModelRuntime; modelRegistry?: ModelRegistry; thinkingLevel?: any } export class PiAgentRuntimeFactory implements AgentRuntimeFactory { private tree?: AgentTree; @@ -51,15 +51,19 @@ export class PiAgentRuntimeFactory implements AgentRuntimeFactory { const sessionManager = record.sessionFile ? SessionManager.open(record.sessionFile, this.deps.store.sessionDir(record.runId), this.deps.cwd) : SessionManager.create(this.deps.cwd, this.deps.store.sessionDir(record.runId)); - const [provider, ...modelParts] = spec.model.split("/"); - const inheritedModel = spec.model !== "inherit" && provider && modelParts.length + // Runtime configuration is authoritative: persisted specs, including older + // "inherit" records, cannot route a child back to the Supervisor's LLM. + const modelName = this.deps.config.subagent_model; + const [provider, ...modelParts] = modelName.split("/"); + if (!this.deps.modelRuntime && !this.deps.modelRegistry) throw new Error("Pi model runtime is unavailable; Hypothesis Machine requires Pi 0.78 or newer"); + const subagentModel = provider && modelParts.length ? this.deps.modelRuntime?.getModel(provider, modelParts.join("/")) ?? this.deps.modelRegistry?.find(provider, modelParts.join("/")) : undefined; + if (!subagentModel) throw new Error(`Pi cannot resolve subagent model ${JSON.stringify(modelName)}. Configure its direct API credentials with /login deepseek or DEEPSEEK_API_KEY.`); const inheritedThinking = spec.thinking_level !== "inherit" ? spec.thinking_level : this.deps.thinkingLevel; - if (!this.deps.modelRuntime && !this.deps.modelRegistry) throw new Error("Pi model runtime is unavailable; Hypothesis Machine requires Pi 0.78 or newer"); const modelServices = this.deps.modelRuntime ? { modelRuntime: this.deps.modelRuntime } : { modelRegistry: this.deps.modelRegistry }; const { session } = await createAgentSession({ - cwd: this.deps.cwd, ...modelServices, model: inheritedModel ?? this.deps.model, + cwd: this.deps.cwd, ...modelServices, model: subagentModel, thinkingLevel: inheritedThinking as any, resourceLoader, settingsManager, sessionManager, customTools, tools: [...safeBuiltins, ...customNames], } as any); diff --git a/src/supervisor.ts b/src/supervisor.ts index c88c17c..e4664b8 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -3,6 +3,7 @@ import { defineTool, ModelRuntime, type ExtensionAPI, type ExtensionCommandConte import { Text, truncateToWidth } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; +import { AgentChatPanel, AGENT_STATUS_GLYPH, shortAgentName, type AgentChatPanelResult } from "./agent-chat-panel.js"; import { AgentTree } from "./agent-tree.js"; import { loadConfig, type HypothesisMachineConfig } from "./config.js"; import { PiAgentRuntimeFactory } from "./pi-runtime.js"; @@ -12,18 +13,11 @@ import { RunStore } from "./run-store.js"; import { ExperimentRunner } from "./tools/experiment.js"; import { createResearchTools } from "./tools/index.js"; import { WebGateway } from "./tools/web.js"; -import type { AgentRecord, AgentStatus } from "./types.js"; +import type { AgentRecord } from "./types.js"; const RUN_ENTRY = "hypothesis-machine-run"; const toolText = (value: unknown) => ({ content: [{ type: "text" as const, text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }], details: {} }); -/** One-character status glyphs for the agent-chat selector (OpenCode-style checklist markers). */ -const AGENT_STATUS_GLYPH: Record = { - created: "·", running: "•", waiting: "·", completed: "✓", failed: "✖", cancelled: "·", interrupted: "!", archived: "·", -}; - -function shortAgentName(id: string): string { return id.replace(/-[a-f0-9]{8}$/, ""); } - /** * Derive the run id from an agent session file path (`/runs//sessions/…`). * Used when the current session is an agent chat opened via `/agent` (it has no @@ -81,7 +75,7 @@ export class SupervisorIntegration { const sessionFile = ctx.sessionManager.getSessionFile(); const runId = runIdFromEntry ?? runIdFromSessionPath(sessionFile, stateDir); const isMainSession = !runIdFromEntry && !runId; - const runtimeFactory = new PiAgentRuntimeFactory({ cwd: ctx.cwd, config: this.config, store, memory: this.memory, web: this.web, experiments: this.experiments, ...(this.modelRuntime ? { modelRuntime: this.modelRuntime } : {}), ...(this.modelRegistry ? { modelRegistry: this.modelRegistry } : {}), ...(ctx.model ? { model: ctx.model } : {}), ...(ctx.thinkingLevel ? { thinkingLevel: ctx.thinkingLevel } : {}) }); + const runtimeFactory = new PiAgentRuntimeFactory({ cwd: ctx.cwd, config: this.config, store, memory: this.memory, web: this.web, experiments: this.experiments, ...(this.modelRuntime ? { modelRuntime: this.modelRuntime } : {}), ...(this.modelRegistry ? { modelRegistry: this.modelRegistry } : {}), ...(ctx.thinkingLevel ? { thinkingLevel: ctx.thinkingLevel } : {}) }); const onRootMessage = (fromId: string, message: string) => this.pi.sendMessage({ customType: "hypothesis-machine-agent-update", content: `Agent ${fromId} reports:\n\n${message}`, display: true, details: { fromId, runId: this.tree?.runId } }, { triggerTurn: false, deliverAs: "nextTurn" }); this.tree = runId && store.exists(runId) ? AgentTree.restore(store, runtimeFactory, this.config, runId, onRootMessage) : new AgentTree(store, runtimeFactory, this.config, { goal: "Research requested in the current Supervisor session", inherited: { model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "inherit", thinkingLevel: ctx.thinkingLevel ?? "inherit" }, onRootMessage }); runtimeFactory.attachTree(this.tree); if (!runId) this.pi.appendEntry(RUN_ENTRY, { runId: this.tree.runId }); @@ -116,6 +110,37 @@ export class SupervisorIntegration { team(): string { return this.required().tree.render(); } findings(kind?: string): unknown { return this.required().memory.list(kind); } + /** + * OpenCode-style agent inspector: full TUI panel with the agent tree on the + * left and the selected agent's live chat transcript on the right. Tracking + * happens inside the current session; the chat area is not replaced. + */ + async showAgentChatPanel(ctx: ExtensionCommandContext, filter?: string): Promise { + const { tree } = this.required(); + const agents = tree.list(); + const query = (filter ?? "").trim().toLowerCase(); + const filtered = query + ? agents.filter((agent) => agent.id.toLowerCase().includes(query) || agent.task.toLowerCase().includes(query)) + : agents; + if (filtered.length === 0) { ctx.ui.notify(query ? `No agents match "${filter}"` : "No agents in this run yet", "info"); return; } + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + const panel = new AgentChatPanel({ + theme, + rootId: tree.rootId, + runId: tree.runId, + goal: tree.inspect(tree.rootId).task, + getAgents: () => tree.list(), + ...(query ? { filter: query } : {}), + }); + const refresh = setInterval(() => { panel.invalidate(); tui.requestRender(); }, 1500); + const finish = (value: AgentChatPanelResult) => { clearInterval(refresh); done(value); }; + panel.onDone = finish; + return { render: (w) => panel.render(w), handleInput: (d) => { panel.handleInput(d); panel.invalidate(); tui.requestRender(); }, invalidate: () => panel.invalidate(), dispose: () => clearInterval(refresh) }; + }); + if (!result || result.action === "close") return; + if (result.action === "openSession") { await this.showAgentChat(ctx, result.agentId); return; } + } + /** * OpenCode-style subagent switcher: let the user pick an agent and open its * full chat session in the TUI (like `Subagent Actions → Open` in OpenCode). @@ -182,7 +207,7 @@ export class SupervisorIntegration { return lines; } - private shortName(id: string): string { return id.replace(/-[a-f0-9]{8}$/, ""); } + private shortName(id: string): string { return shortAgentName(id); } continueIfNeeded(ctx: ExtensionContext): void { const loop = this.loop; if (!loop || !ctx.isIdle()) return; const state = loop.snapshot(); if (state.status !== "running" || state.iteration <= this.lastScheduledIteration) return; this.lastScheduledIteration = state.iteration; if (ctx.hasUI) ctx.ui.setStatus("hypothesis-machine", `HM ${state.runId} · iteration ${state.iteration + 1}`); this.pi.sendUserMessage(`Continue bounded research run ${state.runId} with iteration ${state.iteration + 1}. Reassess unknowns and contradictions, use agents only where they add information, then call research_control record_iteration. Stop when its coded state is no longer running.`); } async shutdown(): Promise { if (this.widgetTimer) { clearInterval(this.widgetTimer); this.widgetTimer = undefined; } this.requestAgentRender = undefined; await this.tree?.shutdown(); this.memory?.close(); this.tree = undefined; } } diff --git a/src/tools/index.ts b/src/tools/index.ts index 226ca37..df9468d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -7,6 +7,7 @@ import type { AgentTree } from "../agent-tree.js"; import type { ResearchMemory } from "../research-memory.js"; import type { ExperimentRunner } from "./experiment.js"; import type { WebGateway } from "./web.js"; +import { DEFAULT_SUBAGENT_MODEL } from "../config.js"; interface ToolDeps { tree: AgentTree; parentId: string; memory: ResearchMemory; web: WebGateway; experiments: ExperimentRunner; cwd: string; subagentModel?: string } const text = (value: unknown, details: unknown = {}) => ({ content: [{ type: "text" as const, text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }], details }); @@ -23,7 +24,7 @@ export function createResearchTools(deps: ToolDeps): ToolDefinition[] { name: "spawn_agent", label: "Spawn research agent", description: "Create a dynamically specialized child AgentSession. Every child receives this tool and may recurse within coded limits.", promptSnippet: "Spawn a specialized recursive research child", promptGuidelines: ["Use spawn_agent only for a concrete, non-duplicate specialized task with an expected output and completion criterion. Mark independent replications explicitly."], parameters: Type.Object({ name: Type.String({ minLength: 2 }), role: Type.String({ minLength: 3 }), task: Type.String({ minLength: 12 }), expected_output: Type.String({ minLength: 3 }), completion_criteria: Type.String({ minLength: 3 }), context: Type.Optional(Type.String()), responsibilities: Type.Optional(Type.String()), tools: Type.Optional(Type.Array(Type.String())), background: Type.Optional(Type.Boolean({ default: true })), replication_of: Type.Optional(Type.String()), independent_context: Type.Optional(Type.Boolean()) }), - async execute(_id, params, _signal, _update, ctx) { const record = await deps.tree.spawn({ parentId: deps.parentId, name: params.name, role: params.role, task: params.task, expectedOutput: params.expected_output, completionCriteria: params.completion_criteria, ...(params.context ? { context: params.context } : {}), ...(params.responsibilities ? { responsibilities: params.responsibilities } : {}), ...(params.tools ? { tools: params.tools } : {}), background: params.background ?? true, ...(params.replication_of ? { replicationOf: params.replication_of } : {}), ...(params.independent_context !== undefined ? { independentContext: params.independent_context } : {}) }, { model: deps.subagentModel ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "inherit"), thinkingLevel: ctx.thinkingLevel ?? "inherit" }); if (params.background === false) return text(await deps.tree.start(record.id), { agentId: record.id }); return text({ agentId: record.id, status: "running", depth: record.depth, parent: record.parentId }, { agentId: record.id }); }, + async execute(_id, params, _signal, _update, ctx) { const record = await deps.tree.spawn({ parentId: deps.parentId, name: params.name, role: params.role, task: params.task, expectedOutput: params.expected_output, completionCriteria: params.completion_criteria, ...(params.context ? { context: params.context } : {}), ...(params.responsibilities ? { responsibilities: params.responsibilities } : {}), ...(params.tools ? { tools: params.tools } : {}), background: params.background ?? true, ...(params.replication_of ? { replicationOf: params.replication_of } : {}), ...(params.independent_context !== undefined ? { independentContext: params.independent_context } : {}) }, { model: deps.subagentModel ?? DEFAULT_SUBAGENT_MODEL, thinkingLevel: ctx.thinkingLevel ?? "inherit" }); if (params.background === false) return text(await deps.tree.start(record.id), { agentId: record.id }); return text({ agentId: record.id, status: "running", depth: record.depth, parent: record.parentId }, { agentId: record.id }); }, }); const control = defineTool({ name: "agent_control", label: "Agent tree control", description: "List, inspect, wait for, message, steer, cancel, or collect another agent.", diff --git a/tests/agent-chat-panel.test.ts b/tests/agent-chat-panel.test.ts new file mode 100644 index 0000000..6a1e085 --- /dev/null +++ b/tests/agent-chat-panel.test.ts @@ -0,0 +1,138 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { buildTreeRows, parseAgentModel, parseAgentTranscript, renderTranscriptLines, shortAgentName } from "../src/agent-chat-panel.js"; +import type { AgentRecord } from "../src/types.js"; + +const SESSION_HEADER = [ + '{"type":"session","version":3,"id":"s-1","timestamp":"2026-07-31T00:00:00.000Z","cwd":"/proj"}', + '{"type":"model_change","id":"m-1","parentId":null,"timestamp":"2026-07-31T00:00:00.000Z","provider":"deepseek","modelId":"deepseek-v4-flash"}', +].join("\n"); + +const USER_MSG = '{"type":"message","id":"u-1","parentId":null,"timestamp":"2026-07-31T00:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"Research the market"}]}}'; + +const ASSISTANT_MSG = '{"type":"message","id":"a-1","parentId":"u-1","timestamp":"2026-07-31T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"I should spawn a child."},{"type":"text","text":"I will delegate."},{"type":"toolCall","id":"call_1","name":"spawn_agent","arguments":"{\\"name\\":\\"critic\\"}"}],"model":"deepseek-v4-flash"}}'; + +const TOOL_RESULT_MSG = '{"type":"message","id":"t-1","parentId":"a-1","timestamp":"2026-07-31T00:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_1","toolName":"spawn_agent","content":[{"type":"text","text":"{\\"status\\":\\"completed\\"}"}]}}'; + +const MALFORMED = "{not json"; + +function writeSession(dir: string, lines: string[]): string { + const file = join(dir, "agent.jsonl"); + writeFileSync(file, lines.join("\n") + "\n", "utf8"); + return file; +} + +describe("parseAgentTranscript", () => { + it("extracts user, assistant, thinking, tool calls and tool results in order", () => { + const dir = mkdtempSync(join(tmpdir(), "hm-transcript-")); + try { + const file = writeSession(dir, [SESSION_HEADER, USER_MSG, ASSISTANT_MSG, TOOL_RESULT_MSG, MALFORMED].flatMap((line) => [line])); + const entries = parseAgentTranscript(file); + expect(entries.map((entry) => entry.role)).toEqual(["user", "assistant", "thinking", "tool", "tool"]); + expect(entries[0]).toMatchObject({ role: "user", label: "user", text: "Research the market" }); + expect(entries[1]).toMatchObject({ role: "assistant", label: "assistant", text: "I will delegate." }); + expect(entries[2]).toMatchObject({ role: "thinking", label: "thinking" }); + expect(entries[2]?.text).toContain("spawn a child"); + expect(entries[3]).toMatchObject({ role: "tool", label: "→ spawn_agent" }); + expect(entries[3]?.text).toContain('"name": "critic"'); + expect(entries[4]).toMatchObject({ role: "tool", label: "← spawn_agent" }); + expect(entries[4]?.text).toContain('"status":"completed"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("ignores non-message entries and malformed lines", () => { + const dir = mkdtempSync(join(tmpdir(), "hm-transcript-")); + try { + const file = writeSession(dir, [SESSION_HEADER, MALFORMED]); + expect(parseAgentTranscript(file)).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reads the actual provider/model from a model-change entry", () => { + const dir = mkdtempSync(join(tmpdir(), "hm-transcript-")); + try { + const file = writeSession(dir, [SESSION_HEADER, ASSISTANT_MSG]); + expect(parseAgentModel(file)).toBe("deepseek/deepseek-v4-flash"); + expect(parseAgentModel(undefined)).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("falls back to the task when the file is missing or absent", () => { + expect(parseAgentTranscript(undefined)).toEqual([]); + expect(parseAgentTranscript(undefined, "Do the thing")).toEqual([{ role: "assistant", label: "task", text: "Do the thing" }]); + expect(parseAgentTranscript("/no/such/file.jsonl", "Fallback")).toEqual([{ role: "assistant", label: "task", text: "Fallback" }]); + }); +}); + +const base: Omit = { + runId: "run-test", parentId: "root", children: [], lineage: [], depth: 0, + task: "t", taskFingerprint: "t", expectedOutput: "", completionCriteria: "", specPath: "", + createdAt: new Date().toISOString(), +}; +const agent = (id: string, status: AgentRecord["status"], parentId: string | null, depth: number, startedMin: number, finishedMin?: number): AgentRecord => { + const record: AgentRecord = { ...base, id, parentId, depth, status, startedAt: new Date(Date.now() - startedMin * 60_000).toISOString() }; + if (finishedMin !== undefined) record.finishedAt = new Date(Date.now() - finishedMin * 60_000).toISOString(); + return record; +}; + +describe("buildTreeRows", () => { + it("flattens depth-first with depth and short names", () => { + const root = agent("supervisor-91e05755", "created", null, 0, 0); + const lead = agent("arithmetic-lead-80d7cee9", "running", root.id, 1, 3); + const calc = agent("independent-calculator-39d08b41", "completed", lead.id, 2, 5, 2); + root.children = [lead.id]; lead.children = [calc.id]; + const rows = buildTreeRows([root, lead, calc], root.id, Date.now()); + expect(rows.map((row) => row.id)).toEqual([root.id, lead.id, calc.id]); + expect(rows.map((row) => row.depth)).toEqual([0, 1, 2]); + expect(rows.map((row) => row.name)).toEqual(["supervisor", "arithmetic-lead", "independent-calculator"]); + expect(rows[1]?.elapsed).toMatch(/^\d{2}:\d{2}$/); + expect(rows[0]?.elapsed).toBe(""); + expect(rows[1]?.status).toBe("running"); + expect(rows[1]?.hasSession).toBe(false); + }); + + it("computes elapsed from start to finish for completed agents", () => { + const lead = agent("arithmetic-lead-80d7cee9", "completed", "supervisor-91e05755", 1, 10, 4); + const root = agent("supervisor-91e05755", "created", null, 0, 0); + root.children = [lead.id]; + const rows = buildTreeRows([root, lead], root.id, Date.now()); + expect(rows[1]?.elapsed).toBe("06:00"); + }); +}); + +describe("renderTranscriptLines", () => { + it("adds role separators and wraps content to the width", () => { + const entries = [ + { role: "user" as const, label: "user", text: "short" }, + { role: "assistant" as const, label: "assistant", text: "word ".repeat(40) }, + ]; + const width = 30; + const lines = renderTranscriptLines(entries, width); + expect(lines[0]).toMatchObject({ kind: "separator", role: "user" }); + expect(lines[0]?.text.startsWith("── user")).toBe(true); + expect(lines[1]).toMatchObject({ kind: "content", role: "user", text: "short" }); + for (const line of lines) expect(visibleWidth(line.text)).toBeLessThanOrEqual(width); + expect(lines.filter((line) => line.role === "assistant" && line.kind === "content").length).toBeGreaterThan(2); + }); + + it("stays width-safe with long labels", () => { + const lines = renderTranscriptLines([{ role: "tool" as const, label: "← some_really_long_tool_name", text: "x" }], 20); + expect(visibleWidth(lines[0]!.text)).toBeLessThanOrEqual(20); + }); +}); + +describe("shortAgentName", () => { + it("strips the trailing 8-hex id", () => { + expect(shortAgentName("arithmetic-lead-80d7cee9")).toBe("arithmetic-lead"); + expect(shortAgentName("plain")).toBe("plain"); + }); +}); diff --git a/tests/agent-tree.test.ts b/tests/agent-tree.test.ts index 9fad777..4837b21 100644 --- a/tests/agent-tree.test.ts +++ b/tests/agent-tree.test.ts @@ -22,5 +22,5 @@ describe("AgentTree", () => { it("does not count cancelled or failed children toward the child limit", async () => { const { tree } = setup({ ...DEFAULT_CONFIG, max_children_per_agent: 2 }); const first = await tree.spawn(request(tree.rootId, "Alpha", "First concrete assignment in a limited tree")); await tree.cancel(first.id); const second = await tree.spawn(request(tree.rootId, "Beta", "Second concrete assignment in a limited tree")); await tree.cancel(second.id); await expect(tree.spawn(request(tree.rootId, "Gamma", "Third concrete assignment in a limited tree"))).resolves.toBeTruthy(); }); it("cancel does not clobber an already recorded result", async () => { const { tree } = setup(); const child = await tree.spawn(request(tree.rootId, "Done", "Complete a concrete assignment and return")); const result = await tree.start(child.id); expect(result.status).toBe("completed"); await tree.cancel(child.id); expect(tree.inspect(child.id).status).toBe("cancelled"); expect(tree.inspect(child.id).result?.status).toBe("completed"); }); it("fails agents that exceed the configured timeout", async () => { const { tree } = setup({ ...DEFAULT_CONFIG, agent_timeout_seconds: 0.05 }, 200); const child = await tree.spawn(request(tree.rootId, "Slow", "Run a deliberately slow concrete assignment")); const result = await tree.start(child.id); expect(result.status).toBe("failed"); expect(result.summary).toMatch(/timed out/); expect(tree.inspect(child.id).error).toMatch(/timed out/); }); - it("caps how many agent sessions stream at the same time", async () => { const { tree } = setup({ ...DEFAULT_CONFIG, agent_concurrency: 1 }, 40); const a = await tree.spawn(request(tree.rootId, "First", "Sequential streaming assignment number one")); const b = await tree.spawn(request(tree.rootId, "Second", "Sequential streaming assignment number two")); const [ra, rb] = await Promise.all([tree.start(a.id), tree.start(b.id)]); expect(ra.status).toBe("completed"); expect(rb.status).toBe("completed"); const startedA = Date.parse(tree.inspect(a.id).startedAt!); const startedB = Date.parse(tree.inspect(b.id).startedAt!); const finishedA = Date.parse(tree.inspect(a.id).finishedAt!); expect(startedB).toBeGreaterThanOrEqual(finishedA - 5); }); + it("caps how many agent sessions stream at the same time", async () => { const { tree } = setup({ ...DEFAULT_CONFIG, agent_concurrency: 1 }, 40); const a = await tree.spawn(request(tree.rootId, "First", "Sequential streaming assignment number one")); const b = await tree.spawn(request(tree.rootId, "Second", "Sequential streaming assignment number two")); const [ra, rb] = await Promise.all([tree.start(a.id), tree.start(b.id)]); expect(ra.status).toBe("completed"); expect(rb.status).toBe("completed"); const startedB = Date.parse(tree.inspect(b.id).startedAt!); const finishedA = Date.parse(tree.inspect(a.id).finishedAt!); expect(startedB).toBeGreaterThanOrEqual(finishedA - 5); }); }); diff --git a/tests/extension-smoke.test.ts b/tests/extension-smoke.test.ts index a2050f6..e7fb11c 100644 --- a/tests/extension-smoke.test.ts +++ b/tests/extension-smoke.test.ts @@ -2,5 +2,5 @@ import { describe, expect, it } from "vitest"; import extension from "../src/index.js"; describe("Pi extension smoke", () => { - it("loads as an extension factory and registers all commands", () => { const commands: string[] = []; const events: string[] = []; const renderers: string[] = []; const fakePi = { on: (name: string) => events.push(name), registerCommand: (name: string) => commands.push(name), registerMessageRenderer: (name: string) => renderers.push(name) } as any; extension(fakePi); expect(commands).toEqual(expect.arrayContaining(["team", "research", "research-status", "research-pause", "research-resume", "research-stop", "findings", "hypotheses"])); expect(events).toEqual(expect.arrayContaining(["session_start", "session_shutdown"])); expect(renderers).toContain("hypothesis-machine-agent-update"); }); + it("loads as an extension factory and registers all commands", () => { const commands: string[] = []; const events: string[] = []; const renderers: string[] = []; const fakePi = { on: (name: string) => events.push(name), registerCommand: (name: string) => commands.push(name), registerMessageRenderer: (name: string) => renderers.push(name) } as any; extension(fakePi); expect(commands).toEqual(expect.arrayContaining(["team", "agent", "agent-open", "research", "research-status", "research-pause", "research-resume", "research-stop", "findings", "hypotheses"])); expect(events).toEqual(expect.arrayContaining(["session_start", "session_shutdown"])); expect(renderers).toContain("hypothesis-machine-agent-update"); }); }); diff --git a/tests/pi-agent-runtime.integration.test.ts b/tests/pi-agent-runtime.integration.test.ts index c7ae80e..e4344d1 100644 --- a/tests/pi-agent-runtime.integration.test.ts +++ b/tests/pi-agent-runtime.integration.test.ts @@ -18,8 +18,9 @@ describe("PiAgentRuntimeFactory", () => { const model: Model = { id: "fake-model", name: "Fake model", api: "openai-completions", provider: "hm-fake", baseUrl: "http://invalid.test", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32_000, maxTokens: 2_000 }; const runtime = await ModelRuntime.create({ authPath: resolve(cwd, "auth.json"), modelsPath: null }); runtime.registerNativeProvider({ id: "hm-fake", name: "HM fake provider", auth: { apiKey: { name: "fake", resolve: async () => ({ auth: { apiKey: "not-a-real-secret" }, source: "test" }) } }, getModels: () => [model], stream: () => { throw new Error("simple stream expected"); }, streamSimple: () => { const stream = createAssistantMessageEventStream(); const message: AssistantMessage = { role: "assistant", content: [{ type: "text", text: "fake child result" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 1, output: 3, cacheRead: 0, cacheWrite: 0, totalTokens: 4, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: Date.now() }; queueMicrotask(() => stream.end(message)); return stream; } }); - const stateDir = resolve(cwd, ".hypothesis-machine"); const store = new RunStore(stateDir); const memory = new ResearchMemory(stateDir); const web = new WebGateway(DEFAULT_CONFIG, memory); const experiments = new ExperimentRunner(stateDir, DEFAULT_CONFIG.experiment); - const factory = new PiAgentRuntimeFactory({ cwd, config: DEFAULT_CONFIG, store, memory, web, experiments, modelRuntime: runtime, model, thinkingLevel: "off" }); const tree = new AgentTree(store, factory, DEFAULT_CONFIG, { goal: "Test the official child runtime" }); factory.attachTree(tree); + const config = { ...DEFAULT_CONFIG, subagent_model: "hm-fake/fake-model" }; + const stateDir = resolve(cwd, ".hypothesis-machine"); const store = new RunStore(stateDir); const memory = new ResearchMemory(stateDir); const web = new WebGateway(config, memory); const experiments = new ExperimentRunner(stateDir, config.experiment); + const factory = new PiAgentRuntimeFactory({ cwd, config, store, memory, web, experiments, modelRuntime: runtime, thinkingLevel: "off" }); const tree = new AgentTree(store, factory, config, { goal: "Test the official child runtime" }); factory.attachTree(tree); const child = await tree.spawn({ parentId: tree.rootId, name: "Runtime Child", role: "Runtime verifier", task: "Return the deterministic fake model response", expectedOutput: "Text result", completionCriteria: "A response is persisted", tools: [], background: false }); const result = await tree.start(child.id); expect(result.summary).toBe("fake child result"); expect(tree.inspect(child.id).sessionFile).toMatch(/\.jsonl$/); expect(tree.inspect(child.id).status).toBe("completed"); memory.close(); }); diff --git a/tests/subagent-model.test.ts b/tests/subagent-model.test.ts new file mode 100644 index 0000000..31d9eb5 --- /dev/null +++ b/tests/subagent-model.test.ts @@ -0,0 +1,26 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { AgentTree } from "../src/agent-tree.js"; +import { readAgentSpec } from "../src/agent-spec.js"; +import { DEFAULT_CONFIG, DEFAULT_SUBAGENT_MODEL } from "../src/config.js"; +import { RunStore } from "../src/run-store.js"; +import { createResearchTools } from "../src/tools/index.js"; +import { FakeRuntimeFactory } from "./helpers.js"; + +describe("subagent model selection", () => { + it("pins spawned agents to DeepSeek V4 Flash instead of the caller model", async () => { + const cwd = mkdtempSync(resolve(tmpdir(), "hm-subagent-model-")); + const store = new RunStore(cwd); + const tree = new AgentTree(store, new FakeRuntimeFactory(), DEFAULT_CONFIG, { goal: "Test subagent model routing" }); + const spawn = createResearchTools({ tree, parentId: tree.rootId, memory: {} as any, web: {} as any, experiments: {} as any, cwd }).find((tool) => tool.name === "spawn_agent")!; + + await spawn.execute("spawn-1", { + name: "Direct API verifier", role: "routing verifier", task: "Verify that subagents use the configured direct model", expected_output: "Agent specification", completion_criteria: "Model is pinned", background: false, + }, undefined, undefined, { model: { provider: "openai", id: "gpt-5.6-terra" }, thinkingLevel: "high" } as any); + + const child = tree.list().find((agent) => agent.id !== tree.rootId)!; + expect(readAgentSpec(child.specPath).model).toBe(DEFAULT_SUBAGENT_MODEL); + }); +});