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
38 changes: 27 additions & 11 deletions src/harness/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,8 +686,10 @@ pub struct StreamAccumulator {
/// the final message text).
reasoning: String,
/// Per-call-id accumulated tool-call argument fragments, in first-seen
/// order.
tool_chunks: Vec<(String, String)>,
/// order: `(call_id, arguments, tool_name)`. The name is the first non-empty
/// `ToolDelta::tool_name` seen for the call (providers surface it on the
/// call-opening delta); `None` until one arrives.
tool_chunks: Vec<(String, String, Option<String>)>,
/// Most recent usage value seen.
usage: Option<Usage>,
/// Authoritative final response, when a `Completed` item was seen.
Expand All @@ -710,11 +712,15 @@ impl StreamAccumulator {
self.text.push_str(&delta.text);
self.reasoning.push_str(&delta.reasoning);
if let Some(tool_call) = &delta.tool_call {
self.push_tool_chunk(&tool_call.call_id, &tool_call.content);
self.push_tool_chunk(
&tool_call.call_id,
&tool_call.content,
tool_call.tool_name.as_deref(),
);
}
}
ModelStreamItem::ToolCallDelta(delta) => {
self.push_tool_chunk(&delta.call_id, &delta.content);
self.push_tool_chunk(&delta.call_id, &delta.content, delta.tool_name.as_deref());
}
ModelStreamItem::UsageDelta(usage) => {
self.usage = Some(*usage);
Expand All @@ -741,13 +747,23 @@ impl StreamAccumulator {
}

/// Appends a tool-call argument fragment for `call_id`, preserving
/// first-seen ordering across calls.
fn push_tool_chunk(&mut self, call_id: &str, content: &str) {
if let Some(entry) = self.tool_chunks.iter_mut().find(|(id, _)| id == call_id) {
/// first-seen ordering across calls. Records the first non-empty `tool_name`
/// seen for the call (call-opening deltas carry it; argument fragments do
/// not) so the reconstructed [`ToolCall`] is named.
fn push_tool_chunk(&mut self, call_id: &str, content: &str, tool_name: Option<&str>) {
if let Some(entry) = self.tool_chunks.iter_mut().find(|(id, ..)| id == call_id) {
entry.1.push_str(content);
if entry.2.is_none() {
if let Some(name) = tool_name.filter(|n| !n.is_empty()) {
entry.2 = Some(name.to_string());
}
}
} else {
self.tool_chunks
.push((call_id.to_string(), content.to_string()));
self.tool_chunks.push((
call_id.to_string(),
content.to_string(),
tool_name.filter(|n| !n.is_empty()).map(str::to_string),
));
}
}

Expand Down Expand Up @@ -805,8 +821,8 @@ impl StreamAccumulator {
let tool_calls = self
.tool_chunks
.into_iter()
.map(|(id, args)| ToolCall {
name: String::new(),
.map(|(id, args, name)| ToolCall {
name: name.unwrap_or_default(),
arguments: serde_json::from_str(&args).unwrap_or(Value::Null),
id,
})
Expand Down
29 changes: 29 additions & 0 deletions src/harness/model/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,34 @@ fn finish_backfills_usage_from_stream_delta_when_completed_lacks_it() {
assert_eq!(finished.message.usage, Some(Usage::new(4, 6)));
}

#[test]
fn finish_names_reconstructed_tool_call_from_the_call_opening_delta_name() {
// A call-opening delta carries the tool name (no args yet); subsequent
// argument fragments carry only content. With no authoritative `Completed`
// response, the accumulator must still name the reconstructed tool call from
// the first non-empty `tool_name` it saw for that call id.
use crate::harness::tool::ToolDelta;

let mut acc = StreamAccumulator::new();
acc.push(&ModelStreamItem::ToolCallDelta(ToolDelta {
call_id: "call-1".into(),
content: String::new(),
tool_name: Some("search".into()),
}));
acc.push(&ModelStreamItem::ToolCallDelta(ToolDelta {
call_id: "call-1".into(),
content: r#"{"q":"rust"}"#.into(),
tool_name: None,
}));

let finished = acc.finish().unwrap();
assert_eq!(finished.message.tool_calls.len(), 1);
let call = &finished.message.tool_calls[0];
assert_eq!(call.id, "call-1");
assert_eq!(call.name, "search", "name carried from the opening delta");
assert_eq!(call.arguments, serde_json::json!({ "q": "rust" }));
}

/// Round-trips a [`ModelStreamItem`] through JSON and asserts the re-serialized
/// form is byte-for-byte stable, proving every variant survives serde.
fn roundtrip_stream_item(item: ModelStreamItem) {
Expand All @@ -704,6 +732,7 @@ fn model_stream_item_roundtrips_every_variant() {
crate::harness::tool::ToolDelta {
call_id: "call-1".into(),
content: "{\"q\":1}".into(),
tool_name: None,
},
));
roundtrip_stream_item(ModelStreamItem::UsageDelta(Usage::new(3, 5)));
Expand Down
4 changes: 4 additions & 0 deletions src/harness/providers/openai/sse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ impl OpenAiStreamAcc {
pending.push_back(ModelStreamItem::ToolCallDelta(ToolDelta {
call_id,
content: args,
// Surface the tool name (captured into `slot.name` from
// the call-opening fragment) so consumers can label the
// call as it streams; the accumulator keeps the first.
tool_name: Some(slot.name.clone()).filter(|n| !n.is_empty()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Emit the tool name when the call opens

Because this is populated from the cached slot.name only inside the non-empty-arguments branch, an OpenAI opening fragment such as {name: "lookup", arguments: ""} emits no start/name delta at all, and then every later split argument fragment carries Some("lookup") because the slot name remains cached. That contradicts the new ToolDelta contract that the name labels the call-opening delta and later argument fragments leave it None; consumers using Some(tool_name) as the streamed start marker will miss zero-argument/name-only openings and can emit duplicate starts for chunked arguments. Emit a delta when a new non-empty name is observed and only mark the name once per call.

Useful? React with 👍 / 👎.

}));
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/harness/tool/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,14 @@ pub struct ToolDelta {
pub call_id: String,
/// Incremental content fragment.
pub content: String,
/// The tool's name, when known. Providers that surface it on the first
/// (call-opening) delta populate this so consumers can label a tool call as
/// soon as it begins — before its arguments have streamed. Subsequent
/// argument fragments leave it `None`; [`StreamAccumulator`] remembers the
/// first non-empty name per `call_id` and stamps it onto the reconstructed
/// [`ToolCall`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
}

/// A tool the harness can invoke during an agent loop.
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e_harness_provider_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,12 @@ async fn model_request_response_registry_and_stream_contracts_are_stable() {
accumulator.push(&ModelStreamItem::ToolCallDelta(ToolDelta {
call_id: "call-1".into(),
content: r#"{"a":"#.into(),
tool_name: None,
}));
accumulator.push(&ModelStreamItem::ToolCallDelta(ToolDelta {
call_id: "call-1".into(),
content: r#""b"}"#.into(),
tool_name: None,
}));
accumulator.push(&ModelStreamItem::UsageDelta(Usage::new(4, 5)));
accumulator.push(&ModelStreamItem::MessageDelta(MessageDelta {
Expand Down Expand Up @@ -316,6 +318,7 @@ fn model_profiles_stream_chunks_usage_and_cost_are_additive() {
tool_call: Some(ToolDelta {
call_id: "tool-1".into(),
content: "partial".into(),
tool_name: None,
}),
}),
StreamChunk::Debug("trace".into()),
Expand Down
1 change: 1 addition & 0 deletions tests/e2e_middleware_parser_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ async fn middleware_stack_runs_lifecycle_hooks_and_builtin_guards() {
let mut tool_delta = ToolDelta {
call_id: "tool-1".into(),
content: "progress".into(),
tool_name: None,
};
stack
.run_on_tool_delta(&mut ctx, &(), &mut tool_delta)
Expand Down
Loading