-
Notifications
You must be signed in to change notification settings - Fork 0
fix: preserve headless tool-call failure truth #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -186,7 +186,10 @@ export namespace SessionProcessor { | |||||
| start: Date.now(), | ||||||
| }, | ||||||
| }, | ||||||
| metadata: value.providerMetadata, | ||||||
| metadata: { | ||||||
|
byapparov marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Persisted providerExecuted key skips SDK/share consumers.
Suggested change
🤖 Fix with your agentWhy this mattersEvery new ToolPart now persists metadata.providerExecuted, but the coupled neighbours that consume persisted part metadata are untouched in this PR: packages/sdk/src/gen/types.gen.ts is a weight-1.0 co-change partner of both edited files in recent PRs, and share/export surfaces (e.g. src/share/share-next.ts, which imports MessageV2) serialize part metadata verbatim. If any of those schemas type ToolPart.metadata as a closed shape rather than an open record, serialized sessions carrying the new key fail validation; at minimum an internal execution flag is now exposed on shared/exported session payloads with no scrubbing (toolProviderMetadata strips it only at the toModelMessages boundary). metadata: {
...value.providerMetadata,
[MessageV2.PROVIDER_EXECUTED_METADATA_KEY]: value.providerExecuted === true,
},
})
toolcalls[value.toolCallId] = part as MessageV2.ToolPart
parts.add(part.id) |
||||||
| ...value.providerMetadata, | ||||||
| [MessageV2.PROVIDER_EXECUTED_METADATA_KEY]: value.providerExecuted === true, | ||||||
| }, | ||||||
| }) | ||||||
| toolcalls[value.toolCallId] = part as MessageV2.ToolPart | ||||||
| parts.add(part.id) | ||||||
|
|
@@ -504,7 +507,7 @@ export namespace SessionProcessor { | |||||
| state: { | ||||||
| ...part.state, | ||||||
| status: "error", | ||||||
| error: "Tool execution aborted", | ||||||
| error: MessageV2.TOOL_EXECUTION_ABORTED, | ||||||
| time: { | ||||||
| start: Date.now(), | ||||||
| end: Date.now(), | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,6 +62,27 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc | |
| export namespace SessionPrompt { | ||
| const log = Log.create({ service: "session.prompt" }) | ||
|
|
||
| export function hasToolCalls(parts: MessageV2.Part[]) { | ||
| return parts.some( | ||
| (part) => | ||
| part.type === "tool" && | ||
| part.metadata?.[MessageV2.PROVIDER_EXECUTED_METADATA_KEY] !== true && | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Legacy parts default to client-executed in hasToolCalls. 🤖 Fix with your agentWhy this mattersThe gate reads part.metadata?.[PROVIDER_EXECUTED_METADATA_KEY] with two different comparisons: export function hasToolCalls(parts: MessageV2.Part[]) {
return parts.some(
(part) =>
part.type === "tool" &&
part.metadata?.[MessageV2.PROVIDER_EXECUTED_METADATA_KEY] !== true &&
(part.state.status === "completed" ||
(part.state.status === "error" &&
(part.state.error !== MessageV2.TOOL_EXECUTION_ABORTED ||
part.metadata?.[MessageV2.PROVIDER_EXECUTED_METADATA_KEY] === false))),
)
} |
||
| (part.state.status === "completed" || | ||
| (part.state.status === "error" && | ||
| (part.state.error !== MessageV2.TOOL_EXECUTION_ABORTED || | ||
| part.metadata?.[MessageV2.PROVIDER_EXECUTED_METADATA_KEY] === false))), | ||
| ) | ||
| } | ||
|
|
||
| export function isModelFinished(finish?: string) { | ||
| return !!finish && !["tool-calls", "unknown"].includes(finish) | ||
| } | ||
|
|
||
| export async function missingStructuredOutput(message: MessageV2.Assistant, load: () => Promise<MessageV2.Part[]>) { | ||
| if (!isModelFinished(message.finish) || message.error) return false | ||
| return !hasToolCalls(await load()) | ||
| } | ||
|
|
||
| const state = Instance.state( | ||
| () => { | ||
| const data: Record< | ||
|
|
@@ -332,9 +353,11 @@ export namespace SessionPrompt { | |
| } | ||
|
|
||
| if (!lastUser) throw new Error("No user message found in stream. This should never happen.") | ||
| const lastAssistantMsg = msgs.findLast((msg) => msg.info.id === lastAssistant?.id) | ||
| if ( | ||
| lastAssistant?.finish && | ||
| !["tool-calls", "unknown"].includes(lastAssistant.finish) && | ||
| lastAssistant && | ||
| isModelFinished(lastAssistant.finish) && | ||
| !hasToolCalls(lastAssistantMsg?.parts ?? []) && | ||
| lastUser.id < lastAssistant.id | ||
| ) { | ||
| log.info("exiting loop", { sessionID }) | ||
|
|
@@ -727,19 +750,16 @@ export namespace SessionPrompt { | |
| break | ||
| } | ||
|
|
||
| // Check if model finished (finish reason is not "tool-calls" or "unknown") | ||
| const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish) | ||
|
|
||
| if (modelFinished && !processor.message.error) { | ||
| if (format.type === "json_schema") { | ||
| // Model stopped without calling StructuredOutput tool | ||
| processor.message.error = new MessageV2.StructuredOutputError({ | ||
| message: "Model did not produce structured output", | ||
| retries: 0, | ||
| }).toObject() | ||
| await Session.updateMessage(processor.message) | ||
| break | ||
| } | ||
| if ( | ||
| format.type === "json_schema" && | ||
| (await missingStructuredOutput(processor.message, () => MessageV2.parts(processor.message.id))) | ||
| ) { | ||
| processor.message.error = new MessageV2.StructuredOutputError({ | ||
| message: "Model did not produce structured output", | ||
| retries: 0, | ||
| }).toObject() | ||
| await Session.updateMessage(processor.message) | ||
| break | ||
| } | ||
|
|
||
| if (result === "stop") break | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -25,6 +25,28 @@ const parameters = z.object({ | |||||
| command: z.string().describe("The command that triggered this task").optional(), | ||||||
| }) | ||||||
|
|
||||||
| export function taskResultText(result: MessageV2.WithParts, sessionID: string) { | ||||||
| if (result.info.role === "assistant" && result.info.error) { | ||||||
| const data = result.info.error.data | ||||||
|
byapparov marked this conversation as resolved.
|
||||||
| const message = | ||||||
| data && typeof data === "object" && "message" in data && typeof data.message === "string" | ||||||
| ? data.message | ||||||
| : result.info.error.name | ||||||
| throw new Error(`Subagent failed (task_id: ${sessionID}): ${message}`) | ||||||
| } | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Trailing orphan error part discards valid child text.
Suggested change
🤖 Fix with your agentWhy this matterstaskResultText hard-fails the subagent when the child's final message contains ANY error-status tool part, even when the same message also carries the model's textual answer. The prompt layer added in this PR treats exactly such a case as a clean stop: an orphan partial tool input (tool-input-start with no tool-call, finish "stop") is persisted as an errored part while the loop exits after one model call with no message.error — the PR's own prompt-tool-loop test pins that behavior. The two layers therefore disagree: prompt says "terminal but clean", TaskTool throws "Subagent failed (task_id: …)", and the parent model loses the child's valid text for what may be a provider truncation glitch. const failed = result.parts.findLast((part) => part.type === "tool" && part.state.status === "error")
if (failed?.type === "tool" && failed.state.status === "error") {
throw new Error(`Subagent failed (task_id: ${sessionID}): ${failed.state.error}`)
}
return result.parts.findLast((part) => part.type === "text")?.text ?? ""
} |
||||||
| const failed = result.parts.findLast((part) => part.type === "tool" && part.state.status === "error") | ||||||
|
byapparov marked this conversation as resolved.
|
||||||
| if (failed?.type === "tool" && failed.state.status === "error") { | ||||||
| throw new Error(`Subagent failed (task_id: ${sessionID}): ${failed.state.error}`) | ||||||
| } | ||||||
| return result.parts.findLast((part) => part.type === "text")?.text ?? "" | ||||||
| } | ||||||
|
|
||||||
| export async function completeTask(result: MessageV2.WithParts, subagentSessionID: string, parentSessionID: string) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ Last-text extraction duplicated in task helpers. 🤖 Fix with your agentWhy this matterscompleteTask computes export async function completeTask(result: MessageV2.WithParts, subagentSessionID: string, parentSessionID: string) {
const text = result.parts.findLast((part) => part.type === "text")?.text ?? ""
await Plugin.trigger("agent.subtask.complete", { subagentSessionID, parentSessionID }, { result: text })
return taskResultText(result, subagentSessionID)
} |
||||||
| const text = result.parts.findLast((part) => part.type === "text")?.text ?? "" | ||||||
| await Plugin.trigger("agent.subtask.complete", { subagentSessionID, parentSessionID }, { result: text }) | ||||||
| return taskResultText(result, subagentSessionID) | ||||||
| } | ||||||
|
|
||||||
| export const TaskTool = Tool.define("task", async (ctx) => { | ||||||
| const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) | ||||||
|
|
||||||
|
|
@@ -150,13 +172,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { | |||||
| parts: promptParts, | ||||||
|
byapparov marked this conversation as resolved.
|
||||||
| }) | ||||||
|
|
||||||
| const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" | ||||||
|
|
||||||
| await Plugin.trigger( | ||||||
| "agent.subtask.complete", | ||||||
| { subagentSessionID: session.id, parentSessionID: ctx.sessionID }, | ||||||
| { result: text }, | ||||||
| ) | ||||||
| const text = await completeTask(result, session.id, ctx.sessionID) | ||||||
|
|
||||||
| const output = [ | ||||||
| `task_id: ${session.id} (for resuming to continue this task if needed)`, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import { describe, expect, spyOn, test } from "bun:test" | ||
| import { Agent } from "../../src/agent/agent" | ||
| import { Identifier } from "../../src/id/id" | ||
| import { Instance } from "../../src/project/instance" | ||
| import { Provider } from "../../src/provider/provider" | ||
| import { Session } from "../../src/session" | ||
| import { LLM } from "../../src/session/llm" | ||
| import { SessionProcessor } from "../../src/session/processor" | ||
| import { MessageV2 } from "../../src/session/message-v2" | ||
| import { tmpdir } from "../fixture/fixture" | ||
|
|
||
| describe("session processor tool metadata", () => { | ||
| test("persists provider-executed attribution from the stream", async () => { | ||
| await using tmp = await tmpdir({ | ||
| config: { | ||
| enabled_providers: ["alibaba"], | ||
| provider: { alibaba: { options: { apiKey: "test-key" } } }, | ||
| }, | ||
| }) | ||
|
|
||
| await Instance.provide({ | ||
| directory: tmp.path, | ||
| fn: async () => { | ||
| const session = await Session.create({ title: "Provider tool metadata fixture" }) | ||
| const agent = await Agent.get("build") | ||
| const model = await Provider.getModel("alibaba", "qwen-plus") | ||
| const user = (await Session.updateMessage({ | ||
| id: Identifier.ascending("message"), | ||
| sessionID: session.id, | ||
| role: "user", | ||
| time: { created: Date.now() }, | ||
| agent: agent.name, | ||
| model: { providerID: model.providerID, modelID: model.id }, | ||
| })) as MessageV2.User | ||
| const assistant = (await Session.updateMessage({ | ||
| id: Identifier.ascending("message"), | ||
| sessionID: session.id, | ||
| role: "assistant", | ||
| parentID: user.id, | ||
| modelID: model.id, | ||
| providerID: model.providerID, | ||
| mode: agent.name, | ||
| agent: agent.name, | ||
| path: { cwd: tmp.path, root: tmp.path }, | ||
| cost: 0, | ||
| tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, | ||
| time: { created: Date.now() }, | ||
| })) as MessageV2.Assistant | ||
|
|
||
| const stream = spyOn(LLM, "stream").mockResolvedValue({ | ||
| fullStream: (async function* () { | ||
| yield { type: "tool-input-start", id: "call_1", toolName: "server_tool" } | ||
| yield { | ||
| type: "tool-call", | ||
| toolCallId: "call_1", | ||
| toolName: "server_tool", | ||
| input: {}, | ||
| providerExecuted: true, | ||
| } | ||
| yield { type: "tool-input-start", id: "call_2", toolName: "server_tool" } | ||
| yield { | ||
| type: "tool-call", | ||
| toolCallId: "call_2", | ||
| toolName: "server_tool", | ||
| input: { second: true }, | ||
| providerExecuted: false, | ||
| providerMetadata: { providerExecuted: true }, | ||
| } | ||
| yield { | ||
| type: "finish-step", | ||
| finishReason: "stop", | ||
| usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, | ||
| } | ||
| })(), | ||
| } as unknown as Awaited<ReturnType<typeof LLM.stream>>) | ||
|
|
||
| try { | ||
| const processor = SessionProcessor.create({ | ||
| assistantMessage: assistant, | ||
| sessionID: session.id, | ||
| model, | ||
| abort: new AbortController().signal, | ||
| }) | ||
| await processor.process({ | ||
| user, | ||
| sessionID: session.id, | ||
| model, | ||
| agent, | ||
| abort: new AbortController().signal, | ||
| system: [], | ||
| messages: [], | ||
| tools: {}, | ||
| }) | ||
|
|
||
| const parts = (await Session.messages({ sessionID: session.id })).flatMap((message) => message.parts) | ||
| const first = parts.find((part) => part.type === "tool" && part.callID === "call_1") | ||
| const second = parts.find((part) => part.type === "tool" && part.callID === "call_2") | ||
| expect(first?.type === "tool" ? first.metadata?.providerExecuted : undefined).toBe(true) | ||
| expect(second?.type === "tool" ? second.metadata?.providerExecuted : undefined).toBe(false) | ||
| } finally { | ||
| stream.mockRestore() | ||
| } | ||
| }, | ||
| }) | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.