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
12 changes: 12 additions & 0 deletions .changeset/files-field-send-message.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions rust/examples/dev-support/tests/serve_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
1 change: 1 addition & 0 deletions rust/smooth-operator-lambda/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ async fn send_message(
extensions: None,
// The lambda flavor is text-only; no multimodal attachments.
images: vec![],
files: vec![],
},
&tx,
)
Expand Down
6 changes: 4 additions & 2 deletions rust/smooth-operator-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

}
42 changes: 42 additions & 0 deletions rust/smooth-operator-server/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<smooth_operator::tool_provider::UserFile> = 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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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<UserFile> {
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
Expand Down
11 changes: 10 additions & 1 deletion rust/smooth-operator-server/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<smooth_operator::tool_provider::UserImage>,
/// 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<smooth_operator::tool_provider::UserFile>,
}

/// Runs one knowledge-grounded, streaming turn for a session's conversation and
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions rust/smooth-operator-server/tests/acl_chat_leak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ async fn run_turn_as(
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down
1 change: 1 addition & 0 deletions rust/smooth-operator-server/tests/acl_trusted_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ async fn run_turn_as(
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down
1 change: 1 addition & 0 deletions rust/smooth-operator-server/tests/agent_tool_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ async fn run(
tool_configs,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down
1 change: 1 addition & 0 deletions rust/smooth-operator-server/tests/confirm_tool_action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ fn spawn_turn(
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&sink,
)
Expand Down
2 changes: 2 additions & 0 deletions rust/smooth-operator-server/tests/conversation_workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ async fn run_turn(
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down Expand Up @@ -289,6 +290,7 @@ async fn run_turn_on(
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down
1 change: 1 addition & 0 deletions rust/smooth-operator-server/tests/empty_reply_fallback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ async fn empty_terminal_content_falls_back_to_streamed_reply() {
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ async fn run_against(chunks: Vec<String>) -> (String, Vec<String>, Vec<String>,
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down
3 changes: 3 additions & 0 deletions rust/smooth-operator-server/tests/injection_seams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ async fn run_turn_with_key(
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down Expand Up @@ -237,6 +238,7 @@ async fn run_turn_with_storage(
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down Expand Up @@ -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)),
Expand Down
1 change: 1 addition & 0 deletions rust/smooth-operator-server/tests/interactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ fn spawn_turn(
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&sink,
)
Expand Down
1 change: 1 addition & 0 deletions rust/smooth-operator-server/tests/knowledge_org_scoping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ async fn run_turn_as(storage: Arc<dyn StorageAdapter>, access: AccessContext) {
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down
1 change: 1 addition & 0 deletions rust/smooth-operator-server/tests/suggested_replies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ async fn run_turn(deltas: &[&str]) -> (TurnResult, Vec<String>, String) {
tool_configs: None,
extensions: None,
images: vec![],
files: vec![],
},
&tx,
)
Expand Down
1 change: 1 addition & 0 deletions rust/smooth-operator-server/tests/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
63 changes: 63 additions & 0 deletions rust/smooth-operator/src/tool_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ pub struct UserImage {
pub detail: Option<String>,
}

/// 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<String>,
/// 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
Expand Down Expand Up @@ -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<UserImage>,
/// 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<UserFile>,
}

impl ToolProviderContext {
Expand All @@ -118,6 +138,7 @@ impl ToolProviderContext {
tool_specific_config: std::collections::HashMap::new(),
directive_sink: None,
images: Vec::new(),
files: Vec::new(),
}
}

Expand Down Expand Up @@ -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<UserFile>) -> Self {
self.files = files;
self
}
}

/// Host seam for contributing EXTRA tools to a turn's [`ToolRegistry`].
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down
Loading