Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions packages/cli/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ import { type SystemError } from "bun"
import type { Provider } from "@/provider/provider"

export namespace MessageV2 {
export const PROVIDER_EXECUTED_METADATA_KEY = "providerExecuted"
export const TOOL_EXECUTION_ABORTED = "Tool execution aborted"

export function toolProviderMetadata(part: ToolPart) {
const metadata = Object.fromEntries(
Object.entries(part.metadata ?? {}).filter(([key]) => key !== PROVIDER_EXECUTED_METADATA_KEY),
)
return Object.keys(metadata).length ? metadata : undefined
}

export function hasVisibleOutput(parts: Part[]) {
return parts.some((part) => (part.type === "text" && !!part.text.trim()) || part.type === "tool")
}
Expand Down Expand Up @@ -652,7 +662,7 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
output,
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
...(differentModel ? {} : { callProviderMetadata: toolProviderMetadata(part) }),
})
}
if (part.state.status === "error")
Expand All @@ -662,7 +672,7 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
errorText: part.state.error,
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
...(differentModel ? {} : { callProviderMetadata: toolProviderMetadata(part) }),
})
// Handle pending/running tool calls to prevent dangling tool_use blocks
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
Expand All @@ -673,7 +683,7 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
errorText: "[Tool execution was interrupted]",
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
...(differentModel ? {} : { callProviderMetadata: toolProviderMetadata(part) }),
})
}
if (part.type === "reasoning") {
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,10 @@ export namespace SessionProcessor {
start: Date.now(),
},
},
metadata: value.providerMetadata,
metadata: {
Comment thread
byapparov marked this conversation as resolved.
Comment thread
byapparov marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Persisted providerExecuted key skips SDK/share consumers.

Suggested change
metadata: {
Audit part.metadata consumers: confirm SDK generated types (packages/sdk/src/gen/types.gen.ts) and share/export part schemas treat metadata as an open Record<string, JSONValue>, regenerating types if needed; if the flag is meant to stay internal, consider stripping it (like toolProviderMetadata does) or moving it to a dedicated typed field on ToolPart instead of the generic metadata bag.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/session/processor.ts:189-192):

Problem: Persisted providerExecuted key skips SDK/share consumers
Detail: Every 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).
Suggested fix: Audit part.metadata consumers: confirm SDK generated types (packages/sdk/src/gen/types.gen.ts) and share/export part schemas treat metadata as an open Record<string, JSONValue>, regenerating types if needed; if the flag is meant to stay internal, consider stripping it (like toolProviderMetadata does) or moving it to a dedicated typed field on ToolPart instead of the generic metadata bag.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Every 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)
Expand Down Expand Up @@ -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(),
Expand Down
50 changes: 35 additions & 15 deletions packages/cli/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Legacy parts default to client-executed in hasToolCalls.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/session/prompt.ts:69-75):

Problem: Legacy parts default to client-executed in hasToolCalls
Detail: The gate reads part.metadata?.[PROVIDER_EXECUTED_METADATA_KEY] with two different comparisons: `!== true` for the completed branch but `=== false` for the aborted branch. Tool parts persisted before this PR never carry the key (metadata was just value.providerMetadata), so a legacy completed tool part is always classified as client-executed. On resume/retry of a pre-PR session whose last assistant message has a terminal finish plus completed tool parts, the top-of-loop check that previously exited now returns hasToolCalls=true and triggers an extra model turn that re-sends tool results — including results for tools the provider already executed server-side, which some providers reject. The undefined-vs-false asymmetry between the two branches is implicit rather than a documented tri-state decision.
Suggested fix: Make the tri-state explicit: normalize once (`const executed = part.metadata?.[MessageV2.PROVIDER_EXECUTED_METADATA_KEY]`) and decide deliberately how `undefined` (legacy parts) behaves per status branch — e.g. treat undefined as client-executed for completed parts only if that resume behavior is intended, otherwise fall back to the pre-PR finish-only exit for parts without the marker.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The gate reads part.metadata?.[PROVIDER_EXECUTED_METADATA_KEY] with two different comparisons: !== true for the completed branch but === false for the aborted branch. Tool parts persisted before this PR never carry the key (metadata was just value.providerMetadata), so a legacy completed tool part is always classified as client-executed. On resume/retry of a pre-PR session whose last assistant message has a terminal finish plus completed tool parts, the top-of-loop check that previously exited now returns hasToolCalls=true and triggers an extra model turn that re-sends tool results — including results for tools the provider already executed server-side, which some providers reject. The undefined-vs-false asymmetry between the two branches is implicit rather than a documented tri-state decision.

  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<
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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
Expand Down
30 changes: 23 additions & 7 deletions packages/cli/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
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}`)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Trailing orphan error part discards valid child text.

Suggested change
}
Only throw when the errored tool part has no subsequent text answer in the message (or tolerate parts whose error is MessageV2.TOOL_EXECUTION_ABORTED, mirroring hasToolCalls), otherwise return the text — optionally annotated with the failure — so the parent keeps the child's answer while still surfacing genuine failures.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/tool/task.ts:36-39):

Problem: Trailing orphan error part discards valid child text
Detail: taskResultText 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.
Suggested fix: Only throw when the errored tool part has no subsequent text answer in the message (or tolerate parts whose error is MessageV2.TOOL_EXECUTION_ABORTED, mirroring hasToolCalls), otherwise return the text — optionally annotated with the failure — so the parent keeps the child's answer while still surfacing genuine failures.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

taskResultText 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")
Comment thread
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Last-text extraction duplicated in task helpers.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #116, packages/cli/src/tool/task.ts:44-46):

Problem: Last-text extraction duplicated in task helpers
Detail: completeTask computes `result.parts.findLast((part) => part.type === "text")?.text ?? ""` for the plugin payload, then delegates to taskResultText which repeats the identical expression for its return value. The two copies can drift if text selection ever changes (e.g. trimming or filtering empty texts) and the duplication is avoidable.
Suggested fix: Extract a single helper (e.g. `const lastText = (parts) => parts.findLast((p) => p.type === "text")?.text ?? ""`) and use it in both completeTask and taskResultText, or have taskResultText accept the precomputed text.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

completeTask computes result.parts.findLast((part) => part.type === "text")?.text ?? "" for the plugin payload, then delegates to taskResultText which repeats the identical expression for its return value. The two copies can drift if text selection ever changes (e.g. trimming or filtering empty texts) and the duplication is avoidable.

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"))

Expand Down Expand Up @@ -150,13 +172,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
parts: promptParts,
Comment thread
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)`,
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/test/session/processor-tool-metadata.test.ts
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()
}
},
})
})
})
Loading
Loading