fix(coding-agent): harden edge cases from codex review

- Guard JSON.parse of tool arguments with try-catch
- Robust tool-call accumulation via Map (handles out-of-order deltas)
- Pass real ToolCall to onToolCall/onToolResult (correct ids in scrollback)
- Persist skipped tool calls to Static scrollback
- Flush partial streaming text on network errors
- Ensure empty assistant messages have content: "" (valid schema)
This commit is contained in:
Yunfei He
2026-05-24 22:49:57 +08:00
parent 4ac8a87507
commit 638dd9fbc7
2 changed files with 41 additions and 55 deletions
+11 -23
View File
@@ -1,6 +1,6 @@
import { shallowRef, defineComponent } from "vue";
import { Box, Text, Static, useInput, useExit } from "@vue-tui/runtime";
import { runAgentLoop, type Message } from "./agent";
import { runAgentLoop, type Message, type ToolCall } from "./agent";
import MessageList from "./components/MessageList";
type AppState = "idle" | "streaming" | "approving";
@@ -37,8 +37,7 @@ export default defineComponent(() => {
onToken(token) {
streamingText.value += token;
},
onToolCall(command) {
// Flush streaming text before tool call
onToolCall(tc: ToolCall, command: string) {
if (streamingText.value) {
completedMessages.value = [
...completedMessages.value,
@@ -48,24 +47,11 @@ export default defineComponent(() => {
}
pendingCommand.value = command;
},
onToolResult(output) {
// Persist the tool call (bordered box) and its output
onToolResult(tc: ToolCall, output: string) {
completedMessages.value = [
...completedMessages.value,
{
role: "assistant",
tool_calls: [
{
id: "",
type: "function",
function: {
name: "bash",
arguments: JSON.stringify({ command: pendingCommand.value }),
},
},
],
},
{ role: "tool", content: output },
{ role: "assistant", tool_calls: [tc] },
{ role: "tool", tool_call_id: tc.id, content: output },
];
pendingCommand.value = "";
},
@@ -92,10 +78,12 @@ export default defineComponent(() => {
messages.length = 0;
messages.push(...updated);
} catch (err: any) {
completedMessages.value = [
...completedMessages.value,
{ role: "assistant", content: `Error: ${err.message}` },
];
const errParts: Message[] = [];
if (streamingText.value) {
errParts.push({ role: "assistant", content: streamingText.value });
}
errParts.push({ role: "assistant", content: `Error: ${err.message}` });
completedMessages.value = [...completedMessages.value, ...errParts];
}
streamingText.value = "";
+30 -32
View File
@@ -16,8 +16,8 @@ export interface ToolCall {
export interface AgentOptions {
onToken: (token: string) => void;
onToolCall: (command: string) => void;
onToolResult: (output: string) => void;
onToolCall: (tc: ToolCall, command: string) => void;
onToolResult: (tc: ToolCall, output: string) => void;
onComplete: () => void;
autoApprove: boolean;
requestApproval: (command: string) => Promise<boolean>;
@@ -100,8 +100,7 @@ export async function runAgentLoop(
});
let contentBuffer = "";
let toolCalls: ToolCall[] = [];
const toolCallArgs: Map<number, string> = new Map();
const toolCallMap = new Map<number, ToolCall>();
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
@@ -114,32 +113,24 @@ export async function runAgentLoop(
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
if (tc.id) {
toolCalls.push({
id: tc.id,
type: "function",
function: { name: tc.function?.name ?? "", arguments: "" },
});
}
if (tc.function?.arguments) {
const existing = toolCallArgs.get(tc.index) ?? "";
toolCallArgs.set(tc.index, existing + tc.function.arguments);
}
const existing = toolCallMap.get(tc.index) ?? {
id: "",
type: "function" as const,
function: { name: "", arguments: "" },
};
if (tc.id) existing.id = tc.id;
if (tc.function?.name) existing.function.name = tc.function.name;
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
toolCallMap.set(tc.index, existing);
}
}
}
// Finalize accumulated tool call arguments
for (const [index, args] of toolCallArgs) {
if (toolCalls[index]) {
toolCalls[index].function.arguments = args;
}
}
const toolCalls = [...toolCallMap.entries()].sort(([a], [b]) => a - b).map(([, tc]) => tc);
// Append the full assistant message to history
const assistantMsg: Message = {
role: "assistant",
content: contentBuffer || undefined,
content: contentBuffer || (toolCalls.length > 0 ? undefined : ""),
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
};
messages = [...messages, assistantMsg];
@@ -150,26 +141,33 @@ export async function runAgentLoop(
return messages;
}
// Execute each tool call
for (const tc of assistantMsg.tool_calls) {
const parsed = JSON.parse(tc.function.arguments);
const command: string = parsed.command;
let command: string;
try {
const parsed = JSON.parse(tc.function.arguments);
command = parsed.command ?? "";
} catch {
const errMsg = `Failed to parse tool arguments: ${tc.function.arguments}`;
options.onToolCall(tc, errMsg);
options.onToolResult(tc, errMsg);
messages = [...messages, { role: "tool", tool_call_id: tc.id, content: errMsg }];
continue;
}
options.onToolCall(command);
options.onToolCall(tc, command);
if (!options.autoApprove) {
const approved = await options.requestApproval(command);
if (!approved) {
messages = [
...messages,
{ role: "tool", tool_call_id: tc.id, content: "(skipped by user)" },
];
const skipped = "(skipped by user)";
options.onToolResult(tc, skipped);
messages = [...messages, { role: "tool", tool_call_id: tc.id, content: skipped }];
continue;
}
}
const output = await executeBash(command);
options.onToolResult(output);
options.onToolResult(tc, output);
messages = [...messages, { role: "tool", tool_call_id: tc.id, content: output }];
}
// Loop back to call the API again with updated messages