From 6b69659a1c9f78fd68da6f902e8bf817ee8007ad Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Tue, 11 Aug 2026 16:07:09 -0400 Subject: [PATCH] file transfer (rust): parse send_message.files[] onto the tool context Add UserFile + ToolProviderContext.files (parallel to images) + with_files builder; parse send_message.files[] fail-soft in handler and thread through the runner onto the tool context. Files are surfaced to host tools (for the host to persist into the workspace), NOT sent to the model. Rust already had images + the directive sink, so this completes Rust's side of the file-transfer contract. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Vzh8t76aDVgeoiwC9KEKZZ --- .changeset/files-field-send-message.md | 12 ++++ .../examples/dev-support/tests/serve_smoke.rs | 1 + rust/smooth-operator-lambda/src/dispatch.rs | 1 + rust/smooth-operator-server/src/config.rs | 6 +- rust/smooth-operator-server/src/handler.rs | 42 +++++++++++++ rust/smooth-operator-server/src/runner.rs | 11 +++- .../tests/acl_chat_leak.rs | 1 + .../tests/acl_trusted_mode.rs | 1 + .../tests/agent_tool_auth.rs | 1 + .../tests/confirm_tool_action.rs | 1 + .../tests/conversation_workflow.rs | 2 + .../tests/empty_reply_fallback.rs | 1 + .../tests/gateway_wire_empty_reply.rs | 1 + .../tests/injection_seams.rs | 3 + .../tests/interactions.rs | 1 + .../tests/knowledge_org_scoping.rs | 1 + .../tests/suggested_replies.rs | 1 + .../smooth-operator-server/tests/telemetry.rs | 1 + rust/smooth-operator/src/tool_provider.rs | 63 +++++++++++++++++++ 19 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 .changeset/files-field-send-message.md diff --git a/.changeset/files-field-send-message.md b/.changeset/files-field-send-message.md new file mode 100644 index 00000000..e9f1649c --- /dev/null +++ b/.changeset/files-field-send-message.md @@ -0,0 +1,12 @@ +--- +'@smooai/smooth-operator-server': minor +--- + +`send_message` grows an optional `files[]` array — non-image attachments the host persists, never sent to the model. + +Each entry is `{ name, mimeType?, url }`. Parsing mirrors `images`: fail-soft (an absent key ⇒ empty, a malformed array is dropped rather than rejecting the turn). Unlike `images`, files do NOT reach the model — they ride the turn's `ToolProviderContext` only, so a host tool (paired with the `send_file` directive convention on `eventual_response.directive`) can persist and echo them back. + +- New `smooth_operator::tool_provider::UserFile { name, mime, url }` (`mimeType` ↔ `mime` on the wire), alongside `UserImage`. +- `ToolProviderContext.files` + `with_files(..)` builder; threaded from the `send_message` frame through `TurnRequest` into the per-turn tool-provider context. + +Backward compatible: an absent `files` field is byte-for-byte the previous behavior. Rust is the reference implementation. diff --git a/rust/examples/dev-support/tests/serve_smoke.rs b/rust/examples/dev-support/tests/serve_smoke.rs index a37b5c89..b482b3b1 100644 --- a/rust/examples/dev-support/tests/serve_smoke.rs +++ b/rust/examples/dev-support/tests/serve_smoke.rs @@ -382,6 +382,7 @@ async fn grounded_turn_over_served_storage_answers_from_the_ingested_repo() { tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator-lambda/src/dispatch.rs b/rust/smooth-operator-lambda/src/dispatch.rs index ff5ebb85..add45626 100644 --- a/rust/smooth-operator-lambda/src/dispatch.rs +++ b/rust/smooth-operator-lambda/src/dispatch.rs @@ -588,6 +588,7 @@ async fn send_message( extensions: None, // The lambda flavor is text-only; no multimodal attachments. images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator-server/src/config.rs b/rust/smooth-operator-server/src/config.rs index afd2b775..33491667 100644 --- a/rust/smooth-operator-server/src/config.rs +++ b/rust/smooth-operator-server/src/config.rs @@ -436,7 +436,9 @@ mod tests { /// which surfaces as an assistant that says nothing at all. #[test] fn default_temperature_is_the_universally_accepted_value() { - assert!((DEFAULT_TEMPERATURE - 1.0).abs() < f32::EPSILON, "temperature must be 1.0; see DEFAULT_TEMPERATURE for the measured table"); + assert!( + (DEFAULT_TEMPERATURE - 1.0).abs() < f32::EPSILON, + "temperature must be 1.0; see DEFAULT_TEMPERATURE for the measured table" + ); } - } diff --git a/rust/smooth-operator-server/src/handler.rs b/rust/smooth-operator-server/src/handler.rs index 807f5a58..ae0a82aa 100644 --- a/rust/smooth-operator-server/src/handler.rs +++ b/rust/smooth-operator-server/src/handler.rs @@ -1235,6 +1235,15 @@ async fn handle_send_message( .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); + // Optional non-image file attachments (`files[]`: `{name, mimeType?, url}`). + // Fail-soft like `images`: absent ⇒ none, a malformed array is dropped rather + // than rejecting the turn. Files never reach the model — they ride the + // tool-provider context so a host tool can persist them. + let files: Vec = parsed + .get("files") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + // Optional named skill. The wire carries the INTENT ("use skill X"); the // server resolves the body here and composes it into the turn's system // prompt below, so `message` stays exactly what the user typed (and is what @@ -1628,6 +1637,9 @@ async fn handle_send_message( extensions, // Optional multimodal attachments (empty ⇒ text-only, unchanged). images, + // Optional non-image file attachments (empty ⇒ none). Never sent + // to the model — carried onto the tool-provider context only. + files, }, &sink_owned, ) @@ -2256,6 +2268,36 @@ mod tests { use super::*; use smooth_operator_core::llm::{ApiFormat, RetryPolicy}; + /// The `send_message.files[]` parse (mirrors the inline handler expression): + /// a well-formed array parses onto the turn context, a malformed array is + /// dropped rather than rejecting the turn, and an absent key ⇒ empty. + #[test] + fn files_array_parses_fail_soft() { + use smooth_operator::tool_provider::UserFile; + let parse = |v: Value| -> Vec { + v.get("files") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default() + }; + + // Well-formed: `mimeType` maps onto `mime`; missing MIME ⇒ None. + let files = parse(json!({ + "files": [ + { "name": "report.pdf", "mimeType": "application/pdf", "url": "https://x/report.pdf" }, + { "name": "notes.txt", "url": "data:text/plain;base64,AAAA" } + ] + })); + assert_eq!(files.len(), 2); + assert_eq!(files[0].name, "report.pdf"); + assert_eq!(files[0].mime.as_deref(), Some("application/pdf")); + assert_eq!(files[1].mime, None); + + // Malformed entry (missing required `url`) drops the whole array, like `images`. + assert!(parse(json!({ "files": [{ "name": "x" }] })).is_empty()); + // Absent key ⇒ empty. + assert!(parse(json!({})).is_empty()); + } + /// The durable workflow pointer round-trips through conversation metadata and /// a FRESH load resumes on it — the whole point of moving it off the per-pod /// in-memory session map, which reset to step 0 on every reconnect / pod hop diff --git a/rust/smooth-operator-server/src/runner.rs b/rust/smooth-operator-server/src/runner.rs index 21d83257..67ead219 100644 --- a/rust/smooth-operator-server/src/runner.rs +++ b/rust/smooth-operator-server/src/runner.rs @@ -400,6 +400,11 @@ pub struct TurnRequest<'a> { /// [`ToolProviderContext`] so a host tool can see them. Empty (the default) /// ⇒ a text-only turn, byte-for-byte unchanged. pub images: Vec, + /// Non-image file attachments for this turn. Unlike [`images`](Self::images), + /// these are NOT sent to the model — the runner only carries them into the + /// [`ToolProviderContext`] so a host tool can persist them. Empty (the + /// default) ⇒ byte-for-byte unchanged. + pub files: Vec, } /// Runs one knowledge-grounded, streaming turn for a session's conversation and @@ -451,6 +456,7 @@ pub async fn run_streaming_turn( tool_configs, extensions, images, + files, } = req; // Capture the OTel turn-span attributes up front, since `llm` is moved into @@ -650,7 +656,10 @@ pub async fn run_streaming_turn( // directive; drained after the turn onto `eventual_response.directive`. .with_directive_sink(Arc::clone(&directive_sink)) // Multimodal: let a host tool see the turn's image attachments. - .with_images(images.clone()); + .with_images(images.clone()) + // File transfer: carry the turn's non-image attachments so a host + // tool can persist them. These never reach the model. + .with_files(files.clone()); if let Some(key) = gateway_key { ctx = ctx.with_gateway_key(key); } diff --git a/rust/smooth-operator-server/tests/acl_chat_leak.rs b/rust/smooth-operator-server/tests/acl_chat_leak.rs index 91906e6d..dfa4cf18 100644 --- a/rust/smooth-operator-server/tests/acl_chat_leak.rs +++ b/rust/smooth-operator-server/tests/acl_chat_leak.rs @@ -143,6 +143,7 @@ async fn run_turn_as( tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator-server/tests/acl_trusted_mode.rs b/rust/smooth-operator-server/tests/acl_trusted_mode.rs index e3f9324f..38c4a031 100644 --- a/rust/smooth-operator-server/tests/acl_trusted_mode.rs +++ b/rust/smooth-operator-server/tests/acl_trusted_mode.rs @@ -148,6 +148,7 @@ async fn run_turn_as( tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator-server/tests/agent_tool_auth.rs b/rust/smooth-operator-server/tests/agent_tool_auth.rs index 4d459502..4f22f7af 100644 --- a/rust/smooth-operator-server/tests/agent_tool_auth.rs +++ b/rust/smooth-operator-server/tests/agent_tool_auth.rs @@ -144,6 +144,7 @@ async fn run( tool_configs, extensions: None, images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator-server/tests/confirm_tool_action.rs b/rust/smooth-operator-server/tests/confirm_tool_action.rs index 20068483..5f74bc91 100644 --- a/rust/smooth-operator-server/tests/confirm_tool_action.rs +++ b/rust/smooth-operator-server/tests/confirm_tool_action.rs @@ -160,6 +160,7 @@ fn spawn_turn( tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &sink, ) diff --git a/rust/smooth-operator-server/tests/conversation_workflow.rs b/rust/smooth-operator-server/tests/conversation_workflow.rs index ae1f5647..214c0045 100644 --- a/rust/smooth-operator-server/tests/conversation_workflow.rs +++ b/rust/smooth-operator-server/tests/conversation_workflow.rs @@ -108,6 +108,7 @@ async fn run_turn( tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) @@ -289,6 +290,7 @@ async fn run_turn_on( tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator-server/tests/empty_reply_fallback.rs b/rust/smooth-operator-server/tests/empty_reply_fallback.rs index 460091f5..8010d0be 100644 --- a/rust/smooth-operator-server/tests/empty_reply_fallback.rs +++ b/rust/smooth-operator-server/tests/empty_reply_fallback.rs @@ -142,6 +142,7 @@ async fn empty_terminal_content_falls_back_to_streamed_reply() { tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator-server/tests/gateway_wire_empty_reply.rs b/rust/smooth-operator-server/tests/gateway_wire_empty_reply.rs index d3118f5e..87a5ede1 100644 --- a/rust/smooth-operator-server/tests/gateway_wire_empty_reply.rs +++ b/rust/smooth-operator-server/tests/gateway_wire_empty_reply.rs @@ -92,6 +92,7 @@ async fn run_against(chunks: Vec) -> (String, Vec, Vec, tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator-server/tests/injection_seams.rs b/rust/smooth-operator-server/tests/injection_seams.rs index 8970d358..bf4c798d 100644 --- a/rust/smooth-operator-server/tests/injection_seams.rs +++ b/rust/smooth-operator-server/tests/injection_seams.rs @@ -145,6 +145,7 @@ async fn run_turn_with_key( tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) @@ -237,6 +238,7 @@ async fn run_turn_with_storage( tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) @@ -486,6 +488,7 @@ async fn injected_tool_hook_observes_tool_calls() { request_id: "req-hook", user_message: "look me up", images: vec![], + files: vec![], model_max_output: None, access: AccessContext::anonymous(), llm_provider: Some(Arc::new(mock)), diff --git a/rust/smooth-operator-server/tests/interactions.rs b/rust/smooth-operator-server/tests/interactions.rs index 190a8fce..1e98afe6 100644 --- a/rust/smooth-operator-server/tests/interactions.rs +++ b/rust/smooth-operator-server/tests/interactions.rs @@ -197,6 +197,7 @@ fn spawn_turn( tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &sink, ) diff --git a/rust/smooth-operator-server/tests/knowledge_org_scoping.rs b/rust/smooth-operator-server/tests/knowledge_org_scoping.rs index fc14a9af..2ccfe522 100644 --- a/rust/smooth-operator-server/tests/knowledge_org_scoping.rs +++ b/rust/smooth-operator-server/tests/knowledge_org_scoping.rs @@ -226,6 +226,7 @@ async fn run_turn_as(storage: Arc, access: AccessContext) { tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator-server/tests/suggested_replies.rs b/rust/smooth-operator-server/tests/suggested_replies.rs index 97b5405c..f986dd2b 100644 --- a/rust/smooth-operator-server/tests/suggested_replies.rs +++ b/rust/smooth-operator-server/tests/suggested_replies.rs @@ -72,6 +72,7 @@ async fn run_turn(deltas: &[&str]) -> (TurnResult, Vec, String) { tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator-server/tests/telemetry.rs b/rust/smooth-operator-server/tests/telemetry.rs index aca2fdaf..7eace5d2 100644 --- a/rust/smooth-operator-server/tests/telemetry.rs +++ b/rust/smooth-operator-server/tests/telemetry.rs @@ -190,6 +190,7 @@ async fn streaming_turn_emits_gen_ai_spans_with_org_and_tool_args() { tool_configs: None, extensions: None, images: vec![], + files: vec![], }, &tx, ) diff --git a/rust/smooth-operator/src/tool_provider.rs b/rust/smooth-operator/src/tool_provider.rs index 9cb725c9..2b00c93b 100644 --- a/rust/smooth-operator/src/tool_provider.rs +++ b/rust/smooth-operator/src/tool_provider.rs @@ -55,6 +55,22 @@ pub struct UserImage { pub detail: Option, } +/// A non-image file attachment on a turn's user message (the `send_message` +/// `files[]` array). Unlike [`UserImage`], a file is NOT emitted to the model — +/// it only rides the [`ToolProviderContext`] so the host can persist it. `url` +/// is a `data:`/`https` URL; `mime` is the optional MIME type (`mimeType` on the +/// wire, per the spec), omitted when absent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserFile { + /// A human-facing file name. + pub name: String, + /// Optional MIME type. Wire key is `mimeType` (spec); local field is `mime`. + #[serde(rename = "mimeType", default, skip_serializing_if = "Option::is_none")] + pub mime: Option, + /// A `data:`/`https` URL locating the file's bytes. + pub url: String, +} + /// The per-turn context a [`ToolProvider`] sees when asked for tools. /// /// Carries everything a host needs to decide which tools a turn gets WITHOUT @@ -99,6 +115,10 @@ pub struct ToolProviderContext { /// read them; empty for the text-only common case. The runner also maps these /// onto the engine's user message via core `with_user_images`. pub images: Vec, + /// The non-image file attachments this turn carried. Unlike `images`, these + /// are NOT sent to the model — they ride the context only so a host tool can + /// persist them. Empty for the common case. + pub files: Vec, } impl ToolProviderContext { @@ -118,6 +138,7 @@ impl ToolProviderContext { tool_specific_config: std::collections::HashMap::new(), directive_sink: None, images: Vec::new(), + files: Vec::new(), } } @@ -159,6 +180,14 @@ impl ToolProviderContext { self.images = images; self } + + /// Set the turn's [`files`](Self::files) — the non-image attachments the turn + /// carried, for the host to persist. Never sent to the model. + #[must_use] + pub fn with_files(mut self, files: Vec) -> Self { + self.files = files; + self + } } /// Host seam for contributing EXTRA tools to a turn's [`ToolRegistry`]. @@ -239,6 +268,7 @@ mod tests { assert_eq!(ctx.gateway_key, None); assert!(ctx.directive_sink.is_none()); assert!(ctx.images.is_empty()); + assert!(ctx.files.is_empty()); } #[test] @@ -271,6 +301,39 @@ mod tests { assert_eq!(ctx.images[0].detail.as_deref(), Some("high")); } + #[test] + fn builder_sets_files() { + let ctx = ToolProviderContext::new(Some("org-a".into()), AccessContext::anonymous()) + .with_files(vec![UserFile { + name: "report.pdf".into(), + mime: Some("application/pdf".into()), + url: "https://x/report.pdf".into(), + }]); + assert_eq!(ctx.files.len(), 1); + assert_eq!(ctx.files[0].name, "report.pdf"); + assert_eq!(ctx.files[0].mime.as_deref(), Some("application/pdf")); + assert_eq!(ctx.files[0].url, "https://x/report.pdf"); + } + + #[test] + fn user_file_round_trips_with_mime_type_wire_key() { + // Wire key is `mimeType` (spec); absent MIME omits the key entirely. + let v = serde_json::to_value(UserFile { + name: "a.txt".into(), + mime: Some("text/plain".into()), + url: "data:text/plain;base64,AAAA".into(), + }) + .unwrap(); + assert_eq!(v["mimeType"], "text/plain"); + assert!(v.get("mime").is_none()); + + let back: UserFile = + serde_json::from_value(serde_json::json!({"name": "a.txt", "url": "u"})).unwrap(); + assert_eq!(back.name, "a.txt"); + assert_eq!(back.url, "u"); + assert!(back.mime.is_none()); + } + #[test] fn user_image_omits_detail_when_absent() { // Back-compat wire shape: no `detail` key when None.