feat: pin research subagents to DeepSeek Flash
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <goal>` — start the explicit bounded loop;
|
||||
- `/research-status`, `/research-pause`, `/research-resume`, `/research-stop`;
|
||||
- `/findings`, `/hypotheses`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<AgentStatus, string> = {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>): TranscriptEntry | undefined {
|
||||
const message = parsed.message as Record<string, unknown> | 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<string, unknown>).text === "string" ? (part as Record<string, unknown>).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<string, unknown>;
|
||||
try { parsed = JSON.parse(line) as Record<string, unknown>; } 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<string, unknown>;
|
||||
try { parsed = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
||||
if (parsed.type !== "message") continue;
|
||||
const message = parsed.message as Record<string, unknown> | 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<string, { mtimeMs: number; entries: TranscriptEntry[] }>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -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<HypothesisMachineConfig>;
|
||||
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;
|
||||
}
|
||||
|
||||
+3
-1
@@ -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 <goal>", "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";
|
||||
|
||||
+9
-5
@@ -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);
|
||||
|
||||
+35
-10
@@ -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<AgentStatus, string> = {
|
||||
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 (`<stateDir>/runs/<runId>/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<void> {
|
||||
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<AgentChatPanelResult>((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<void> { if (this.widgetTimer) { clearInterval(this.widgetTimer); this.widgetTimer = undefined; } this.requestAgentRender = undefined; await this.tree?.shutdown(); this.memory?.close(); this.tree = undefined; }
|
||||
}
|
||||
|
||||
+2
-1
@@ -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.",
|
||||
|
||||
@@ -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<AgentRecord, "id" | "status"> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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); });
|
||||
});
|
||||
|
||||
@@ -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"); });
|
||||
});
|
||||
|
||||
@@ -18,8 +18,9 @@ describe("PiAgentRuntimeFactory", () => {
|
||||
const model: Model<any> = { 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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user