Skip to content
Draft
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
14 changes: 14 additions & 0 deletions TERMINOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,19 @@ available executor. It routes calls after inference; it is not part of the Respo
The project-specific conversion of heterogeneous tool declarations into the function-tool shape accepted by the
upstream inference server. Normalization changes the upstream representation, not the public tool's meaning.

### tool search

A built-in tool that lets a model discover and load deferred tool definitions at runtime. Preserve the exact
`tool_search`, `tool_search_call`, and `tool_search_output` spellings for their respective wire types. Qualify the
term as **client-executed tool search** when the caller, such as Codex, searches its own catalog; the gateway passes
that call and output through and does not execute the search.

### deferred tool

A tool whose full definition is loaded only when selected through tool search. Use the exact `defer_loading` spelling
for the wire field. For a namespace, `defer_loading` belongs to the nested function declaration rather than the
namespace object.

### pass-through

Forwarding a request, field, tool declaration, call, response, or error without executing it locally. Use
Expand Down Expand Up @@ -360,6 +373,7 @@ These definitions follow current OpenAI documentation:
- [Conversation state](https://developers.openai.com/api/docs/guides/conversation-state)
- [Function calling](https://developers.openai.com/api/docs/guides/function-calling)
- [Using tools](https://developers.openai.com/api/docs/guides/tools)
- [Tool search](https://developers.openai.com/api/docs/guides/tools-tool-search)
- [MCP and Connectors](https://developers.openai.com/api/docs/guides/tools-connectors-mcp)
- [Streaming API responses](https://developers.openai.com/api/docs/guides/streaming-responses)
- [Reasoning models](https://developers.openai.com/api/docs/guides/reasoning)
Expand Down
10 changes: 8 additions & 2 deletions crates/agentic-server-core/src/events/normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ fn json_u32(json: &Value, key: &str) -> u32 {
u32::try_from(json[key].as_u64().unwrap_or(0)).unwrap_or(u32::MAX)
}

fn output_item_type(item: &Value) -> SSEItemType {
item.get("type")
.and_then(Value::as_str)
.map_or(SSEItemType::Message, SSEItemType::from)
}

fn extract_response_payload(json: &Value) -> EventPayload {
let response = &json["response"];
EventPayload::Response {
Expand All @@ -100,7 +106,7 @@ fn extract_output_item_added(json: &Value) -> EventPayload {
let item = &json["item"];
EventPayload::OutputItemAdded {
item_id: json_str(item, "id"),
item_type: SSEItemType::from(json_str(item, "type")),
item_type: output_item_type(item),
output_index: json_u32(json, "output_index"),
name: json_str_opt(item, "name"),
namespace: json_str_opt(item, "namespace"),
Expand All @@ -112,7 +118,7 @@ fn extract_output_item_done(json: &Value) -> EventPayload {
let item = &json["item"];
EventPayload::OutputItemDone {
item_id: json_str(item, "id"),
item_type: SSEItemType::from(json_str(item, "type")),
item_type: output_item_type(item),
output_index: json_u32(json, "output_index"),
item: item.clone(),
}
Expand Down
11 changes: 10 additions & 1 deletion crates/agentic-server-core/src/events/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ pub enum SSEItemType {
Reasoning,
FunctionCall,
CustomToolCall,
ToolSearchCall,
ToolSearchOutput,
WebSearchCall,
McpCall,
Message,
Unknown,
}

impl SSEItemType {
Expand All @@ -21,9 +24,12 @@ impl SSEItemType {
Self::Reasoning => "reasoning",
Self::FunctionCall => "function_call",
Self::CustomToolCall => "custom_tool_call",
Self::ToolSearchCall => "tool_search_call",
Self::ToolSearchOutput => "tool_search_output",
Self::WebSearchCall => "web_search_call",
Self::McpCall => "mcp_call",
Self::Message => "message",
Self::Unknown => "unknown",
}
}
}
Expand All @@ -34,9 +40,12 @@ impl From<&str> for SSEItemType {
"reasoning" => Self::Reasoning,
"function_call" => Self::FunctionCall,
"custom_tool_call" => Self::CustomToolCall,
"tool_search_call" => Self::ToolSearchCall,
"tool_search_output" => Self::ToolSearchOutput,
"web_search_call" => Self::WebSearchCall,
"mcp_call" => Self::McpCall,
_ => Self::Message,
"message" => Self::Message,
_ => Self::Unknown,
}
}
}
Expand Down
56 changes: 55 additions & 1 deletion crates/agentic-server-core/src/executor/accumulator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,11 @@ impl ResponseAccumulator {
text: String::with_capacity(256),
}),
SSEItemType::WebSearchCall if !item_id.is_empty() => Some(InFlight::WebSearchCall { item: None }),
SSEItemType::WebSearchCall => None,
SSEItemType::McpCall => McpCall::try_from(payload).ok().map(|item| InFlight::McpCall { item }),
SSEItemType::WebSearchCall
| SSEItemType::ToolSearchCall
| SSEItemType::ToolSearchOutput
| SSEItemType::Unknown => None,
};
if let Some(item) = item {
self.in_flight.insert(
Expand Down Expand Up @@ -496,6 +499,14 @@ impl ResponseAccumulator {
item.apply_done(payload, &mut String::new());
return;
}
if matches!(item_type, SSEItemType::ToolSearchCall | SSEItemType::ToolSearchOutput) {
if let Some(output_item @ (OutputItem::ToolSearchCall(_) | OutputItem::ToolSearchOutput(_))) =
deserialize_from_value_opt::<OutputItem>(raw_item.clone())
{
self.completed.push((*output_index, output_item));
}
return;
}
if let Some(output_item @ OutputItem::McpCall(_)) = deserialize_from_value_opt::<OutputItem>(raw_item.clone()) {
self.completed.push((*output_index, output_item));
}
Expand Down Expand Up @@ -527,6 +538,7 @@ impl ResponseAccumulator {
model: model.to_string(),
status: self.status.as_str().to_string(),
output: self.output,
tools: None,
usage: self.usage,
incomplete_details: self.incomplete_details,
error: self.error,
Expand Down Expand Up @@ -1580,4 +1592,46 @@ mod tests {
assert_eq!(call.input, "*** Begin Patch");
assert_eq!(call.status, Some(MessageStatus::Completed));
}

#[test]
fn test_reasoning_precedes_done_only_tool_search_by_output_index() {
let lines = vec![
r#"data: {"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[]}}"#.to_owned(),
r#"data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_1","output_index":0,"delta":"Need a tool."}"#.to_owned(),
r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"tool_search_call","execution":"client","call_id":"call_search","status":"completed","arguments":{"query":"shell"}}}"#.to_owned(),
r#"data: {"type":"response.completed","response":{"id":"resp_search","status":"completed","usage":null}}"#.to_owned(),
];

let acc = ResponseAccumulator::from_sse_lines(lines, None);
assert_eq!(acc.output.len(), 2);
assert!(matches!(acc.output[0], OutputItem::Reasoning(_)));
assert!(matches!(acc.output[1], OutputItem::ToolSearchCall(_)));
}

#[test]
fn test_tool_search_completion_order_is_sorted_by_output_index() {
let lines = vec![
r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"tool_search_output","execution":"client","call_id":"call_search","status":"completed","tools":[]}}"#.to_owned(),
r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"tool_search_call","execution":"client","call_id":"call_search","status":"completed","arguments":{"query":"shell"}}}"#.to_owned(),
];

let acc = ResponseAccumulator::from_sse_lines(lines, None);
assert_eq!(acc.output.len(), 2);
assert!(matches!(acc.output[0], OutputItem::ToolSearchCall(_)));
assert!(matches!(acc.output[1], OutputItem::ToolSearchOutput(_)));
}

#[test]
fn test_tool_search_and_unknown_added_items_do_not_create_messages() {
let lines = vec![
r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"tool_search_call","execution":"client","call_id":"call_search","status":"in_progress","arguments":{}}}"#.to_owned(),
r#"data: {"type":"response.output_item.added","output_index":1,"item":{"id":"future_1","type":"future_item"}}"#.to_owned(),
r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"tool_search_call","execution":"client","call_id":"call_search","status":"completed","arguments":{"query":"shell"}}}"#.to_owned(),
r#"data: {"type":"response.output_item.done","output_index":1,"item":{"id":"future_1","type":"future_item","payload":{"a":1}}}"#.to_owned(),
];

let acc = ResponseAccumulator::from_sse_lines(lines, None);
assert_eq!(acc.output.len(), 1);
assert!(matches!(acc.output[0], OutputItem::ToolSearchCall(_)));
}
}
2 changes: 2 additions & 0 deletions crates/agentic-server-core/src/executor/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ fn item_has_meaningful_context(item: &InputItem) -> bool {
InputItem::FunctionCallOutput(output) => !output.output.trim().is_empty(),
InputItem::CustomToolCall(call) => !call.name.trim().is_empty() || !call.input.trim().is_empty(),
InputItem::CustomToolCallOutput(output) => value_has_content(&output.output),
InputItem::ToolSearchCall(call) => value_has_content(&call.arguments),
InputItem::ToolSearchOutput(output) => !output.tools.is_empty(),
InputItem::Reasoning(reasoning) => {
reasoning.content.iter().any(|content| !content.text.trim().is_empty())
|| reasoning.summary.iter().any(value_has_content)
Expand Down
55 changes: 35 additions & 20 deletions crates/agentic-server-core/src/executor/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,13 @@ async fn run_until_gateway_tools_complete(
auth: Option<&str>,
stream_upstream: bool,
mut stream: Option<(&mut GatewayStreamAccumulator, &mpsc::UnboundedSender<StreamEvent>)>,
) -> ExecutorResult<(ResponsePayload, RequestContext)> {
) -> ExecutorResult<(ResponsePayload, RequestContext, ToolRegistry)> {
let mut executors = exec_ctx.gateway_executors.request_scoped();
let registry: ToolRegistry = match ctx.enriched_request.tools.as_mut() {
let mut registry: ToolRegistry = match ctx.enriched_request.tools.as_mut() {
Some(tools) => ToolRegistry::build_with_handlers(tools, &mut executors).await?,
None => ToolRegistry::default(),
};
registry.load_tool_search_output(&ctx.enriched_request.input);
let mut combined_output: Vec<crate::OutputItem> = Vec::new();
let mut combined_usage = None;

Expand All @@ -128,20 +129,10 @@ async fn run_until_gateway_tools_complete(
} else {
(fetch_blocking_payload(&ctx, exec_ctx, auth).await?, Vec::new())
};
registry.restore_final_payload_output(&mut payload.output);
registry.restore_final_payload(&mut payload);
accumulate_usage(&mut combined_usage, payload.usage.take());
let current_output = std::mem::take(&mut payload.output);
for item in &current_output {
if let OutputItem::CustomToolCall(call) = item {
debug!(
response_id = %ctx.response_id,
call_id = %call.call_id,
name = %call.name,
input_bytes = call.input.len(),
"custom tool call requires client execution"
);
}
}
log_client_execution_items(&ctx.response_id, &current_output);
let has_client_owned = has_client_owned_calls(&current_output, &registry);
let gateway_results = execute_and_emit_round_output_calls(
&current_output,
Expand All @@ -168,12 +159,12 @@ async fn run_until_gateway_tools_complete(
gateway_results.into_iter().map(|result| result.input_item).collect(),
);
finalize_loop(&mut payload, combined_output, combined_usage, &ctx);
return Ok((payload, ctx));
return Ok((payload, ctx, registry));
}
// No gateway work remains — this turn is the final response.
LoopDecision::Done => {
finalize_loop(&mut payload, combined_output, combined_usage, &ctx);
return Ok((payload, ctx));
return Ok((payload, ctx, registry));
}
// Budget exhausted while the model was still requesting gateway
// tools: surface the accumulated work as a partial
Expand All @@ -189,7 +180,7 @@ async fn run_until_gateway_tools_complete(
finalize_loop(&mut payload, combined_output, combined_usage, &ctx);
"incomplete".clone_into(&mut payload.status);
payload.incomplete_details = Some(IncompleteDetails { reason: Some(reason) });
return Ok((payload, ctx));
return Ok((payload, ctx, registry));
}
// Gateway tools ran and rounds remain; feed outputs back and loop.
LoopDecision::Continue => {
Expand All @@ -207,6 +198,30 @@ async fn run_until_gateway_tools_complete(
unreachable!("the final round returns Done, RequiresClientAction, or Incomplete");
}

fn log_client_execution_items(response_id: &str, output: &[OutputItem]) {
for item in output {
match item {
OutputItem::CustomToolCall(call) => {
debug!(
response_id,
call_id = %call.call_id,
name = %call.name,
input_bytes = call.input.len(),
"custom tool call requires client execution"
);
}
OutputItem::ToolSearchCall(call) if call.requires_client_execution() => {
debug!(
response_id,
call_id = ?call.call_id,
"tool search call requires client execution"
);
}
_ => {}
}
}
}

async fn execute_and_emit_round_output_calls(
output_items: &[OutputItem],
registry: &ToolRegistry,
Expand Down Expand Up @@ -332,7 +347,7 @@ async fn run_blocking(
exec_ctx: &ExecutionContext,
auth: Option<&str>,
) -> ExecutorResult<ResponsePayload> {
let (payload, ctx) = run_until_gateway_tools_complete(ctx, exec_ctx, auth, false, None).await?;
let (payload, ctx, _registry) = run_until_gateway_tools_complete(ctx, exec_ctx, auth, false, None).await?;

let ch = exec_ctx.conv_handler.clone();
let rh = exec_ctx.resp_handler.clone();
Expand Down Expand Up @@ -380,7 +395,7 @@ fn run_stream(ctx: RequestContext, exec_ctx: Arc<ExecutionContext>, auth: Option
yield stream_accumulator.executor_error_chunk(&e);
yield DONE_MARKER.to_string();
}
Ok((Ok((payload, ctx)), mut stream_accumulator)) => {
Ok((Ok((payload, ctx, registry)), mut stream_accumulator)) => {
while let Ok(event) = event_rx.try_recv() {
yield consume_stream_event(event, &mut next_sequence_number);
}
Expand All @@ -391,7 +406,7 @@ fn run_stream(ctx: RequestContext, exec_ctx: Arc<ExecutionContext>, auth: Option
let ch = exec_ctx.conv_handler.clone();
let rh = exec_ctx.resp_handler.clone();
let mut terminal_accumulator = stream_accumulator.clone();
let terminal_chunk = terminal_accumulator.terminal_response_chunk(&payload);
let terminal_chunk = terminal_accumulator.terminal_response_chunk(&payload, &registry);
match persist_if_needed(payload, ctx, ch, rh).await {
Ok(()) => match terminal_chunk {
Ok(chunk) => yield chunk,
Expand Down
4 changes: 4 additions & 0 deletions crates/agentic-server-core/src/executor/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@ pub(super) fn emit_gateway_start_events(
OutputItem::Message(_)
| OutputItem::FunctionCall(_)
| OutputItem::CustomToolCall(_)
| OutputItem::ToolSearchCall(_)
| OutputItem::ToolSearchOutput(_)
| OutputItem::Reasoning(_)
| OutputItem::Unknown => {}
}
Expand Down Expand Up @@ -381,6 +383,8 @@ pub(super) fn emit_gateway_completed_events(
OutputItem::Message(_)
| OutputItem::FunctionCall(_)
| OutputItem::CustomToolCall(_)
| OutputItem::ToolSearchCall(_)
| OutputItem::ToolSearchOutput(_)
| OutputItem::Reasoning(_)
| OutputItem::Unknown => continue,
};
Expand Down
Loading