Upgrade candidates (all 12 done): - #1 parallel read-only tool execution (runToolBatch, 4 loop sites) - #2 configurable retry policy (maxRetries + exponential backoff via SDK) - #3 script-aware token estimation (CJK/symbol/structure-aware heuristic) - #4 head+tail output capping (truncate.ts), auto-applies to bash/git/etc - #5 partial-history compaction (preserve recent tail, summarize older prefix) - #6 dynamic max_tokens (resolveMaxTokens) - #7 richer tool descriptions with "use when" guidance - #8 MCP reconnect retry + /mcp reconnect command + session toolset refresh - #9 context-window cache TTL (cachedAt timestamp, default 7 days) - #10 edit_file similar-match suggestion on old_string miss (bounded Levenshtein) - #11 git_status output head+tail (resolved via #4) - #12 auto-accept now approves all mutating tools; auto-edit stays edit-only Other: - DEFAULT_MAX_ITERATIONS 50 -> 100 (local models issue one tool call per step) - MaxIterationsError message guides resume + config override - plus prior known-issues work (grep -e/--, session id sanitization, MCP content types, 12 hook events, plugin collisions, skill references, git ops expansion, configurable autoCompactThreshold, bashGuard, pathGuard, FilePanel, replay)
109 lines
4.6 KiB
TypeScript
109 lines
4.6 KiB
TypeScript
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
|
|
import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js";
|
|
import { nextId, type HistoryItem, type NewHistoryItem } from "../ui/ink/types.js";
|
|
import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js";
|
|
|
|
const TOOL_CALL_BLOCK_RE = /```tool_call\s*[\s\S]*?```/g;
|
|
const TOOL_RESULT_BLOCK_RE = /^```tool_result\n([\s\S]*?)\n```$/;
|
|
|
|
/** A message's `content` can be a plain string or an array of content parts (text/image_url) —
|
|
* see pushToolResultMessage in agent/loop.ts. Only the text part matters for replay display. */
|
|
function textContent(content: ChatCompletionMessageParam["content"]): string | null {
|
|
if (typeof content === "string") return content;
|
|
if (Array.isArray(content)) {
|
|
const part = content.find((p): p is { type: "text"; text: string } => (p as { type?: string }).type === "text");
|
|
return part?.text ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isErrorResult(result: unknown): boolean {
|
|
return !!(result && typeof result === "object" && "error" in (result as object));
|
|
}
|
|
|
|
/** Rebuilds the tool_call/tool_result HistoryItems a resumed session's transcript is otherwise
|
|
* missing (see App.tsx initSessionFromRecord) — the persisted record (SessionRecord.messages) is
|
|
* the raw OpenAI-shape history, which carries everything needed (tool name, arguments, result)
|
|
* even though it was never saved as a pre-rendered display label. Handles both native mode
|
|
* (assistant `tool_calls` + matching `tool`-role messages, correlated by `tool_call_id`) and
|
|
* fallback mode (` ```tool_call``` ` blocks embedded in assistant text, ` ```tool_result``` `
|
|
* blocks embedded in user text — see fallbackParser.ts and pushToolResultMessage). */
|
|
export function buildReplayHistory(messages: ChatCompletionMessageParam[]): HistoryItem[] {
|
|
const items: HistoryItem[] = [];
|
|
// Native mode: `tool` messages only carry a tool_call_id, not the tool's name — remember the
|
|
// name from the assistant message that made the call so its later result can be labeled.
|
|
const pendingCallNames = new Map<string, string>();
|
|
|
|
const push = (item: NewHistoryItem) => items.push({ id: nextId(), ...item } as HistoryItem);
|
|
|
|
for (const m of messages) {
|
|
if (m.role === "user") {
|
|
const text = textContent(m.content);
|
|
if (text === null) continue;
|
|
const fallbackResult = TOOL_RESULT_BLOCK_RE.exec(text.trim());
|
|
if (fallbackResult) {
|
|
try {
|
|
const { name, result } = JSON.parse(fallbackResult[1]!) as { name: string; result: unknown };
|
|
push({ kind: "tool_result", summary: summarizeToolResult(name, result), isError: isErrorResult(result) });
|
|
} catch {
|
|
// Malformed persisted block (shouldn't happen since we wrote it) — drop rather than
|
|
// show the raw JSON to the user.
|
|
}
|
|
continue;
|
|
}
|
|
if (text) push({ kind: "user", text });
|
|
continue;
|
|
}
|
|
|
|
if (m.role === "assistant") {
|
|
const toolCalls = m.tool_calls;
|
|
if (toolCalls?.length) {
|
|
const text = textContent(m.content);
|
|
if (text) push({ kind: "assistant", text });
|
|
for (const call of toolCalls) {
|
|
if (call.type !== "function") continue;
|
|
let args: unknown = {};
|
|
try {
|
|
args = JSON.parse(call.function.arguments);
|
|
} catch {
|
|
// Leave args as {} — formatCallLabel degrades gracefully for a missing field.
|
|
}
|
|
pendingCallNames.set(call.id, call.function.name);
|
|
push({ kind: "tool_call", label: formatCallLabel(call.function.name, args) });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const text = textContent(m.content);
|
|
if (!text) continue;
|
|
const parsed = parseFallbackToolCalls(text);
|
|
if (parsed.calls.length) {
|
|
const stripped = text.replace(TOOL_CALL_BLOCK_RE, "").trim();
|
|
if (stripped) push({ kind: "assistant", text: stripped });
|
|
for (const call of parsed.calls) {
|
|
push({ kind: "tool_call", label: formatCallLabel(call.name, call.arguments) });
|
|
}
|
|
} else {
|
|
push({ kind: "assistant", text });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (m.role === "tool") {
|
|
const name = pendingCallNames.get(m.tool_call_id) ?? "unknown";
|
|
const text = textContent(m.content);
|
|
let result: unknown = text;
|
|
if (text) {
|
|
try {
|
|
result = JSON.parse(text);
|
|
} catch {
|
|
result = text;
|
|
}
|
|
}
|
|
push({ kind: "tool_result", summary: summarizeToolResult(name, result), isError: isErrorResult(result) });
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|