Skip to content

Commit ab0437a

Browse files
bojiang3bojiang3maralbaharifranciscojavierarceo
authored
feat: add shell tool wire types (vllm-project#264)
## Summary - Add typed local shell declarations, `shell_call` and `shell_call_output` items, command limits, stdout/stderr, and exit/timeout outcomes. - Normalize shell declarations, selectors, and inference history to function tools, then restore public shell items and incremental command events. Native shell streams share the existing strict lifecycle validation. - Preserve submitted public shell items and extension fields in storage; lower the inference copy when preparing new input or rehydrating later turns. Restore public shell declarations and selectors in response metadata, including inherited settings and requests using tool search. - Keep execution client-side: callers can run commands through their own engine or framework and submit `shell_call_output`. This PR does not register a gateway-side shell executor; that remains follow-up work. - Include 16 real OpenAI-reference and gateway recordings covering success, nonzero exits, timeouts, and multiple commands in streaming and non-streaming modes, with two-turn continuation and replay/parity tests. Part of vllm-project#170. ## Test Plan - `cargo test --offline --workspace`: 1,198 passed, 9 ignored. - `cargo test --offline -p agentic-server-core`: 870 passed, 9 ignored after the final assertions. - `cargo clippy --offline --workspace --all-targets -- -D warnings`. - `uvx --from pre-commit pre-commit run --all-files`, including formatting and merge-conflict checks. - Regression tests first reproduced the persistence and streaming metadata failures, then passed with the fixes. Coverage includes response and conversation storage, extension fields, a third turn loading persisted shell output, inherited tools/selectors, and shell metadata with and without tool search. - Existing recordings were replayed; no new live provider recordings were made for the final two fixes. --------- Signed-off-by: bojiang3 <bojiang3@illinois.edu> Signed-off-by: maral <maralbahari.98@gmail.com> Signed-off-by: Francisco Javier Arceo <farceo@redhat.com> Co-authored-by: bojiang3 <bojiang3@illinois.edu> Co-authored-by: maral <maralbahari.98@gmail.com> Co-authored-by: Francisco Javier Arceo <farceo@redhat.com>
1 parent 9b22fb3 commit ab0437a

59 files changed

Lines changed: 23288 additions & 99 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ARCHITECTURE.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,11 @@ classification and reshapes those raw calls accordingly:
493493
synthetic `function_call` events named `tool_search` are projected into that same
494494
public lifecycle after validation.
495495

496+
Shell functions are restored to `shell_call` for client execution by default.
497+
When an application explicitly registers a shell executor, the translator instead
498+
suppresses those internal function frames using the registry's resolved ownership;
499+
the existing gateway event plan emits the shell call's added/done lifecycle.
500+
496501
It also buffers function-call events that arrive before the call's name is known
497502
(bounded at 256 KiB) and replays them once the name resolves.
498503

@@ -740,13 +745,23 @@ the behavioral layer — routing, handler traits, normalization, and execution.
740745
reused across requests, specifically for gateway tools that need **lazy, per-request
741746
connection setup**: MCP servers (connects and caches `McpClient`s keyed by server
742747
URL, falling back to connecting a fresh request-declared server) and the shared
743-
`WebSearchHandler`. As of today it only has slots for `ToolType::Mcp` and
744-
`ToolType::WebSearch`; `GatewayExecutorRegistration` has typed variants for those
745-
supported slots. Client-owned
748+
`WebSearchHandler`. It also has an optional, application-provided `ShellExecutor`
749+
slot. `GatewayExecutorRegistration::Shell` is an explicit execution grant; an
750+
unregistered shell declaration remains client-executed. `ShellExecutor` accepts
751+
a typed call with bounded action limits and cancellation and returns typed command
752+
outputs. The adapter binds into the existing gateway scheduler, not a second tool loop.
753+
Client-owned
746754
tools (`function`, `custom`, `namespace`) never touch this file; their registry
747755
entries are inserted with `ToolOwnership::Client` and no `GatewayExecutors`
748756
involvement.
749757

758+
Shell item history is preserved publicly in storage. At the inference boundary,
759+
`ShellHandler::model_input` lowers shell calls and outputs into matching function
760+
history, just as declarations and explicit shell selectors are normalized. For an
761+
opt-in gateway executor, storage additionally retains the canonical internal function
762+
call/output pair; rehydration omits that pair's public shell-call projection to avoid
763+
replaying the invocation twice. Client-executed shell history is not omitted.
764+
750765
**To add a new tool type:**
751766
1. Implement `ToolHandler`, including its typed `ToolParams`, for it. If it's client-executed,
752767
stop theresee `function.rs`/`custom.rs` for the pattern.

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/agentic-server-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ serde_json.workspace = true
4242
sse-stream.workspace = true
4343
thiserror.workspace = true
4444
tokio = { workspace = true, features = ["time"] }
45+
tokio-util = { workspace = true, features = ["rt"] }
4546
tracing.workspace = true
4647
url.workspace = true
4748

crates/agentic-server-core/src/events/normalize.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use serde_json::Value;
22

3-
use super::types::{EventFrame, EventPayload, SSEEventType, SSEItemType, WireEvent};
3+
use super::types::{EventFrame, EventPayload, SSEEventType, SSEItemType, ShellCommandUpdate, WireEvent};
4+
use crate::types::io::OutputItem;
45
use crate::utils::common::{deserialize_from_str_opt, deserialize_from_value_opt};
56

67
/// Normalize a raw SSE data line into a typed [`EventFrame`].
@@ -64,6 +65,9 @@ fn extract_payload(event_type: SSEEventType, json: &Value) -> EventPayload {
6465
SSEEventType::FunctionCallArgumentsDone => extract_fn_call_args_done(json),
6566
SSEEventType::CustomToolCallInputDelta => extract_custom_tool_call_input_delta(json),
6667
SSEEventType::CustomToolCallInputDone => extract_custom_tool_call_input_done(json),
68+
SSEEventType::ShellCallCommandAdded => extract_shell_call_command_added(json),
69+
SSEEventType::ShellCallCommandDelta => extract_shell_call_command_delta(json),
70+
SSEEventType::ShellCallCommandDone => extract_shell_call_command_done(json),
6771

6872
SSEEventType::ReasoningTextDelta => extract_reasoning_text_delta(json),
6973
SSEEventType::ReasoningTextDone => extract_reasoning_text_done(json),
@@ -133,6 +137,14 @@ fn extract_output_item_added(json: &Value) -> EventPayload {
133137
name: json_str_opt(item, "name"),
134138
namespace: json_str_opt(item, "namespace"),
135139
call_id: json_str_opt(item, "call_id"),
140+
shell_call: if item["type"] == "shell_call" {
141+
match deserialize_from_value_opt::<OutputItem>(item.clone()) {
142+
Some(OutputItem::ShellCall(call)) => Some(Box::new(call)),
143+
_ => None,
144+
}
145+
} else {
146+
None
147+
},
136148
}
137149
}
138150

@@ -169,6 +181,27 @@ fn extract_text_done(json: &Value) -> EventPayload {
169181
}
170182
}
171183

184+
fn extract_shell_call_command_added(json: &Value) -> EventPayload {
185+
extract_shell_command(json, ShellCommandUpdate::Added(json_str(json, "command")))
186+
}
187+
188+
fn extract_shell_call_command_delta(json: &Value) -> EventPayload {
189+
extract_shell_command(json, ShellCommandUpdate::Delta(json_str(json, "delta")))
190+
}
191+
192+
fn extract_shell_call_command_done(json: &Value) -> EventPayload {
193+
extract_shell_command(json, ShellCommandUpdate::Done(json_str(json, "command")))
194+
}
195+
196+
fn extract_shell_command(json: &Value, update: ShellCommandUpdate) -> EventPayload {
197+
EventPayload::ShellCallCommand {
198+
item_id: json_str(json, "item_id"),
199+
output_index: json_u32(json, "output_index"),
200+
command_index: json_u32(json, "command_index"),
201+
update,
202+
}
203+
}
204+
172205
fn extract_fn_call_args_delta(json: &Value) -> EventPayload {
173206
EventPayload::FunctionCallArgsDelta {
174207
delta: json_str(json, "delta"),

crates/agentic-server-core/src/events/types.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use serde::{Deserialize, Serialize};
22
use serde_json::{Map, Value};
33

4-
use crate::types::io::{OutputItem, ResponseUsage};
4+
use crate::types::io::{OutputItem, ResponseUsage, ShellCall};
55

66
/// The type of an output item received during streaming.
77
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -13,6 +13,7 @@ pub enum SSEItemType {
1313
WebSearchCall,
1414
McpCall,
1515
McpListTools,
16+
ShellCall,
1617
Compaction,
1718
Message,
1819
}
@@ -28,6 +29,7 @@ impl SSEItemType {
2829
Self::WebSearchCall => "web_search_call",
2930
Self::McpCall => "mcp_call",
3031
Self::McpListTools => "mcp_list_tools",
32+
Self::ShellCall => "shell_call",
3133
Self::Compaction => "compaction",
3234
Self::Message => "message",
3335
}
@@ -52,6 +54,7 @@ impl std::str::FromStr for SSEItemType {
5254
"web_search_call" => Ok(Self::WebSearchCall),
5355
"mcp_call" => Ok(Self::McpCall),
5456
"mcp_list_tools" => Ok(Self::McpListTools),
57+
"shell_call" => Ok(Self::ShellCall),
5558
"compaction" => Ok(Self::Compaction),
5659
"message" => Ok(Self::Message),
5760
_ => Err(()),
@@ -71,6 +74,7 @@ impl TryFrom<&OutputItem> for SSEItemType {
7174
OutputItem::WebSearchCall(_) => Ok(Self::WebSearchCall),
7275
OutputItem::McpCall(_) => Ok(Self::McpCall),
7376
OutputItem::McpListTools(_) => Ok(Self::McpListTools),
77+
OutputItem::ShellCall(_) => Ok(Self::ShellCall),
7478
OutputItem::Reasoning(_) => Ok(Self::Reasoning),
7579
OutputItem::Compaction(_) => Ok(Self::Compaction),
7680
OutputItem::Unknown => Err(()),
@@ -125,6 +129,9 @@ pub enum SSEEventType {
125129
FunctionCallArgumentsDone,
126130
CustomToolCallInputDelta,
127131
CustomToolCallInputDone,
132+
ShellCallCommandAdded,
133+
ShellCallCommandDelta,
134+
ShellCallCommandDone,
128135

129136
// Reasoning
130137
ReasoningTextDelta,
@@ -171,6 +178,9 @@ impl From<&str> for SSEEventType {
171178
"response.function_call_arguments.done" => Self::FunctionCallArgumentsDone,
172179
"response.custom_tool_call_input.delta" => Self::CustomToolCallInputDelta,
173180
"response.custom_tool_call_input.done" => Self::CustomToolCallInputDone,
181+
"response.shell_call_command.added" => Self::ShellCallCommandAdded,
182+
"response.shell_call_command.delta" => Self::ShellCallCommandDelta,
183+
"response.shell_call_command.done" => Self::ShellCallCommandDone,
174184
"response.reasoning_text.delta" => Self::ReasoningTextDelta,
175185
"response.reasoning_text.done" => Self::ReasoningTextDone,
176186
"response.reasoning_part.added" => Self::ReasoningPartAdded,
@@ -215,6 +225,9 @@ impl TryFrom<SSEEventType> for &'static str {
215225
SSEEventType::FunctionCallArgumentsDone => Ok("response.function_call_arguments.done"),
216226
SSEEventType::CustomToolCallInputDelta => Ok("response.custom_tool_call_input.delta"),
217227
SSEEventType::CustomToolCallInputDone => Ok("response.custom_tool_call_input.done"),
228+
SSEEventType::ShellCallCommandAdded => Ok("response.shell_call_command.added"),
229+
SSEEventType::ShellCallCommandDelta => Ok("response.shell_call_command.delta"),
230+
SSEEventType::ShellCallCommandDone => Ok("response.shell_call_command.done"),
218231
SSEEventType::ReasoningTextDelta => Ok("response.reasoning_text.delta"),
219232
SSEEventType::ReasoningTextDone => Ok("response.reasoning_text.done"),
220233
SSEEventType::ReasoningPartAdded => Ok("response.reasoning_part.added"),
@@ -276,6 +289,14 @@ impl WireEvent {
276289
}
277290
}
278291

292+
/// One command's incremental lifecycle within a shell output item.
293+
#[derive(Debug, Clone)]
294+
pub enum ShellCommandUpdate {
295+
Added(String),
296+
Delta(String),
297+
Done(String),
298+
}
299+
279300
/// Typed payload extracted from an SSE event's JSON data.
280301
#[derive(Debug, Clone)]
281302
#[non_exhaustive]
@@ -296,6 +317,8 @@ pub enum EventPayload {
296317
name: Option<String>,
297318
namespace: Option<String>,
298319
call_id: Option<String>,
320+
/// Preserve the typed initial shell item before command events arrive.
321+
shell_call: Option<Box<ShellCall>>,
299322
},
300323

301324
/// `response.output_item.done`
@@ -339,6 +362,12 @@ pub enum EventPayload {
339362
},
340363

341364
/// `response.custom_tool_call_input.delta`
365+
ShellCallCommand {
366+
item_id: String,
367+
output_index: u32,
368+
command_index: u32,
369+
update: ShellCommandUpdate,
370+
},
342371
CustomToolCallInputDelta {
343372
delta: String,
344373
item_id: String,
@@ -468,6 +497,9 @@ mod tests {
468497
SSEEventType::FunctionCallArgumentsDone,
469498
SSEEventType::CustomToolCallInputDelta,
470499
SSEEventType::CustomToolCallInputDone,
500+
SSEEventType::ShellCallCommandAdded,
501+
SSEEventType::ShellCallCommandDelta,
502+
SSEEventType::ShellCallCommandDone,
471503
SSEEventType::ReasoningTextDelta,
472504
SSEEventType::ReasoningTextDone,
473505
SSEEventType::ReasoningPartAdded,

crates/agentic-server-core/src/events/validate.rs

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ pub(crate) fn validate_frame(frame: &EventFrame) -> Result<ValidatedFrame<'_>, E
4949
SSEEventType::Other => Ok(ValidatedFrame { item: None }),
5050
event_type => {
5151
let output_index = required_output_index(frame, event_name)?;
52-
let item_id = required_str(&frame.wire.rest, "item_id", event_name)?;
52+
let item_id = validate_event_item_id(frame, event_name)?;
5353
validate_event_fields(&frame.wire.rest, event_type, event_name)?;
5454
let item_type = expected_item_type(event_type).ok_or_else(|| {
5555
invalid(format!(
@@ -80,6 +80,9 @@ fn expected_item_type(event_type: SSEEventType) -> Option<SSEItemType> {
8080
SSEEventType::CustomToolCallInputDelta | SSEEventType::CustomToolCallInputDone => {
8181
Some(SSEItemType::CustomToolCall)
8282
}
83+
SSEEventType::ShellCallCommandAdded
84+
| SSEEventType::ShellCallCommandDelta
85+
| SSEEventType::ShellCallCommandDone => Some(SSEItemType::ShellCall),
8386
SSEEventType::ReasoningTextDelta
8487
| SSEEventType::ReasoningTextDone
8588
| SSEEventType::ReasoningPartAdded
@@ -192,12 +195,30 @@ fn validate_output_item<'a>(
192195
})
193196
}
194197

198+
fn validate_event_item_id<'a>(frame: &'a EventFrame, event_name: &str) -> Result<&'a str, EventError> {
199+
if matches!(
200+
frame.event_type,
201+
SSEEventType::ShellCallCommandAdded | SSEEventType::ShellCallCommandDelta | SSEEventType::ShellCallCommandDone
202+
) && !frame.wire.rest.contains_key("item_id")
203+
{
204+
// Only native shell command events may omit the ID and resolve by output_index.
205+
return Ok("");
206+
}
207+
let item_id = required_str(&frame.wire.rest, "item_id", event_name)?;
208+
Ok(item_id)
209+
}
210+
195211
fn validate_event_fields(
196212
event: &Map<String, Value>,
197213
event_type: SSEEventType,
198214
event_name: &str,
199215
) -> Result<(), EventError> {
200216
match event_type {
217+
SSEEventType::ShellCallCommandAdded
218+
| SSEEventType::ShellCallCommandDelta
219+
| SSEEventType::ShellCallCommandDone => {
220+
required_u32(event, "command_index", event_name)?;
221+
}
201222
SSEEventType::OutputTextDelta
202223
| SSEEventType::OutputTextDone
203224
| SSEEventType::ContentPartAdded
@@ -218,6 +239,7 @@ fn validate_event_fields(
218239
SSEEventType::OutputTextDelta
219240
| SSEEventType::FunctionCallArgumentsDelta
220241
| SSEEventType::CustomToolCallInputDelta
242+
| SSEEventType::ShellCallCommandDelta
221243
| SSEEventType::ReasoningTextDelta
222244
| SSEEventType::ReasoningSummaryTextDelta
223245
| SSEEventType::McpCallArgumentsDelta => Some("delta"),
@@ -226,6 +248,7 @@ fn validate_event_fields(
226248
}
227249
SSEEventType::FunctionCallArgumentsDone | SSEEventType::McpCallArgumentsDone => Some("arguments"),
228250
SSEEventType::CustomToolCallInputDone => Some("input"),
251+
SSEEventType::ShellCallCommandAdded | SSEEventType::ShellCallCommandDone => Some("command"),
229252
SSEEventType::ContentPartAdded
230253
| SSEEventType::ContentPartDone
231254
| SSEEventType::ReasoningPartAdded
@@ -308,3 +331,48 @@ fn missing_field(owner: &str, field: &str) -> EventError {
308331
fn invalid(message: impl Into<String>) -> EventError {
309332
EventError(message.into())
310333
}
334+
335+
#[cfg(test)]
336+
mod tests {
337+
use serde_json::json;
338+
339+
use super::validate_frame;
340+
use crate::events::normalize_sse_line;
341+
342+
#[test]
343+
fn only_native_shell_command_events_allow_omitted_item_id() {
344+
for (event_type, allows_omitted_id) in [
345+
("response.shell_call_command.added", true),
346+
("response.shell_call_command.delta", true),
347+
("response.shell_call_command.done", true),
348+
("response.function_call_arguments.delta", false),
349+
("response.function_call_arguments.done", false),
350+
("response.custom_tool_call_input.delta", false),
351+
("response.custom_tool_call_input.done", false),
352+
("response.mcp_call_arguments.delta", false),
353+
] {
354+
let event = json!({
355+
"type": event_type,
356+
"output_index": 0,
357+
"command_index": 0,
358+
"command": "pwd",
359+
"delta": "",
360+
"arguments": "{}",
361+
"input": "pwd"
362+
});
363+
let frame = normalize_sse_line(&format!("data: {event}")).unwrap();
364+
assert_eq!(validate_frame(&frame).is_ok(), allows_omitted_id, "{event_type}");
365+
366+
for item_id in [json!(""), json!(null), json!(42), json!("sh_1")] {
367+
let mut event = event.clone();
368+
event["item_id"] = item_id.clone();
369+
let frame = normalize_sse_line(&format!("data: {event}")).unwrap();
370+
assert_eq!(
371+
validate_frame(&frame).is_ok(),
372+
item_id == json!("sh_1"),
373+
"{event_type} with item_id={item_id}"
374+
);
375+
}
376+
}
377+
}
378+
}

0 commit comments

Comments
 (0)