384 lines
13 KiB
JavaScript
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 };
|