/**
* Agent Loop — local-first agentic CLI with server tool support
*
* LLM calls proxy through the `agent-proxy` edge function (server holds API key).
* User authenticates via Supabase JWT. Local tools execute on the client.
* Server tools execute via direct import of executeTool() (same codebase).
*
* Fallback: if proxy is unavailable and ANTHROPIC_API_KEY is set, calls directly.
*
* This file is the thin orchestrator + re-export facade. Domain logic lives in:
* - memory-manager.ts (loadMemory, addMemory, removeMemory, listMemories)
* - git-context.ts (gatherGitContext, refreshGitContext)
* - claude-md-loader.ts (loadClaudeMd, reloadClaudeMd)
* - permission-modes.ts (PermissionMode, set/get/isAllowed)
* - model-manager.ts (setModel, getModel, getModelShortName, estimateCostUsd)
* - session-persistence.ts (SessionMeta, save/load/list/find sessions)
* - system-prompt.ts (buildSystemPrompt)
*/
import type Anthropic from "@anthropic-ai/sdk";
import {
LOCAL_TOOL_DEFINITIONS,
executeLocalTool,
isLocalTool,
} from "./local-tools.js";
import {
INTERACTIVE_TOOL_DEFINITIONS,
executeInteractiveTool,
} from "./interactive-tools.js";
import { loadConfig, resolveConfig, getProxyUrl } from "./config-store.js";
import { getValidToken } from "./auth-service.js";
import {
isServerTool,
loadServerToolDefinitions,
executeServerTool,
getServerStatus,
type ServerStatus,
} from "./server-tools.js";
import {
nextTurn,
createTurnContext,
logSpan,
generateSpanId,
} from "./telemetry.js";
import {
AgentEventEmitter,
setGlobalEmitter,
clearGlobalEmitter,
} from "./agent-events.js";
import { mcpClientManager } from "./mcp-client.js";
import { loadHooks, runBeforeToolHook, runAfterToolHook, runSessionHook, type HookConfig } from "./hooks.js";
import { LoopDetector } from "../../shared/agent-core.js";
import { parseSSEStream, processStreamWithCallbacks, collectStreamResult } from "../../shared/sse-parser.js";
import { callServerProxy, buildAPIRequest, buildSystemBlocks, prepareWithCaching, trimGeminiContext, trimOpenAIContext } from "../../shared/api-client.js";
import { getProvider, MODELS } from "../../shared/constants.js";
import { dispatchTools, buildAssistantContent } from "../../shared/tool-dispatch.js";
import type { StreamResult as SharedStreamResult, ToolResultMessage } from "../../shared/types.js";
// ── Extracted modules (re-exported below for backward compatibility) ──
import { loadMemory, addMemory, removeMemory, listMemories } from "./memory-manager.js";
import { refreshGitContext, resetGitContext } from "./git-context.js";
import { loadClaudeMd, reloadClaudeMd, resetClaudeMdCache } from "./claude-md-loader.js";
import { setPermissionMode, getPermissionMode, isToolAllowedByPermission, type PermissionMode } from "./permission-modes.js";
import { setModel, setModelById, getModel, getModelShortName, estimateCostUsd } from "./model-manager.js";
import { saveSession, loadSession, listSessions, findLatestSessionForCwd, type SessionMeta } from "./session-persistence.js";
import { buildSystemPrompt } from "./system-prompt.js";
// ============================================================================
// RE-EXPORTS — all consumers keep importing from agent-loop.ts
// ============================================================================
// Memory
export { loadMemory, addMemory, removeMemory, listMemories };
// Git context
export { refreshGitContext };
// CLAUDE.md
export { loadClaudeMd, reloadClaudeMd };
// Permission modes
export { setPermissionMode, getPermissionMode, isToolAllowedByPermission, type PermissionMode };
// Model management
export { setModel, getModel, getModelShortName, estimateCostUsd };
// Session persistence
export { saveSession, loadSession, listSessions, findLatestSessionForCwd, type SessionMeta };
// Server status (pass-through)
export { getServerStatus, type ServerStatus };
// MCP client manager
export { mcpClientManager };
// Re-export background process listing for /tasks command
export { listProcesses, listBackgroundAgents } from "./background-processes.js";
// Re-export event emitter for ChatApp
export { AgentEventEmitter, type AgentEvent } from "./agent-events.js";
// ============================================================================
// TYPES
// ============================================================================
export interface AgentLoopCallbacks {
onText: (text: string) => void;
onToolStart: (name: string, input?: Record<string, unknown>) => void;
onToolResult: (name: string, success: boolean, result: unknown, input?: Record<string, unknown>, durationMs?: number) => void;
onUsage: (input_tokens: number, output_tokens: number, thinking_tokens?: number, model?: string, costUsd?: number, cacheReadTokens?: number, cacheCreationTokens?: number) => void;
onDone: (finalMessages: Anthropic.MessageParam[]) => void;
onError: (error: string, partialMessages?: Anthropic.MessageParam[]) => void;
onAutoCompact?: (beforeMessages: number, afterMessages: number, tokensSaved: number) => void;
}
export interface AgentLoopOptions {
message: string;
images?: { base64: string; mediaType: string }[]; // Image attachments (base64-encoded)
conversationHistory: Anthropic.MessageParam[];
callbacks: AgentLoopCallbacks;
abortSignal?: AbortSignal;
model?: string;
emitter?: AgentEventEmitter; // Event emitter for decoupled UI
// v4.7.0 extensions
maxTurns?: number;
maxBudgetUsd?: number;
effort?: "low" | "medium" | "high";
allowedTools?: string[];
disallowedTools?: string[];
fallbackModel?: string;
// v5.1.0 — extended thinking
thinking?: boolean;
// v6.1.0 — shell output summarization
shellSummarization?: boolean; // default: true
// v6.1.0 — working directory for hooks
cwd?: string;
}
// ============================================================================
// SESSION STATE
// ============================================================================
// CLI-only: Session-wide token tracking (actual counts from API responses).
// Reset via resetSessionState() when starting a new conversation.
let sessionInputTokens = 0;
let sessionOutputTokens = 0;
export function getSessionTokens(): { input: number; output: number } {
return { input: sessionInputTokens, output: sessionOutputTokens };
}
/**
* Reset all CLI-only session state. Call when starting a new conversation
* (e.g., /clear command, new print-mode run) to prevent stale token counts,
* loop detector state, and caches from leaking across sessions.
*
* Does NOT reset activeModel or permissionMode — those are user preferences
* that persist intentionally until explicitly changed.
*/
export function resetSessionState(): void {
sessionInputTokens = 0;
sessionOutputTokens = 0;
sessionLoopDetector = null;
resetGitContext();
resetClaudeMdCache();
}
/** CLI-only: loop detector — persists session error state across turns (reset by resetSessionState) */
let sessionLoopDetector: LoopDetector | null = null;
const MAX_TURNS = 200; // Match Claude Code — effectively unlimited within a session
// ============================================================================
// SHELL OUTPUT SUMMARIZATION
// ============================================================================
const SHELL_SUMMARIZE_LINE_THRESHOLD = 200;
const SHELL_SUMMARIZE_SIZE_THRESHOLD = 50_000; // 50KB
const SHELL_SUMMARIZE_MAX_INPUT = 100_000; // 100KB max to summarizer
const SHELL_SUMMARIZE_ORIGINAL_PREVIEW_LINES = 20;
/**
* Check if shell output should be summarized based on line count or size.
*/
function shouldSummarizeShellOutput(output: string): boolean {
if (output.length > SHELL_SUMMARIZE_SIZE_THRESHOLD) return true;
const lineCount = output.split("\n").length;
return lineCount > SHELL_SUMMARIZE_LINE_THRESHOLD;
}
/**
* Summarize long shell output using Haiku via server proxy.
* Returns summarized output or original if summarization fails.
*/
async function summarizeShellOutput(
output: string,
proxyUrl: string,
token: string,
storeId?: string,
): Promise<string> {
const lineCount = output.split("\n").length;
const truncatedForSummary = output.length > SHELL_SUMMARIZE_MAX_INPUT
? output.slice(0, SHELL_SUMMARIZE_MAX_INPUT) + "\n... (truncated for summarization)"
: output;
try {
const summaryConfig = buildAPIRequest({
model: MODELS.HAIKU,
contextProfile: "subagent",
});
const stream = await callServerProxy({
proxyUrl,
token,
model: MODELS.HAIKU,
system: [{ type: "text", text: "You are a concise technical summarizer. Summarize shell/command output preserving key information, errors, warnings, file paths, and actionable items. Be brief but thorough." }],
messages: [{ role: "user", content: `Summarize this shell output concisely, preserving key information, errors, and actionable items:\n\n${truncatedForSummary}` }],
tools: [],
apiConfig: summaryConfig,
storeId,
timeoutMs: 15_000,
});
const result = await collectStreamResult(parseSSEStream(stream));
const summary = result.text.trim();
if (!summary) return output; // Summarization failed, return original
// Build first N lines preview
const originalLines = output.split("\n");
const preview = originalLines.slice(0, SHELL_SUMMARIZE_ORIGINAL_PREVIEW_LINES).join("\n");
return `[Summarized from ${lineCount} lines]\n\n${summary}\n\n[First ${SHELL_SUMMARIZE_ORIGINAL_PREVIEW_LINES} lines of original output]\n${preview}`;
} catch {
// Summarization failed silently — return original output
return output;
}
}
/**
* Post-process tool results to summarize long bash output.
* Only affects bash tool results that exceed size/line thresholds.
*/
async function summarizeLongToolResults(
toolResults: ToolResultMessage[],
toolNames: Map<string, string>,
proxyUrl: string,
token: string,
shellSummarization: boolean,
storeId?: string,
): Promise<ToolResultMessage[]> {
if (!shellSummarization) return toolResults;
const tasks = toolResults.map(async (result) => {
// Only summarize bash tool string results
const toolName = toolNames.get(result.tool_use_id);
if (toolName !== "bash" || typeof result.content !== "string") return result;
// Check thresholds
if (!shouldSummarizeShellOutput(result.content)) return result;
const summarized = await summarizeShellOutput(result.content, proxyUrl, token, storeId);
return { ...result, content: summarized };
});
return Promise.all(tasks);
}
// ============================================================================
// TOOL DEFINITIONS
// ============================================================================
async function getTools(allowedTools?: string[], disallowedTools?: string[]): Promise<{ tools: Anthropic.Tool[]; serverToolCount: number }> {
const localTools: Anthropic.Tool[] = LOCAL_TOOL_DEFINITIONS.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.input_schema as Anthropic.Tool["input_schema"],
}));
// Add interactive tools (ask_user_question, enter_plan_mode, exit_plan_mode)
const interactiveTools: Anthropic.Tool[] = INTERACTIVE_TOOL_DEFINITIONS.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.input_schema as Anthropic.Tool["input_schema"],
}));
localTools.push(...interactiveTools);
let serverTools: Anthropic.Tool[] = [];
try {
serverTools = await loadServerToolDefinitions();
} catch {
// Server tools silently unavailable
}
// Deduplicate: local tools take priority over server tools with the same name
const localNames = new Set(localTools.map(t => t.name));
const uniqueServerTools = serverTools.filter(t => !localNames.has(t.name));
// MCP tools from external servers
const mcpTools = mcpClientManager.getTools();
let allTools = [...localTools, ...uniqueServerTools, ...mcpTools];
// Apply tool filtering
if (allowedTools && allowedTools.length > 0) {
const allowed = new Set(allowedTools);
allTools = allTools.filter(t => allowed.has(t.name));
}
if (disallowedTools && disallowedTools.length > 0) {
const disallowed = new Set(disallowedTools);
allTools = allTools.filter(t => !disallowed.has(t.name));
}
return {
tools: allTools,
serverToolCount: uniqueServerTools.length,
};
}
/** Exposed for /status command */
export async function getServerToolCount(): Promise<number> {
try {
const defs = await loadServerToolDefinitions();
return defs.length;
} catch {
return 0;
}
}
// ============================================================================
// MAIN LOOP
// ============================================================================
export async function runAgentLoop(opts: AgentLoopOptions): Promise<void> {
const { message, conversationHistory, callbacks, abortSignal, emitter } = opts;
if (opts.model) setModel(opts.model);
// Set global emitter for subagents to use
if (emitter) {
setGlobalEmitter(emitter);
}
const effectiveMaxTurns = opts.maxTurns || MAX_TURNS;
// Load hooks from project and user config
const hooksCwd = opts.cwd || process.cwd();
const hooks = loadHooks(hooksCwd);
// Fire SessionStart hook (non-blocking)
if (hooks.length > 0) {
runSessionHook(hooks, "SessionStart", { session_id: `turn-${Date.now()}` }).catch(() => {});
}
// Shell summarization config (default: true)
const shellSummarization = opts.shellSummarization !== false;
const { tools, serverToolCount } = await getTools(opts.allowedTools, opts.disallowedTools);
const systemPrompt = await buildSystemPrompt(serverToolCount > 0, opts.effort);
// Build user content — text-only string or content blocks array with images
let userContent: string | Anthropic.ContentBlockParam[];
if (opts.images && opts.images.length > 0) {
const blocks: Anthropic.ContentBlockParam[] = [];
for (const img of opts.images) {
blocks.push({
type: "image",
source: {
type: "base64",
media_type: img.mediaType as "image/png" | "image/jpeg" | "image/gif" | "image/webp",
data: img.base64,
},
} as any);
}
blocks.push({ type: "text", text: message || "(see attached images)" });
userContent = blocks;
} else {
userContent = message;
}
const messages: Anthropic.MessageParam[] = [
...conversationHistory,
{ role: "user", content: userContent },
];
// Session-level loop detector: persists failed strategies across turns.
// Created once per conversation, reset only when user starts a new conversation.
if (!sessionLoopDetector || conversationHistory.length === 0) {
sessionLoopDetector = new LoopDetector();
}
const loopDetector = sessionLoopDetector;
loopDetector.resetTurn();
let totalIn = 0;
let totalOut = 0;
let totalCacheCreation = 0;
let totalCacheRead = 0;
let totalThinking = 0;
let allAssistantText: string[] = [];
let prevIterationInputTokens = 0; // Tracks actual context size from last API call
// Telemetry: one turn per user message (not per API call)
const sessionStart = Date.now();
const { storeId } = resolveConfig();
const turnNum = nextTurn(); // ONCE per user message
const turnCtx = createTurnContext({ model: getModel(), turnNumber: turnNum });
logSpan({
action: "chat.user_message",
durationMs: 0,
context: turnCtx,
storeId: storeId || undefined,
details: {
message: message,
conversation_history_length: conversationHistory.length,
},
});
let sessionCostUsd = 0;
const activeModel = getModel();
// Tool executor — routes to interactive, local, server, or MCP tools.
// Wraps execution with before/after hooks when hooks are loaded.
const INTERACTIVE_TOOL_NAMES = new Set(INTERACTIVE_TOOL_DEFINITIONS.map(t => t.name));
const toolExecutor = async (name: string, input: Record<string, unknown>): Promise<{ success: boolean; output: string }> => {
if (!name) {
return { success: false, output: "Tool call missing name — skipping." };
}
// Permission mode enforcement
if (!isToolAllowedByPermission(name)) {
return { success: false, output: `Tool "${name}" blocked by ${getPermissionMode()} mode. Switch modes with /mode.` };
}
// BeforeTool hook — may block or modify input
let effectiveInput = input;
if (hooks.length > 0) {
const hookResult = await runBeforeToolHook(hooks, name, input);
if (!hookResult.allow) {
return { success: false, output: hookResult.message || "Blocked by hook" };
}
if (hookResult.modifiedInput) {
effectiveInput = hookResult.modifiedInput;
}
}
let result: { success: boolean; output: string };
if (INTERACTIVE_TOOL_NAMES.has(name)) {
result = await executeInteractiveTool(name, effectiveInput);
} else if (isLocalTool(name)) {
result = await executeLocalTool(name, effectiveInput);
} else if (isServerTool(name)) {
result = await executeServerTool(name, effectiveInput);
} else if (mcpClientManager.isMcpTool(name)) {
result = await mcpClientManager.callTool(name, effectiveInput);
} else {
result = { success: false, output: `Unknown tool: ${name}` };
}
// AfterTool hook — may modify output
if (hooks.length > 0) {
const afterResult = await runAfterToolHook(hooks, name, result.output, result.success);
if (afterResult.modifiedOutput !== undefined) {
result = { ...result, output: afterResult.modifiedOutput };
}
}
return result;
};
// Effort-aware truncation limits
const maxResultChars = opts.effort === "low" ? 10_000 : opts.effort === "high" ? 30_000 : 20_000;
try {
for (let iteration = 0; iteration < effectiveMaxTurns; iteration++) {
if (abortSignal?.aborted) {
logSpan({ action: "chat.cancelled", durationMs: Date.now() - sessionStart, context: turnCtx, storeId: storeId || undefined, details: { iteration, reason: "user_abort" } });
callbacks.onError("Cancelled", messages);
return;
}
// Budget enforcement
if (opts.maxBudgetUsd && sessionCostUsd >= opts.maxBudgetUsd) {
logSpan({ action: "chat.budget_exceeded", durationMs: Date.now() - sessionStart, context: turnCtx, storeId: storeId || undefined, severity: "warn", details: { session_cost_usd: sessionCostUsd, max_budget_usd: opts.maxBudgetUsd, iteration } });
callbacks.onError(`Budget exceeded: $${sessionCostUsd.toFixed(4)} >= $${opts.maxBudgetUsd}`, messages);
return;
}
const apiStart = Date.now();
const apiSpanId = generateSpanId();
const costContext = `Session cost: $${sessionCostUsd.toFixed(2)}${opts.maxBudgetUsd ? ` | Budget remaining: $${(opts.maxBudgetUsd - sessionCostUsd).toFixed(2)}` : ""}`;
// Build API request config
const currentModel = getModel();
const apiConfig = buildAPIRequest({
model: currentModel,
contextProfile: "main",
thinkingEnabled: opts.thinking,
});
// Prepare with prompt caching
let { tools: cachedTools, messages: cachedMessages } = prepareWithCaching(tools as any, messages as any);
const system = buildSystemBlocks(systemPrompt, costContext);
// Client-side context trimming for non-Anthropic providers.
// Uses prevIterationInputTokens (actual context size from last API call) — NOT
// cumulative sessionInputTokens, which grows quadratically and would trigger too early.
const provider = getProvider(currentModel);
if (provider === "gemini" || provider === "openai") {
const preTrimMessages = cachedMessages;
if (provider === "gemini") {
cachedMessages = trimGeminiContext(cachedMessages, prevIterationInputTokens);
} else {
// GPT-4o: 128K context → 96K threshold. GPT-5/o3/o4-mini: 200K+ → 150K threshold.
const threshold = currentModel === "gpt-4o" ? 96_000 : 150_000;
cachedMessages = trimOpenAIContext(cachedMessages, prevIterationInputTokens, threshold);
}
// Notify UI when trimming actually occurred (trim returns same ref if no-op)
if (cachedMessages !== preTrimMessages) {
// Count tool results before/after to report meaningful numbers
const countToolResults = (msgs: Array<Record<string, unknown>>) =>
msgs.reduce((sum, m) => sum + (Array.isArray(m.content)
? (m.content as Array<Record<string, unknown>>).filter(b => b.type === "tool_result" && b.content !== "[trimmed]").length
: 0), 0);
const activeBefore = countToolResults(preTrimMessages);
const activeAfter = countToolResults(cachedMessages);
const estimatedSaved = Math.round(prevIterationInputTokens * ((activeBefore - activeAfter) / Math.max(activeBefore, 1)));
callbacks.onAutoCompact?.(activeBefore, activeAfter, estimatedSaved);
emitter?.emitCompact(activeBefore, activeAfter, estimatedSaved);
}
}
// Get auth token
const token = await getValidToken();
if (!token) {
throw new Error("Not logged in. Run: whale login");
}
// Call server proxy with built-in retry
const originalModel = currentModel;
const stream = await callServerProxy({
proxyUrl: getProxyUrl(),
token,
model: currentModel,
system,
messages: cachedMessages,
tools: cachedTools,
apiConfig,
signal: abortSignal,
fallbackModel: opts.fallbackModel,
storeId: storeId || undefined,
onFallback: (from, to) => {
setModel(to);
logSpan({ action: "claude_api_fallback", durationMs: 0, context: { ...turnCtx, spanId: apiSpanId }, storeId: storeId || undefined, details: { from_model: from, to_model: to } });
},
onRetry: (attempt, max, err) => {
const msg = `\n\x1b[33m\u21BB Retrying (${attempt}/${max})... ${err.slice(0, 80)}\x1b[0m\n`;
if (emitter) { emitter.emitText(msg); } else { callbacks.onText(msg); }
},
});
// Process stream events with UI callbacks
const result: SharedStreamResult = await processStreamWithCallbacks(
parseSSEStream(stream, abortSignal),
{
onText: (text) => {
if (emitter) {
emitter.emitText(text);
} else {
callbacks.onText(text);
}
},
onToolStart: (name, input) => {
callbacks.onToolStart(name, input);
if (input) {
// Tool block complete — emit structured start
emitter?.emitToolStart("", name);
}
},
},
abortSignal,
);
// Flush buffered text
emitter?.flushText();
// Restore original model after transient fallback
if (getModel() !== originalModel && opts.fallbackModel) {
setModelById(originalModel);
}
// Update session token tracking
sessionInputTokens += result.usage.inputTokens;
sessionOutputTokens += result.usage.outputTokens;
prevIterationInputTokens = result.usage.inputTokens; // Actual context size for next trim check
// Emit usage with model + cost context for all providers
if (emitter && (result.usage.inputTokens > 0 || result.usage.outputTokens > 0)) {
const iterCost = estimateCostUsd(result.usage.inputTokens, result.usage.outputTokens, currentModel, result.thinkingTokens, result.usage.cacheReadTokens, result.usage.cacheCreationTokens);
emitter.emitUsage(result.usage.inputTokens, result.usage.outputTokens, currentModel, iterCost, result.usage.cacheReadTokens, result.usage.cacheCreationTokens);
}
totalIn += result.usage.inputTokens;
totalOut += result.usage.outputTokens;
totalCacheCreation += result.usage.cacheCreationTokens;
totalCacheRead += result.usage.cacheReadTokens;
totalThinking += result.thinkingTokens;
sessionCostUsd += estimateCostUsd(result.usage.inputTokens, result.usage.outputTokens, currentModel, result.thinkingTokens, result.usage.cacheReadTokens, result.usage.cacheCreationTokens);
// Server-side context management notification
if (result.contextManagementApplied) {
callbacks.onAutoCompact?.(messages.length, messages.length, 0);
emitter?.emitCompact(messages.length, messages.length, 0);
logSpan({ action: "chat.api_compaction", durationMs: Date.now() - apiStart, context: turnCtx, storeId: storeId || undefined, details: { type: "server_side", has_compaction_content: result.compactionContent !== null, iteration } });
}
if (result.text) allAssistantText.push(result.text);
// Telemetry: API call span
logSpan({
action: "claude_api_request",
durationMs: Date.now() - apiStart,
context: { ...turnCtx, spanId: apiSpanId, inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens },
storeId: storeId || undefined,
details: {
"gen_ai.request.model": currentModel,
"gen_ai.usage.input_tokens": result.usage.inputTokens,
"gen_ai.usage.output_tokens": result.usage.outputTokens,
"gen_ai.usage.cache_creation_tokens": result.usage.cacheCreationTokens,
"gen_ai.usage.cache_read_tokens": result.usage.cacheReadTokens,
stop_reason: result.toolUseBlocks.length > 0 ? "tool_use" : "end_turn",
iteration,
tool_count: result.toolUseBlocks.length,
tool_names: result.toolUseBlocks.map(t => t.name),
},
});
// No tool calls — we're done
if (result.toolUseBlocks.length === 0) break;
// Execute tools via shared dispatch
const { results: toolResults, bailOut, bailMessage } = await dispatchTools(
result.toolUseBlocks,
toolExecutor,
{
loopDetector,
maxConcurrent: 7,
maxResultChars,
onStart: (name, input) => {
callbacks.onToolStart(name, input);
},
onResult: (name, success, output, durationMs) => {
callbacks.onToolResult(name, success, output, undefined, durationMs);
logSpan({
action: `tool.${name}`,
durationMs,
context: { ...turnCtx, parentSpanId: apiSpanId },
storeId: storeId || undefined,
error: success ? undefined : output,
details: {
tool_input: {},
tool_result: truncateResult(output, 2000),
error_type: success ? undefined : classifyToolError(output),
iteration,
},
});
},
signal: abortSignal,
},
);
if (bailOut) {
logSpan({ action: "chat.bail_out", durationMs: Date.now() - sessionStart, context: turnCtx, storeId: storeId || undefined, severity: "warn", details: { ...loopDetector.getSessionStats(), message: bailMessage, iteration } });
}
// Shell output summarization — summarize long bash output to save context window
const toolNameMap = new Map(result.toolUseBlocks.map(t => [t.id, t.name]));
const finalToolResults = await summarizeLongToolResults(
toolResults,
toolNameMap,
getProxyUrl(),
token,
shellSummarization,
storeId || undefined,
);
// Build assistant content and append to conversation
const assistantContent = buildAssistantContent({
text: result.text,
toolUseBlocks: result.toolUseBlocks,
thinkingBlocks: result.thinkingBlocks,
compactionContent: result.compactionContent,
});
messages.push({ role: "assistant", content: assistantContent as any });
messages.push({ role: "user", content: finalToolResults as any });
}
// Telemetry: session summary
logSpan({
action: "chat.session_complete",
durationMs: Date.now() - sessionStart,
context: { ...turnCtx, inputTokens: totalIn, outputTokens: totalOut, model: activeModel },
storeId: storeId || undefined,
details: {
input_tokens: totalIn, output_tokens: totalOut, total_tokens: totalIn + totalOut,
cache_creation_tokens: totalCacheCreation, cache_read_tokens: totalCacheRead,
session_input_tokens: sessionInputTokens, session_output_tokens: sessionOutputTokens,
model: activeModel,
},
});
const turnCostUsd = estimateCostUsd(totalIn, totalOut, activeModel, totalThinking, totalCacheRead, totalCacheCreation);
callbacks.onUsage(totalIn, totalOut, totalThinking, activeModel, turnCostUsd, totalCacheRead, totalCacheCreation);
// Fire SessionEnd hook (non-blocking)
if (hooks.length > 0) {
runSessionHook(hooks, "SessionEnd", { session_id: `turn-${sessionStart}` }).catch(() => {});
}
const finalText = allAssistantText.length > 0 ? allAssistantText[allAssistantText.length - 1] : "";
emitter?.emitDone(finalText, messages);
if (emitter) clearGlobalEmitter();
callbacks.onDone(messages);
} catch (err: any) {
const errorMsg = abortSignal?.aborted || err?.message === "Cancelled"
? "Cancelled"
: String(err?.message || err);
logSpan({
action: errorMsg === "Cancelled" ? "chat.cancelled" : "chat.fatal_error",
durationMs: Date.now() - sessionStart,
context: { ...turnCtx, inputTokens: totalIn, outputTokens: totalOut, model: activeModel },
storeId: storeId || undefined,
severity: errorMsg === "Cancelled" ? "info" : "error",
error: errorMsg === "Cancelled" ? undefined : errorMsg,
details: { input_tokens: totalIn, output_tokens: totalOut, session_cost_usd: sessionCostUsd, model: activeModel },
});
emitter?.emitError(errorMsg);
if (emitter) clearGlobalEmitter();
callbacks.onError(errorMsg, messages);
}
}
// ============================================================================
// TELEMETRY HELPERS
// ============================================================================
export function truncateResult(output: string, maxLen: number): string {
if (output.length <= maxLen) return output;
return output.slice(0, maxLen) + `... (${output.length} chars total)`;
}
export function classifyToolError(output: string): string {
const lower = output.toLowerCase();
if (lower.includes("timed out") || lower.includes("timeout")) return "timeout";
if (lower.includes("permission denied") || lower.includes("eacces")) return "permission";
if (lower.includes("not found") || lower.includes("no such file")) return "not_found";
if (lower.includes("command not found") || lower.includes("exit code 127")) return "command_not_found";
if (lower.includes("import") && lower.includes("error")) return "import_error";
if (lower.includes("syntax") || lower.includes("parse")) return "syntax_error";
if (lower.includes("externally-managed")) return "env_managed";
return "unknown";
}
// Convenience: check if user can use the agent (logged in OR has API key)
export function canUseAgent(): { ready: boolean; reason?: string } {
const config = loadConfig();
const hasToken = !!(config.access_token && config.refresh_token);
const hasApiKey = !!(process.env.ANTHROPIC_API_KEY || config.anthropic_api_key);
if (hasToken || hasApiKey) return { ready: true };
return { ready: false, reason: "Run `whale login` to authenticate." };
}