fix(agent): use canonical assistant history format in subagent_runner (#606)

build_native_assistant_payload in subagent_runner.rs serialised the
assistant tool-call message using {"text": …, "tool_calls": [{
"type":"function","function":{…}}]} — two mismatches vs the format
parse::build_native_assistant_history produces and the backend
jinja template expects:

  1. "text" instead of "content" — the downstream convert_messages
     parser looks for "content" to detect assistant-with-tool-calls
     messages; "text" is not recognised, so the message is treated
     as a plain assistant message.
  2. Nested {"type":"function","function":{"name":…,"arguments":…}}
     wrappers instead of flat {"id":…,"name":…,"arguments":…}.

Result: every sub-agent that made a tool call and entered iteration
1+ hit a 400 from the backend: "jinja template rendering failed.
Message has tool role, but there was no previous assistant message
with a tool call!" — the backend saw a role=tool message without a
recognised assistant-with-tool-calls predecessor.

The fix replaces the broken inline copy with a direct call to
parse::build_native_assistant_history (the same serialiser
tool_loop.rs uses successfully for the orchestrator's tool calls).
The dead build_native_assistant_payload function is removed.

Introduced in PR #474 (6465f3d3, 2026-04-09). Latent since then
because the orchestrator self-heals by retrying via other agents.

E2E verified: skills_agent with gmail toolkit now progresses through
iterations 0→1→2 successfully (previously died at iteration 1 with
the 400 error every time).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-04-16 18:25:03 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent fd213d40a2
commit a78506f6a3
+8 -26
View File
@@ -728,8 +728,15 @@ async fn run_inner_loop(
// Persist assistant message with the original tool_calls payload so
// subsequent role=tool messages can reference call ids correctly.
// Uses the canonical serialiser from `parse` — the old inline
// `build_native_assistant_payload` used `"text"` instead of
// `"content"` and nested `{"type":"function","function":{…}}`
// wrappers instead of flat `{id, name, arguments}`, which caused
// 400 errors from the backend jinja template ("Message has tool
// role, but there was no previous assistant message with a tool
// call!").
let assistant_history_content =
build_native_assistant_payload(&response_text, &native_calls);
super::parse::build_native_assistant_history(&response_text, &native_calls);
history.push(ChatMessage::assistant(assistant_history_content));
// Execute each call, append role=tool messages.
@@ -779,31 +786,6 @@ async fn run_inner_loop(
Err(SubagentRunError::MaxIterationsExceeded(max_iterations))
}
fn build_native_assistant_payload(text: &str, tool_calls: &[ToolCall]) -> String {
// Mirror the existing native-tool-call serialisation pattern used by
// `agent::loop_::parse::build_native_assistant_history`. We inline a
// small subset here to avoid an inter-module dep cycle.
let calls_json: Vec<serde_json::Value> = tool_calls
.iter()
.map(|call| {
serde_json::json!({
"id": call.id,
"type": "function",
"function": {
"name": call.name,
"arguments": call.arguments,
},
})
})
.collect();
let payload = serde_json::json!({
"text": text,
"tool_calls": calls_json,
});
payload.to_string()
}
fn parse_tool_arguments(arguments: &str) -> serde_json::Value {
serde_json::from_str(arguments)
.unwrap_or_else(|_| serde_json::Value::Object(Default::default()))