Files
2026-07-13 12:20:32 +08:00

384 lines
13 KiB
JavaScript

const { settings } = require("../config");
const { workspace } = require("../workspace");
const { broadcast } = require("../broadcast");
const {
launchPiProcess,
SYSTEM_PROMPT,
cleanupDesktop,
writeTrace,
} = require("../pi/process");
const {
checkForLoop,
checkForToolLoop,
resetLoopDetector,
onToolCall,
onTextMessage,
alertLoopingAgent,
} = require("../loop-detect");
// ─── Token usage tracking ──────────────────────────────────────────────────
// Tracks one logical task's LLM usage. Reset on each new launchPiAgent call.
// Rough estimate: 1 token ≈ 4 chars (English); provider-reported counts take
// precedence when available.
function estimateTokens(text) {
return text ? Math.ceil(text.length / 4) : 0;
}
function freshUsage() {
return {
turns: 0,
toolCalls: 0,
toolCallsByName: {},
inputTokensReported: 0,
outputTokensReported: 0,
inputTokensEstimated: 0,
outputTokensEstimated: 0,
toolResultCharsTotal: 0,
toolArgCharsTotal: 0,
systemPromptTokens: 0,
startedAt: Date.now(),
};
}
let taskUsage = freshUsage();
function getTaskUsage() {
return taskUsage;
}
// ─── Extension info-notification patterns ─────────────────────────────────
// Auto-dismiss these from pi-hermes-memory and similar extensions so they
// don't surface as interactive "Agent needs your input" prompts.
const EXTENSION_INFO_PATTERNS = [
/session backfill/i,
/memory.sync/i,
/backfill complete/i,
/indexed.*skipped.*messages/i,
/markdown.*sync complete/i,
];
// ─── Hypervisor RPC event handler ─────────────────────────────────────────
// Processes all JSON-lines events from the main pi agent. Handles:
// - Text streaming (message_start, message_update, message_end)
// - Tool tracking and loop detection (tool_execution_start/end)
// - User help requests from extensions (extension_ui_request)
// - Usage accounting (response)
function handleRpcEvent(line) {
let event;
try {
event = JSON.parse(line);
} catch {
process.stdout.write(`[pi] ${line}\n`);
return;
}
const ts = new Date().toISOString();
const etype = event.type || event.event;
writeTrace({ ts, event: "raw_rpc", type: etype, raw: event });
if (event.usage) {
taskUsage.inputTokensReported +=
event.usage.input_tokens || event.usage.prompt_tokens || 0;
taskUsage.outputTokensReported +=
event.usage.output_tokens || event.usage.completion_tokens || 0;
console.log(
`[tokens] Reported usage: in=${event.usage.input_tokens || event.usage.prompt_tokens || 0} out=${event.usage.output_tokens || event.usage.completion_tokens || 0}`,
);
}
// Only advance the activity timestamp for events that indicate active work.
// Terminal events (response, streaming_end) must not re-arm the idle detector.
const passiveEvents = new Set(["response", "streaming_end", "agent_status"]);
if (!passiveEvents.has(etype)) {
workspace.lastActivityTs = Date.now();
}
switch (etype) {
case "message_start": {
taskUsage.turns++;
onTextMessage();
const msg = event.message;
if (msg?.role === "assistant" && msg?.content) {
const fullText = msg.content.map((p) => p.text || "").join("");
for (const part of msg.content) {
if (part.type === "text" && part.text) {
broadcast({ type: "agent_log", content: part.text });
broadcast({ type: "chat_message", content: part.text, role: "assistant" });
workspace.lastAgentText = part.text;
}
}
taskUsage.outputTokensEstimated += estimateTokens(fullText);
writeTrace({ ts, event: "message_start", role: msg.role, content: msg.content });
if (checkForLoop(fullText)) {
alertLoopingAgent("repetitive text in message_start");
return;
}
}
break;
}
case "message_end": {
if (workspace._textBuf && workspace._textBuf.trim()) {
broadcast({ type: "agent_delta", content: workspace._textBuf.trim() });
workspace._textBuf = "";
}
break;
}
case "message_update": {
const ae = event.assistantMessageEvent;
if (!ae) break;
if (ae.type === "text_delta" && ae.delta) {
workspace._textBuf = (workspace._textBuf || "") + ae.delta;
taskUsage.outputTokensEstimated += estimateTokens(ae.delta);
if (/[.!?\n]$/.test(workspace._textBuf.trim())) {
const flushed = workspace._textBuf.trim();
broadcast({ type: "agent_delta", content: flushed });
broadcast({ type: "chat_message", content: flushed, role: "assistant" });
workspace.lastAgentText = flushed;
workspace._textBuf = "";
if (checkForLoop(flushed)) {
alertLoopingAgent("repetitive text in streaming deltas");
return;
}
}
}
break;
}
case "tool_execution_start": {
if (workspace._textBuf && workspace._textBuf.trim()) {
broadcast({ type: "agent_delta", content: workspace._textBuf.trim() });
workspace._textBuf = "";
}
onToolCall();
if (event.toolName) {
taskUsage.toolCalls++;
taskUsage.toolCallsByName[event.toolName] =
(taskUsage.toolCallsByName[event.toolName] || 0) + 1;
const argsJson = event.args ? JSON.stringify(event.args) : "";
taskUsage.toolArgCharsTotal += argsJson.length;
taskUsage.outputTokensEstimated += estimateTokens(argsJson);
const loopReason = checkForToolLoop(event.toolName, argsJson);
if (loopReason) alertLoopingAgent(loopReason);
const argsStr = argsJson.slice(0, 300);
const summary =
event.toolName === "bash"
? `$ ${(event.args?.command || "").slice(0, 300)}`
: `${event.toolName}(${argsStr})`;
broadcast({ type: "agent_log", content: `[running] ${summary}` });
broadcast({ type: "chat_tool_hint", tool: event.toolName, summary });
writeTrace({ ts, event: "tool_start", tool: event.toolName, args: event.args });
}
break;
}
case "tool_execution_end": {
if (event.toolName) {
let resultFull = "";
if (event.result?.content) {
for (const part of event.result.content) {
if (part.type === "text" && part.text) resultFull += part.text;
}
}
taskUsage.toolResultCharsTotal += resultFull.length;
taskUsage.inputTokensEstimated += estimateTokens(resultFull);
if (resultFull) {
const PREVIEW = 1500;
const preview =
resultFull.length > PREVIEW
? `${resultFull.slice(0, PREVIEW)}… (+${resultFull.length - PREVIEW} chars)`
: resultFull;
broadcast({ type: "agent_log", content: `[result] ${preview}` });
}
writeTrace({
ts,
event: "tool_end",
tool: event.toolName,
result: resultFull,
resultChars: resultFull.length,
});
}
break;
}
case "extension_ui_request": {
if (
event.method === "input" ||
event.method === "confirm" ||
event.method === "select"
) {
console.log(`[pi] extension_ui_request: ${JSON.stringify(event)}`);
const question =
event.placeholder || event.message || event.prompt ||
"The agent has a question.";
// Auto-dismiss informational extension notifications (e.g. memory
// backfill status) without surfacing them to the user.
const isInfoNotification = EXTENSION_INFO_PATTERNS.some((p) =>
p.test(question),
);
if (isInfoNotification) {
console.log(`[pi] Auto-dismissed extension notification: ${question}`);
broadcast({ type: "agent_log", content: `[extension] ${question}` });
if (workspace.piRpc) {
workspace.piRpc.write(
JSON.stringify({
type: "extension_ui_response",
id: event.id,
value: "",
}) + "\n",
);
}
break;
}
workspace.pendingHelp = { id: event.id, method: event.method };
broadcast({
type: "ask_for_help",
requestId: event.id,
method: event.method,
content: question,
title: event.title || "Agent Question",
});
console.log(`[pi] User question: ${question}`);
}
break;
}
case "response": {
// "response" means pi finished processing the current prompt — NOT that
// the overall session is done. The user controls session lifetime.
if (event.success === false && event.error) {
const errMsg = String(event.error);
broadcast({ type: "agent_log", content: `[error] ${errMsg}` });
broadcast({ type: "agent_error", errorType: "pi_error", content: errMsg });
}
taskUsage.elapsedMs = Date.now() - taskUsage.startedAt;
if (taskUsage.turns > 0 || taskUsage.toolCalls > 0) {
const usageLine =
`[usage] turns=${taskUsage.turns} tools=${taskUsage.toolCalls} | ` +
`reported(in=${taskUsage.inputTokensReported} out=${taskUsage.outputTokensReported}) ` +
`est(in=${taskUsage.inputTokensEstimated} out=${taskUsage.outputTokensEstimated}) | ` +
`resultChars=${taskUsage.toolResultCharsTotal} argChars=${taskUsage.toolArgCharsTotal} | ` +
Object.entries(taskUsage.toolCallsByName)
.map(([k, v]) => `${k}:${v}`)
.join(" ");
console.log(`[tokens] ${usageLine}`);
broadcast({ type: "agent_log", content: usageLine });
writeTrace({ ts, event: "usage_summary", usage: { ...taskUsage } });
broadcast({ type: "task_usage", usage: { ...taskUsage } });
}
break;
}
case "streaming_start":
broadcast({ type: "agent_log", content: "[thinking...]" });
break;
case "streaming_end":
case "agent_status":
break;
default:
break;
}
}
// ─── Hypervisor agent launcher ─────────────────────────────────────────────
// Starts the main pi agent for a new task. If a prior subagent run exists,
// its context is prepended so the agent can continue the thread.
function launchPiAgent(prompt) {
const sessionId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
workspace.currentSessionId = sessionId;
broadcast({ type: "agent_log", content: "Starting Pi agent (RPC mode)..." });
let effectivePrompt = prompt;
if (workspace.subagentContext) {
const ctx = workspace.subagentContext;
const outputSummary = ctx.outputs
.map((o) => `- ${o.title}: ${o.output}`)
.join("\n");
effectivePrompt =
`[Previous task context — the user's original request was handled by subagents. ` +
`Original: "${ctx.originalPrompt.slice(0, 300)}"\n` +
`Subagent results:\n${outputSummary}\n` +
`Final answer given:\n${ctx.synthesis}]\n\n` +
`User follow-up: ${prompt}`;
workspace.subagentContext = null;
}
try {
launchPiProcess({
label: "pi",
sessionId,
extensions: [
"ask-user.ts",
"save-deliverable.ts",
"open-browser.ts",
"browser-agent.ts",
"browser-cdp.ts",
"visible-bash.ts",
"desktop-apps.ts",
],
systemPrompt: SYSTEM_PROMPT,
prompt: effectivePrompt,
autoCompaction: true,
onSpawn(proc) {
workspace.piProcess = proc;
workspace.piRpc = proc.stdin;
workspace.pendingHelp = null;
workspace.lastActivityTs = Date.now();
workspace.lastAgentText = "";
resetLoopDetector();
taskUsage = freshUsage();
taskUsage.systemPromptTokens = estimateTokens(SYSTEM_PROMPT);
},
onEvent(line) {
handleRpcEvent(line);
},
onClose(code, proc) {
if (workspace.piProcess !== proc) return;
workspace.lastExitCode = code;
// Detect silent failures: agent exited abnormally without producing
// any visible output — common with context-overflow on local models.
if (code !== 0 && !workspace.lastAgentText) {
const errMsg =
`Agent process exited with code ${code} without producing output. ` +
`This often means the context window was exceeded or the LLM endpoint ` +
`is unreachable. Check the LMStudio / Ollama server and model settings.`;
console.error(`[pi] ${errMsg}`);
broadcast({ type: "agent_error", errorType: "silent_exit", content: errMsg });
}
broadcast({ type: "agent_done", code });
workspace.piProcess = null;
workspace.piRpc = null;
workspace.pendingHelp = null;
workspace.pendingPlan = null;
cleanupDesktop();
},
});
} catch (err) {
console.error(`[pi] Failed to launch agent:`, err.message);
broadcast({
type: "agent_log",
content: `[error] Failed to launch agent: ${err.message}`,
});
workspace.piProcess = null;
workspace.piRpc = null;
}
}
module.exports = { launchPiAgent, handleRpcEvent, getTaskUsage, estimateTokens, freshUsage };