summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-29 16:52:28 +0900
committerAdam Malczewski <[email protected]>2026-05-29 16:52:28 +0900
commit5b3e1ac64681e233f35e1b4d2230d9988667c37e (patch)
tree483b68c5336008c1d531db1f9cda93f97e6b9df0
parent9c401530254b764d9c7a74c992bec612f32ccd8e (diff)
downloaddispatch-5b3e1ac64681e233f35e1b4d2230d9988667c37e.tar.gz
dispatch-5b3e1ac64681e233f35e1b4d2230d9988667c37e.zip
fix: handle unavailable tool calls via native v6 tool-error event, not synthetic invalid tool
- Removed __invalid__ tool definition, experimental_repairToolCall, and v4-era NoSuchToolError catch block — AI SDK v6 already emits a native tool-error stream event with the original tool name - Added synthesizeResidualToolResults() helper to fill orphaned tool-call IDs with isError: true results for abort/error terminal paths - tool-error handler now break's instead of return's — lets sibling tools execute normally via the manual executor loop - Added final safety net after execution loop to catch any genuinely orphaned tool-call IDs before round-tripping to the LLM - Propagated isError through toModelMessages so error results are properly flagged in conversation history - Updated tests: tool-error event now continues to idle (not error), added sibling-orphan prevention test
-rw-r--r--packages/core/src/agent/agent.ts351
-rw-r--r--packages/core/tests/agent/agent.test.ts74
-rw-r--r--wishlist.md10
3 files changed, 279 insertions, 156 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 6139dec..4eace0c 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -129,6 +129,7 @@ function toModelMessages(messages: ChatMessage[], useToolPrefix?: boolean): Mode
toolCallId: string;
toolName: string;
result: string;
+ isError?: boolean;
}> = [];
for (let chunkIdx = 0; chunkIdx < msg.chunks.length; chunkIdx++) {
@@ -176,6 +177,7 @@ function toModelMessages(messages: ChatMessage[], useToolPrefix?: boolean): Mode
toolCallId: entry.id,
toolName,
result: resultText,
+ isError: entry.isError,
});
}
}
@@ -207,6 +209,7 @@ function toModelMessages(messages: ChatMessage[], useToolPrefix?: boolean): Mode
toolCallId: tr.toolCallId,
toolName: tr.toolName,
output: { type: "text" as const, value: tr.result },
+ ...(tr.isError ? { isError: true } : {}),
},
],
});
@@ -626,6 +629,48 @@ export class Agent {
}
}
+ /**
+ * Build synthetic `tool-result` error events for every tool call in
+ * `stepToolCalls` that does not yet have a recorded result in `chunks`.
+ *
+ * When a tool batch is partially resolved — e.g. one tool emitted a
+ * `tool-error` while its siblings were never executed, or the stream
+ * aborted mid-batch — the unresolved tool-call IDs are registered in the
+ * history (inside `tool-batch` chunks) but have no matching result. On the
+ * next LLM round-trip the AI SDK throws `MissingToolResultsError`. Filling
+ * in `isError: true` results for those orphaned IDs keeps the on-the-wire
+ * tool-call/tool-result pairing complete.
+ *
+ * Caller is responsible for `appendEventToChunks(chunks, evt)` + `yield`ing
+ * each returned event.
+ */
+ private synthesizeResidualToolResults(
+ stepToolCalls: ToolCall[],
+ chunks: Chunk[],
+ defaultMessage: string,
+ ): AgentEvent[] {
+ const results: AgentEvent[] = [];
+ const resolved = new Set<string>();
+ for (const c of chunks) {
+ if (c.type !== "tool-batch") continue;
+ for (const entry of c.calls) {
+ if (entry.result !== undefined) resolved.add(entry.id);
+ }
+ }
+ for (const tc of stepToolCalls) {
+ if (resolved.has(tc.id)) continue;
+ const tr: ToolResult = {
+ toolCallId: tc.id,
+ toolName: tc.name,
+ result: defaultMessage,
+ isError: true,
+ };
+ const evt: AgentEvent = { type: "tool-result", toolResult: tr };
+ results.push(evt);
+ }
+ return results;
+ }
+
async *run(
userMessage: string,
options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" },
@@ -827,159 +872,155 @@ export class Agent {
stepMessages.push(assistantTurnMessage);
}
- try {
- for await (const event of result.fullStream) {
- if (event.type === "text-delta") {
- // v6: text-delta carries `text` (not `textDelta`)
- const internalEvent: AgentEvent = {
- type: "text-delta",
- delta: event.text,
- };
- appendEventToChunks(chunks, internalEvent);
- yield internalEvent;
- } else if (event.type === "reasoning-delta") {
- // v6 new event: reasoning-delta carries `text` (not `textDelta`)
- const internalEvent: AgentEvent = {
- type: "reasoning-delta",
- delta: event.text,
- };
- appendEventToChunks(chunks, internalEvent);
- yield internalEvent;
- } else if (event.type === "reasoning-end") {
- // Only emit when providerMetadata is present — non-Anthropic
- // models send reasoning-end without any metadata to round-trip.
- // Anthropic's signature lives inside providerMetadata as
- // { anthropic: { signature: "..." } }.
- if (event.providerMetadata !== undefined) {
- const internalEvent: AgentEvent = {
- type: "reasoning-end",
- metadata: event.providerMetadata as Record<string, unknown>,
- };
- appendEventToChunks(chunks, internalEvent);
- yield internalEvent;
- }
- } else if (event.type === "tool-call") {
- // v6: tool call input is in `input` (not `args`)
- const rawName = event.toolName;
- const toolName = isClaudeOAuth ? unprefixToolName(rawName) : rawName;
- const toolCall: ToolCall = {
- id: event.toolCallId,
- name: toolName,
- arguments: event.input as Record<string, unknown>,
- };
- stepToolCalls.push(toolCall);
- const internalEvent: AgentEvent = { type: "tool-call", toolCall };
- appendEventToChunks(chunks, internalEvent);
- yield internalEvent;
- } else if (event.type === "tool-error") {
- // Anthropic-side / provider-executed tool failures
- // (e.g. server tools or repair-tool-call paths) surface
- // as a fresh stream event rather than as a thrown
- // `tool-result` from our manual executor. Forward both
- // a synthetic tool-result (so the tool-batch entry the
- // model expects has an `isError: true` result) and
- // abort the step as an error chunk — without this the
- // model would silently wait for a result that never
- // arrives.
- const evt = event as unknown as {
- toolCallId: string;
- toolName: string;
- error: unknown;
- };
- const toolName = isClaudeOAuth ? unprefixToolName(evt.toolName) : evt.toolName;
- const errMessage = evt.error instanceof Error ? evt.error.message : String(evt.error);
-
- const trEvent: AgentEvent = {
- type: "tool-result",
- toolResult: {
- toolCallId: evt.toolCallId,
- toolName,
- result: `Error: ${errMessage}`,
- isError: true,
- },
- };
- appendEventToChunks(chunks, trEvent);
- yield trEvent;
-
- const errorMsg = formatError(evt.error, this.config);
- const errChunkEvent: AgentEvent = { type: "error", error: errorMsg };
- appendEventToChunks(chunks, errChunkEvent);
- yield errChunkEvent;
- this.status = "error";
- yield { type: "status", status: "error" };
- return;
- } else if (event.type === "abort") {
- // Stream aborted upstream. Surface as an error so the
- // frontend tab transitions out of `running`. We don't
- // currently call abortController.abort() ourselves, so
- // this would only fire from external signal propagation.
- const reason =
- typeof (event as { reason?: unknown }).reason === "string"
- ? (event as { reason: string }).reason
- : "stream aborted";
- const errorMsg = formatError(new Error(reason), this.config);
- const internalEvent: AgentEvent = { type: "error", error: errorMsg };
- appendEventToChunks(chunks, internalEvent);
- yield internalEvent;
- this.status = "error";
- yield { type: "status", status: "error" };
- return;
- } else if (event.type === "error") {
- const errRecord = event.error as unknown as Record<string, unknown>;
- const statusCode =
- typeof errRecord.statusCode === "number" ? errRecord.statusCode : undefined;
- const errorMsg = formatError(event.error, this.config);
+ // Unexpected stream errors propagate up to the outer try/catch
+ // in `run()`, which formats them and transitions the agent to
+ // the "error" status. The AI SDK v6 surfaces unavailable-tool
+ // calls as native `tool-error` stream events (handled below),
+ // so there's no NoSuchToolError to catch here.
+ for await (const event of result.fullStream) {
+ if (event.type === "text-delta") {
+ // v6: text-delta carries `text` (not `textDelta`)
+ const internalEvent: AgentEvent = {
+ type: "text-delta",
+ delta: event.text,
+ };
+ appendEventToChunks(chunks, internalEvent);
+ yield internalEvent;
+ } else if (event.type === "reasoning-delta") {
+ // v6 new event: reasoning-delta carries `text` (not `textDelta`)
+ const internalEvent: AgentEvent = {
+ type: "reasoning-delta",
+ delta: event.text,
+ };
+ appendEventToChunks(chunks, internalEvent);
+ yield internalEvent;
+ } else if (event.type === "reasoning-end") {
+ // Only emit when providerMetadata is present — non-Anthropic
+ // models send reasoning-end without any metadata to round-trip.
+ // Anthropic's signature lives inside providerMetadata as
+ // { anthropic: { signature: "..." } }.
+ if (event.providerMetadata !== undefined) {
const internalEvent: AgentEvent = {
- type: "error",
- error: errorMsg,
- ...(statusCode !== undefined ? { statusCode } : {}),
+ type: "reasoning-end",
+ metadata: event.providerMetadata as Record<string, unknown>,
};
appendEventToChunks(chunks, internalEvent);
yield internalEvent;
- this.status = "error";
- yield { type: "status", status: "error" };
- return;
}
- // Ignored events (intentional):
- // start, text-start, text-end, reasoning-start,
- // tool-input-start, tool-input-delta, tool-input-end,
- // tool-result (only fires if tool has execute; ours don't),
- // start-step, finish-step, finish, raw,
- // source, file, tool-output-denied, tool-approval-*
+ } else if (event.type === "tool-call") {
+ // v6: tool call input is in `input` (not `args`)
+ const rawName = event.toolName;
+ const toolName = isClaudeOAuth ? unprefixToolName(rawName) : rawName;
+ const toolCall: ToolCall = {
+ id: event.toolCallId,
+ name: toolName,
+ arguments: event.input as Record<string, unknown>,
+ };
+ stepToolCalls.push(toolCall);
+ const internalEvent: AgentEvent = { type: "tool-call", toolCall };
+ appendEventToChunks(chunks, internalEvent);
+ yield internalEvent;
+ } else if (event.type === "tool-error") {
+ // The model called a tool that doesn't exist (or a
+ // provider-executed / server tool failed). The AI SDK v6
+ // emits this as a native stream event carrying the
+ // original tool name. Forward both a synthetic
+ // tool-result (so the tool-batch entry the model expects
+ // has an `isError: true` result) and an error chunk for
+ // visibility — without the result the model would
+ // silently wait for one that never arrives.
+ const evt = event as unknown as {
+ toolCallId: string;
+ toolName: string;
+ error: unknown;
+ };
+ const toolName = isClaudeOAuth ? unprefixToolName(evt.toolName) : evt.toolName;
+ const errMessage = evt.error instanceof Error ? evt.error.message : String(evt.error);
+
+ const trEvent: AgentEvent = {
+ type: "tool-result",
+ toolResult: {
+ toolCallId: evt.toolCallId,
+ toolName,
+ result: `Error: ${errMessage}`,
+ isError: true,
+ },
+ };
+ appendEventToChunks(chunks, trEvent);
+ yield trEvent;
+
+ // Emit an error chunk for visibility, but do NOT set the
+ // agent status to "error" — the step continues and loops
+ // back to the LLM normally.
+ const errorMsg = formatError(evt.error, this.config);
+ const errChunkEvent: AgentEvent = { type: "error", error: errorMsg };
+ appendEventToChunks(chunks, errChunkEvent);
+ yield errChunkEvent;
+ break;
+ } else if (event.type === "abort") {
+ // Stream aborted upstream. Surface as an error so the
+ // frontend tab transitions out of `running`. We don't
+ // currently call abortController.abort() ourselves, so
+ // this would only fire from external signal propagation.
+ const reason =
+ typeof (event as { reason?: unknown }).reason === "string"
+ ? (event as { reason: string }).reason
+ : "stream aborted";
+ // Fill in error results for any unresolved tool calls so
+ // the orphaned tool-call IDs don't trigger a
+ // MissingToolResultsError if this history is ever replayed.
+ const abortResidual = this.synthesizeResidualToolResults(
+ stepToolCalls,
+ chunks,
+ `Error: Stream was aborted: ${reason}`,
+ );
+ for (const r of abortResidual) {
+ appendEventToChunks(chunks, r);
+ yield r;
+ }
+
+ const errorMsg = formatError(new Error(reason), this.config);
+ const internalEvent: AgentEvent = { type: "error", error: errorMsg };
+ appendEventToChunks(chunks, internalEvent);
+ yield internalEvent;
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
+ } else if (event.type === "error") {
+ const errRecord = event.error as unknown as Record<string, unknown>;
+ const statusCode =
+ typeof errRecord.statusCode === "number" ? errRecord.statusCode : undefined;
+ const errorMsg = formatError(event.error, this.config);
+ // Fill in error results for any unresolved tool calls so
+ // the orphaned tool-call IDs don't trigger a
+ // MissingToolResultsError if this history is ever replayed.
+ const streamErrResidual = this.synthesizeResidualToolResults(
+ stepToolCalls,
+ chunks,
+ `Error: ${errorMsg}`,
+ );
+ for (const r of streamErrResidual) {
+ appendEventToChunks(chunks, r);
+ yield r;
+ }
+
+ const internalEvent: AgentEvent = {
+ type: "error",
+ error: errorMsg,
+ ...(statusCode !== undefined ? { statusCode } : {}),
+ };
+ appendEventToChunks(chunks, internalEvent);
+ yield internalEvent;
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
}
- } catch (streamErr) {
- const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
- // v6 SDK error message: "Model tried to call unavailable tool '...'"
- // (v4 was: "tried to call unavailable tool '...'")
- const unavailMatch = errMsg.match(/tried to call unavailable tool '([^']+)'/i);
- if (!unavailMatch) throw streamErr;
-
- // Model tried to call an unavailable tool.
- // Add a synthetic tool call + error result for the bad tool.
- const badToolName = unavailMatch[1] as string;
- const fakeId = `unavail_${crypto.randomUUID().slice(0, 8)}`;
- const availableTools = Object.keys(aiTools).join(", ");
- const errorResult = `Tool "${badToolName}" is not available. Available tools: ${availableTools}. Please use only available tools.`;
-
- const badToolCall: ToolCall = {
- id: fakeId,
- name: badToolName,
- arguments: {},
- };
- stepToolCalls.push(badToolCall);
- const tcEvent: AgentEvent = { type: "tool-call", toolCall: badToolCall };
- appendEventToChunks(chunks, tcEvent);
- yield tcEvent;
-
- const badToolResult: ToolResult = {
- toolCallId: fakeId,
- toolName: badToolName,
- result: errorResult,
- isError: true,
- };
- const trEvent: AgentEvent = { type: "tool-result", toolResult: badToolResult };
- appendEventToChunks(chunks, trEvent);
- yield trEvent;
+ // Ignored events (intentional):
+ // start, text-start, text-end, reasoning-start,
+ // tool-input-start, tool-input-delta, tool-input-end,
+ // tool-result (only fires if tool has execute; ours don't),
+ // start-step, finish-step, finish, raw,
+ // source, file, tool-output-denied, tool-approval-*
}
// No tool calls means the agent is done — the assistant message
@@ -1113,6 +1154,22 @@ export class Agent {
});
batchPendingInjection.length = 0;
}
+
+ // Final safety net: after executing the batch, guarantee every
+ // tool-call recorded in this step has a matching result before we
+ // loop back to the LLM. Any tool-call ID that slipped through
+ // without a result (a path we didn't anticipate) would otherwise
+ // orphan and trigger MissingToolResultsError on the next
+ // round-trip.
+ const safetyResidual = this.synthesizeResidualToolResults(
+ stepToolCalls,
+ chunks,
+ "Error: Internal error: tool result was never produced.",
+ );
+ for (const r of safetyResidual) {
+ appendEventToChunks(chunks, r);
+ yield r;
+ }
}
// Build the final assistant message from the accumulated chunks.
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
index 82ca830..e0a999d 100644
--- a/packages/core/tests/agent/agent.test.ts
+++ b/packages/core/tests/agent/agent.test.ts
@@ -749,14 +749,18 @@ describe("Agent", () => {
});
});
- it("tool-error stream event yields a synthetic tool-result + error chunk and aborts the turn", async () => {
+ it("tool-error stream event yields a synthetic tool-result + error chunk and continues the turn", async () => {
// Provider-executed tools (Anthropic server tools) bypass our
// manual executor and surface as a `tool-error` stream event.
// We must:
// 1. Synthesize a tool-result with isError=true so the chunks
- // reflect that the tool ran and failed.
+ // reflect that the tool ran and failed — this keeps the
+ // tool-call/tool-result pairing complete and avoids the AI SDK
+ // throwing MissingToolResultsError on the next round-trip.
// 2. Emit an error chunk so the UI shows the failure.
- // 3. Transition the agent to "error" status (no further steps).
+ // 3. NOT transition to "error" status — the step breaks out of the
+ // stream loop and the turn ends normally (here, with no further
+ // tool calls pending, the agent completes to idle).
vi.mocked(streamText).mockReturnValue(
makeMockStreamResult([
{
@@ -783,16 +787,72 @@ describe("Agent", () => {
toolResult: { toolCallId: "tc_server", isError: true },
});
- // Error chunk
+ // Error chunk for visibility
const errEvent = events.find((e) => e.type === "error");
expect(errEvent).toBeDefined();
const errMsg = errEvent && "error" in errEvent ? errEvent.error : "";
expect(typeof errMsg).toBe("string");
expect((errMsg as string).includes("upstream tool failure")).toBe(true);
- // Status transitions to error
- const errStatusEvent = events.filter((e) => e.type === "status").at(-1);
- expect(errStatusEvent).toMatchObject({ type: "status", status: "error" });
+ // Status does NOT transition to error — the turn completes to idle.
+ const lastStatus = events.filter((e) => e.type === "status").at(-1);
+ expect(lastStatus).toMatchObject({ type: "status", status: "idle" });
+
+ // The turn produced a `done` event (it did not abort).
+ expect(events.some((e) => e.type === "done")).toBe(true);
+ });
+
+ it("tool-error leaves sibling tool calls to be resolved by the executor (not orphaned)", async () => {
+ // When one tool in a batch errors, its siblings — whose tool-call
+ // events were already yielded — must still receive a result, otherwise
+ // the tool-call IDs are orphaned in the chunks (no matching result)
+ // and the next LLM round-trip throws MissingToolResultsError. The
+ // tool-error handler breaks out of the stream loop WITHOUT executing
+ // the unresolved siblings inline; the normal manual-executor pass then
+ // runs them. Here `sibling_tool` is not a registered tool, so the
+ // executor returns an "Unknown tool" error result — completing the
+ // tool-call/tool-result pairing with `isError: true`.
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([
+ {
+ type: "tool-call",
+ toolCallId: "tc_sibling",
+ toolName: "sibling_tool",
+ input: {},
+ },
+ {
+ type: "tool-error",
+ toolCallId: "tc_failed",
+ toolName: "failed_tool",
+ error: new Error("boom"),
+ },
+ finishStop,
+ ]),
+ );
+
+ const agent = new Agent(makeConfig());
+ const events: AgentEvent[] = [];
+ for await (const event of agent.run("trigger")) {
+ events.push(event);
+ }
+
+ const toolResults = events.filter((e) => e.type === "tool-result");
+ // One for the failed tool, one for the sibling resolved by the executor.
+ const siblingResult = toolResults.find(
+ (e) => "toolResult" in e && e.toolResult.toolCallId === "tc_sibling",
+ );
+ expect(siblingResult).toBeDefined();
+ expect(siblingResult).toMatchObject({
+ type: "tool-result",
+ toolResult: { toolCallId: "tc_sibling", isError: true },
+ });
+ const siblingMsg =
+ siblingResult && "toolResult" in siblingResult ? siblingResult.toolResult.result : "";
+ expect((siblingMsg as string).includes("sibling_tool")).toBe(true);
+
+ // Status completes to idle (the turn continued, not aborted).
+ const lastStatus = events.filter((e) => e.type === "status").at(-1);
+ expect(lastStatus).toMatchObject({ type: "status", status: "idle" });
});
it("abort stream event surfaces as an error event and stops the turn", async () => {
diff --git a/wishlist.md b/wishlist.md
index 1c1b1be..b5eb60f 100644
--- a/wishlist.md
+++ b/wishlist.md
@@ -9,9 +9,15 @@
- **Edit chat history.** Click on any existing message in the chat history and choose to edit it — this applies to user messages, AI responses, and tool results.
-- **AI can summon subagents using pre-configured agent types.** When the AI needs to delegate work, it can spawn subagents by selecting from a list of agent types that were defined in the agent editor page, rather than simply cloning itself with the same model.
+- **Status indicator next to the chat input box.** Exists: spinner while running, checkmark on success, X on error. Needs: clickable to cancel (with text label), not just the spinner icon.
-- **Status indicator next to the chat input box.** A small icon sits to the left of the input area: a spinner while the AI is generating a response, a checkmark when it finishes successfully, and an X when the last generation errored out.
+- **Update the way tools appear in the chat UI.** Improve the visual presentation of tool calls and their results — make them more readable, compact, and scannable.
+
+- **Show git diffs for edited files.** When the AI edits a file (write_file tool call), display a git diff in the UI rather than just the raw file content.
+
+- **Show live shell output in a collapsible block.** When a shell command is running, show live stdout/stderr in a collapsible shell block (similar to the thinking block), instead of requiring the user to expand the tool call and read raw JSON.
+
+- **ntfy push notifications.** Configurable ntfy.sh notifications — ping on chat completion, errors, permission prompts, and other events. Configure topic URL and which events trigger notifications.
- **Failed tool calls should produce proper tool results, not get silently dropped.**
- Repro: agent calls `read_file` but the tool is unavailable → SDK emits `tried to call unavailable tool`. User grants permission and asks agent to retry. Agent tries again, but the SDK now errors with `Tool results are missing for tool calls call_00_..., call_01_...` — the previous step's tool calls were recorded (IDs registered in the assistant message) but their results were never written (because the stream aborted when the unavailable-tool error was caught). The synthetic error path in agent.ts (lines 856-882) only covers the ONE tool that triggered the error; sibling tool calls in the same batch that the LLM sent alongside the unavailable one have their IDs in the history but no matching tool-result, which trips the v6 SDK validation.