diff --git a/.gitignore b/.gitignore index ef0fef441..18e56b591 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ target/ .idea/ .DS_Store docs +!harness/docs/ .worktrees node_modules package-lock.json diff --git a/README.md b/README.md index 46ed22134..810e0eb08 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ matching GitHub Release asset for the host's target triple. | [`models-catalog`](models-catalog/) | Rust | Model capabilities knowledge base under `models::*` (list/get/supports/register). | | [`oauth-anthropic`](oauth-anthropic/) | Rust | Anthropic Claude Pro/Max OAuth (PKCE localhost flow) under `oauth::anthropic::*`. | | [`oauth-openai-codex`](oauth-openai-codex/) | Rust | OpenAI Codex OAuth (PKCE localhost flow) under `oauth::openai_codex::*`. | -| [`policy-denylist`](policy-denylist/) | Rust | Hook subscriber on `agent::before_tool_call` that blocks calls whose name is on a configured denylist. | +| [`policy-denylist`](policy-denylist/) | Rust | Hook subscriber on `agent::before_function_call` that blocks calls whose function id is on a configured denylist. | | [`proof`](proof/) | Node | AI-driven browser testing — diffs changes, generates test plans, drives Playwright. | | [`provider-anthropic`](provider-anthropic/) | Rust | Native Anthropic Messages API streaming provider under `provider::anthropic::*`. | | [`provider-openai`](provider-openai/) | Rust | OpenAI Chat Completions provider under `provider::openai::*`. | diff --git a/approval-gate/README.md b/approval-gate/README.md index a65974688..d4b022861 100644 --- a/approval-gate/README.md +++ b/approval-gate/README.md @@ -1,12 +1,12 @@ # approval-gate -Subscriber on `agent::before_tool_call`. Pauses tool calls whose name appears +Subscriber on `agent::before_function_call`. Pauses function calls whose id appears in the run's `approval_required` list, emits `ApprovalRequested` onto `agent::events/`, and waits for the UI to call `approval::resolve` (or for the configured timeout, default 5 minutes). ## Functions -- `approval::resolve { tool_call_id, decision, reason? }` — flip a pending entry to `allow` or `deny`. +- `approval::resolve { function_call_id, tool_call_id?, decision, reason? }` — flip a pending entry to `allow` or `deny` (`tool_call_id` accepted for backward compatibility). - `approval::list_pending { session_id }` — return currently-blocked calls (used by the UI on tab refresh). ## Config (env) diff --git a/approval-gate/iii.worker.yaml b/approval-gate/iii.worker.yaml index 2b4aeb779..b263e4646 100644 --- a/approval-gate/iii.worker.yaml +++ b/approval-gate/iii.worker.yaml @@ -4,8 +4,8 @@ language: rust deploy: binary manifest: Cargo.toml bin: iii-approval-gate -description: Hook subscriber on agent::before_tool_call that pauses tool calls listed in approval_required until the UI resolves them via approval::resolve. +description: Hook subscriber on agent::before_function_call that pauses function calls listed in approval_required until the UI resolves them via approval::resolve. config: - topic: agent::before_tool_call + topic: agent::before_function_call approval_state_scope: approvals default_timeout_ms: 300000 diff --git a/approval-gate/src/lib.rs b/approval-gate/src/lib.rs index 706f24bbe..5540a768a 100644 --- a/approval-gate/src/lib.rs +++ b/approval-gate/src/lib.rs @@ -1,5 +1,5 @@ -//! Approval gate. Subscribes to `agent::before_tool_call` and blocks calls -//! whose `tool_call.name` appears in the run's `approval_required` list, +//! Approval gate. Subscribes to `agent::before_function_call` and blocks calls +//! whose `function_call.function_id` appears in the run's `approval_required` list, //! waiting for the UI to call `approval::resolve` (or for a timeout). use std::sync::Arc; @@ -23,7 +23,7 @@ pub struct Config { impl Default for Config { fn default() -> Self { Self { - topic: "agent::before_tool_call".into(), + topic: "agent::before_function_call".into(), timeout_ms: DEFAULT_TIMEOUT_MS, } } @@ -48,8 +48,8 @@ impl Config { #[derive(Debug, Clone, PartialEq)] pub struct IncomingCall { pub session_id: String, - pub tool_call_id: String, - pub tool_name: String, + pub function_call_id: String, + pub function_id: String, pub args: Value, pub approval_required: Vec, pub event_id: String, @@ -58,7 +58,9 @@ pub struct IncomingCall { impl IncomingCall { pub fn requires_approval(&self) -> bool { - self.approval_required.iter().any(|n| n == &self.tool_name) + self.approval_required + .iter() + .any(|n| n == &self.function_id) } } @@ -81,15 +83,15 @@ pub enum WireDecision { /// Build the state-store key for a pending approval entry. /// -/// `session_id` and `tool_call_id` must not contain `/`. They are caller-controlled +/// `session_id` and `function_call_id` must not contain `/`. They are caller-controlled /// IDs minted by turn-orchestrator; today neither format uses the separator. -pub fn pending_key(session_id: &str, tool_call_id: &str) -> String { +pub fn pending_key(session_id: &str, function_call_id: &str) -> String { debug_assert!(!session_id.contains('/'), "session_id must not contain '/'"); debug_assert!( - !tool_call_id.contains('/'), - "tool_call_id must not contain '/'" + !function_call_id.contains('/'), + "function_call_id must not contain '/'" ); - format!("{session_id}/{tool_call_id}") + format!("{session_id}/{function_call_id}") } pub fn extract_call(envelope: &Value) -> Option { @@ -103,12 +105,19 @@ pub fn extract_call(envelope: &Value) -> Option { .to_string(); let inner = envelope.get("payload").unwrap_or(envelope); let session_id = inner.get("session_id").and_then(Value::as_str)?.to_string(); - let tc = inner.get("tool_call")?; + let fc = inner + .get("function_call") + .or_else(|| inner.get("tool_call"))?; + let function_id = fc + .get("function_id") + .or_else(|| fc.get("name")) + .and_then(Value::as_str)? + .to_string(); Some(IncomingCall { session_id, - tool_call_id: tc.get("id").and_then(Value::as_str)?.to_string(), - tool_name: tc.get("name").and_then(Value::as_str)?.to_string(), - args: tc.get("arguments").cloned().unwrap_or_else(|| json!({})), + function_call_id: fc.get("id").and_then(Value::as_str)?.to_string(), + function_id, + args: fc.get("arguments").cloned().unwrap_or_else(|| json!({})), approval_required: inner .get("approval_required") .and_then(|v| serde_json::from_value(v.clone()).ok()) @@ -119,15 +128,15 @@ pub fn extract_call(envelope: &Value) -> Option { } pub fn build_pending_record( - tool_call_id: &str, - tool_name: &str, + function_call_id: &str, + function_id: &str, args: &Value, now_ms: u64, timeout_ms: u64, ) -> Value { json!({ - "tool_call_id": tool_call_id, - "tool_name": tool_name, + "function_call_id": function_call_id, + "function_id": function_id, "args": args, "status": "pending", "expires_at": now_ms.saturating_add(timeout_ms), @@ -163,11 +172,12 @@ pub async fn handle_resolve(bus: &dyn StateBus, payload: Value) -> Value { .get("session_id") .and_then(Value::as_str) .unwrap_or(""); - let tool_call_id = payload - .get("tool_call_id") + let function_call_id = payload + .get("function_call_id") + .or_else(|| payload.get("tool_call_id")) .and_then(Value::as_str) .unwrap_or(""); - if session_id.is_empty() || tool_call_id.is_empty() { + if session_id.is_empty() || function_call_id.is_empty() { return json!({ "ok": false, "error": "missing_id" }); } let Some(decision) = payload @@ -177,7 +187,7 @@ pub async fn handle_resolve(bus: &dyn StateBus, payload: Value) -> Value { else { return json!({ "ok": false, "error": "bad_decision" }); }; - let key = pending_key(session_id, tool_call_id); + let key = pending_key(session_id, function_call_id); let Some(mut existing) = bus.get(STATE_SCOPE, &key).await else { return json!({ "ok": false, "error": "not_found" }); }; @@ -218,10 +228,10 @@ const POLL_INTERVAL_MS: u64 = 250; pub async fn await_decision( bus: &dyn StateBus, session_id: &str, - tool_call_id: &str, + function_call_id: &str, expires_at: u64, ) -> Decision { - let key = pending_key(session_id, tool_call_id); + let key = pending_key(session_id, function_call_id); loop { let Some(rec) = bus.get(STATE_SCOPE, &key).await else { return Decision::Deny { @@ -377,7 +387,7 @@ pub fn register(iii: &III, config: Config) -> anyhow::Result { let bus_for_sub = bus.clone(); let subscriber_fn = iii.register_function(( RegisterFunctionMessage::with_id("policy::approval_gate".into()) - .with_description("Pause tool calls listed in approval_required.".into()), + .with_description("Pause function calls listed in approval_required.".into()), move |envelope: Value| { let iii = iii_for_sub.clone(); let bus = bus_for_sub.clone(); @@ -396,8 +406,8 @@ pub fn register(iii: &III, config: Config) -> anyhow::Result { .unwrap_or(0); let expires_at = now.saturating_add(timeout_ms); let record = build_pending_record( - &call.tool_call_id, - &call.tool_name, + &call.function_call_id, + &call.function_id, &call.args, now, timeout_ms, @@ -405,7 +415,7 @@ pub fn register(iii: &III, config: Config) -> anyhow::Result { if let Err(err) = bus .set( STATE_SCOPE, - &pending_key(&call.session_id, &call.tool_call_id), + &pending_key(&call.session_id, &call.function_call_id), record, ) .await @@ -413,7 +423,7 @@ pub fn register(iii: &III, config: Config) -> anyhow::Result { log::error!( "approval-gate: failed to write pending record for {}/{}: {err}", call.session_id, - call.tool_call_id + call.function_call_id ); let reply = json!({ "block": false }); write_hook_reply(&iii, &call.reply_stream, &call.event_id, &reply).await; @@ -424,8 +434,10 @@ pub fn register(iii: &III, config: Config) -> anyhow::Result { &call.session_id, &json!({ "type": "approval_requested", - "tool_call_id": call.tool_call_id, - "tool_name": call.tool_name, + "function_call_id": call.function_call_id, + "tool_call_id": call.function_call_id, + "function_id": call.function_id, + "tool_name": call.function_id, "args": call.args, "expires_at": expires_at, }), @@ -434,7 +446,7 @@ pub fn register(iii: &III, config: Config) -> anyhow::Result { let decision = await_decision( bus.as_ref(), &call.session_id, - &call.tool_call_id, + &call.function_call_id, expires_at, ) .await; @@ -447,7 +459,8 @@ pub fn register(iii: &III, config: Config) -> anyhow::Result { &call.session_id, &json!({ "type": "approval_resolved", - "tool_call_id": call.tool_call_id, + "function_call_id": call.function_call_id, + "tool_call_id": call.function_call_id, "decision": decision_str, "reason": reason_for_event, }), @@ -488,31 +501,47 @@ mod tests { } #[test] - fn extract_call_reads_session_id_and_tool_call_from_envelope() { + fn extract_call_reads_session_id_and_function_call_from_envelope() { let envelope = json!({ "event_id": "evt-1", "reply_stream": "rs-1", "payload": { - "tool_call": { "id": "tc-1", "name": "write", "arguments": {"path": "/tmp/x"} }, + "function_call": { "id": "tc-1", "function_id": "write", "arguments": {"path": "/tmp/x"} }, "approval_required": ["write"], "session_id": "s1", } }); let call = extract_call(&envelope).expect("decoded"); assert_eq!(call.session_id, "s1"); - assert_eq!(call.tool_call_id, "tc-1"); - assert_eq!(call.tool_name, "write"); + assert_eq!(call.function_call_id, "tc-1"); + assert_eq!(call.function_id, "write"); assert_eq!(call.event_id, "evt-1"); assert_eq!(call.reply_stream, "rs-1"); assert!(call.approval_required.iter().any(|s| s == "write")); } #[test] - fn requires_approval_only_for_listed_tools() { + fn extract_call_accepts_legacy_tool_call_envelope_with_name() { + let envelope = json!({ + "event_id": "evt-1", + "reply_stream": "rs-1", + "payload": { + "tool_call": { "id": "tc-1", "name": "write", "arguments": {} }, + "approval_required": ["write"], + "session_id": "s1", + } + }); + let call = extract_call(&envelope).expect("decoded"); + assert_eq!(call.function_call_id, "tc-1"); + assert_eq!(call.function_id, "write"); + } + + #[test] + fn requires_approval_only_for_listed_functions() { let call = IncomingCall { session_id: "s1".into(), - tool_call_id: "tc-1".into(), - tool_name: "ls".into(), + function_call_id: "tc-1".into(), + function_id: "ls".into(), args: json!({}), approval_required: vec!["write".into()], event_id: "e".into(), @@ -521,7 +550,7 @@ mod tests { assert!(!call.requires_approval()); let call2 = IncomingCall { - tool_name: "write".into(), + function_id: "write".into(), ..call }; assert!(call2.requires_approval()); @@ -532,7 +561,7 @@ mod tests { let now = 1_000_000; let rec = build_pending_record("tc-1", "write", &json!({"x": 1}), now, 60_000); assert_eq!(rec["status"], "pending"); - assert_eq!(rec["tool_call_id"], "tc-1"); + assert_eq!(rec["function_call_id"], "tc-1"); assert_eq!(rec["expires_at"], 1_060_000); } @@ -552,7 +581,7 @@ mod tests { } #[test] - fn extract_call_returns_none_when_tool_call_absent() { + fn extract_call_returns_none_when_function_call_absent() { let envelope = json!({ "event_id": "evt-1", "reply_stream": "rs-1", @@ -636,7 +665,7 @@ mod tests { let out = handle_resolve( &bus, json!({ - "tool_call_id": "tc-1", + "function_call_id": "tc-1", "session_id": "s1", "decision": "allow", }), @@ -651,6 +680,30 @@ mod tests { assert_eq!(stored["status"], "allow"); } + #[tokio::test] + async fn resolve_accepts_legacy_tool_call_id_field() { + let bus = InMemoryStateBus::new(); + bus.set( + STATE_SCOPE, + &pending_key("s1", "tc-1"), + build_pending_record("tc-1", "write", &json!({}), 0, 60_000), + ) + .await + .unwrap(); + + let out = handle_resolve( + &bus, + json!({ + "tool_call_id": "tc-1", + "session_id": "s1", + "decision": "allow", + }), + ) + .await; + + assert_eq!(out["ok"], true); + } + #[tokio::test] async fn resolve_rejects_already_resolved_entry() { let bus = InMemoryStateBus::new(); @@ -662,7 +715,7 @@ mod tests { let out = handle_resolve( &bus, - json!({"tool_call_id": "tc-1", "session_id": "s1", "decision": "deny"}), + json!({"function_call_id": "tc-1", "session_id": "s1", "decision": "deny"}), ) .await; assert_eq!(out["ok"], false); @@ -695,7 +748,7 @@ mod tests { let out = handle_list_pending(&bus, json!({ "session_id": "s1" })).await; let items = out["pending"].as_array().unwrap(); assert_eq!(items.len(), 1); - assert_eq!(items[0]["tool_call_id"], "tc-1"); + assert_eq!(items[0]["function_call_id"], "tc-1"); } use std::sync::Arc; @@ -776,7 +829,7 @@ mod tests { &bus, json!({ "session_id": "s1", - "tool_call_id": "tc-1", + "function_call_id": "tc-1", "decision": "deny", "reason": "user clicked cancel", }), diff --git a/approval-gate/tests/integration.rs b/approval-gate/tests/integration.rs index cd8924006..254395a9f 100644 --- a/approval-gate/tests/integration.rs +++ b/approval-gate/tests/integration.rs @@ -1,5 +1,5 @@ //! Engine-backed test for approval-gate. Connects to an in-process / -//! local iii engine, registers the gate, fires a `before_tool_call` +//! local iii engine, registers the gate, fires a `before_function_call` //! envelope on a per-test topic, posts `approval::resolve`, and asserts //! the subscriber unblocks under 1 s. //! @@ -42,9 +42,9 @@ async fn round_trip_allow_unblocks_under_one_second() { .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); - let topic = format!("agent::before_tool_call::it_{nonce}"); + let topic = format!("agent::before_function_call::it_{nonce}"); let session_id = format!("approval-it-{nonce}"); - let tool_call_id = format!("tc-it-{nonce}"); + let function_call_id = format!("tc-it-{nonce}"); let event_id = format!("evt-it-{nonce}"); let reply_stream = format!("rs-it-{nonce}"); @@ -62,9 +62,9 @@ async fn round_trip_allow_unblocks_under_one_second() { "reply_stream": reply_stream, "payload": { "session_id": session_id, - "tool_call": { - "id": tool_call_id, - "name": "shell::filesystem::write", + "function_call": { + "id": function_call_id, + "function_id": "shell::filesystem::write", "arguments": {}, }, "approval_required": ["shell::filesystem::write"], @@ -86,7 +86,7 @@ async fn round_trip_allow_unblocks_under_one_second() { }); // Wait for the gate to write the pending record before we resolve. - let key = format!("{session_id}/{tool_call_id}"); + let key = format!("{session_id}/{function_call_id}"); let mut tries = 0; loop { let v = iii @@ -113,7 +113,7 @@ async fn round_trip_allow_unblocks_under_one_second() { function_id: FN_RESOLVE.into(), payload: json!({ "session_id": session_id, - "tool_call_id": tool_call_id, + "function_call_id": function_call_id, "decision": "allow", }), action: None, diff --git a/harness/ARCHITECTURE.md b/harness/ARCHITECTURE.md index 6d3a0586d..b557b3acc 100644 --- a/harness/ARCHITECTURE.md +++ b/harness/ARCHITECTURE.md @@ -24,7 +24,7 @@ The `harness` is a meta-worker for the [iii](https://github.com/iii-experimental │ agent::call provider::* │◄── turn-orchestrator, … │ session::* state::* │◄── session-tree │ shell::filesystem::* shell::bash::* │◄── shell-* workers - │ agent::before_tool_call (topic) │◄── policy-denylist + │ agent::before_function_call (topic) │◄── policy-denylist │ auth::* skills::register … │◄── auth-credentials, skills └────────────────────────────────────────────────────┘ ``` @@ -54,7 +54,7 @@ The harness exposes two dispatchers: - `agent::call` (turn-orchestrator) — LLM-facing. The provider sees one tool, `agent_call`, with `{function, payload}` arguments. Thin pass- through: validates the `function` field, dispatches via - `iii.trigger(...)`, maps errors back to `ToolResult` envelopes the + `iii.trigger(...)`, maps errors back to `FunctionResult` envelopes the model can read. No payload validation, no sandbox automation, no registry introspection — the model learns iii contracts from skills registered via the skills worker. Both `bridge::trigger` and @@ -73,7 +73,7 @@ The UI does not ship tool schemas: `turn-orchestrator` provisions a single `agent_call` tool (see `agent_call_tool`) and builds the system prompt server-side. The model passes `function` (a bus id such as `shell::filesystem::ls`) and `payload` (arguments). Permission is enforced by -`policy-denylist` on `agent::before_tool_call` (see Trust boundary below). +`policy-denylist` on `agent::before_function_call` (see Trust boundary below). ### 3. The 14 expected workers @@ -81,13 +81,13 @@ server-side. The model passes `function` (a bus id such as | Group | Workers | Role | |---|---|---| -| Orchestration | `turn-orchestrator`, `provider-router` | Runs a turn end-to-end: fan a request to a provider and dispatch tool calls. | +| Orchestration | `turn-orchestrator`, `provider-router` | Runs a turn end-to-end: fan a request to a provider and dispatch function calls. | | Sessions / state | `session-tree`, `session-inbox` | Persisted message trees and a steering/follow-up inbox queue. | | Catalog | `models-catalog` | Model metadata. | | Auth | `auth-credentials` | Provider credentials store. | -| Policy / safety | `policy-denylist`, `llm-budget` | Hook subscriber on `agent::before_tool_call` and budget tracking. | +| Policy / safety | `policy-denylist`, `llm-budget` | Hook subscriber on `agent::before_function_call` and budget tracking. | | Hooks | `hook-fanout` | Generic publish-and-collect primitive. | -| Tools | `shell-bash`, `shell-filesystem`, `subagent` | LLM-callable tool implementations. | +| Tools | `shell-bash`, `shell-filesystem`, `subagent` | LLM-callable iii function implementations. | | Providers | `provider-anthropic`, `provider-openai` | Concrete LLM transport workers behind `provider-router`. | The harness owns *no* logic from any of these — it only knows their names. Each worker is a separate crate in `workers//` with its own `iii.worker.yaml`, lifecycle, and tests. @@ -122,8 +122,8 @@ A user message from the browser: 3. bridge::trigger handler unwraps {body} → {function_id, payload} 4. iii.trigger("run::start_and_wait", payload, timeout=240s) 5. turn-orchestrator picks it up, runs the agent loop: - - emits `agent::before_tool_call` (subscribers: policy-denylist, llm-budget) - - routes each tool execution through `agent_call::dispatch` (validate function field, then `iii.trigger` to the inner function — Tier 2 thin pass-through) + - emits `agent::before_function_call` (subscribers: policy-denylist, llm-budget) + - routes each function execution through `agent_call::dispatch` (validate function field, then `iii.trigger` to the inner function — Tier 2 thin pass-through) - calls provider-router → provider-anthropic / provider-openai - persists transcript via session-tree / state 6. turn-orchestrator returns full transcript @@ -142,7 +142,7 @@ Sessions are persisted in two stores that don't merge automatically: The harness assumes a layered model and does not enforce policy itself: 1. **SDK wrapper (chat client side)** — workspace allowlist on path arguments before the bus call is dispatched. -2. **`policy-denylist` (engine side)** — subscriber on `agent::before_tool_call` that blocks by tool name. Configured via `POLICY_DENIED_TOOLS` env var. **Must include `bridge::trigger`** to prevent the LLM from calling `agent_call(function="bridge::trigger", payload={...})` to recursively dispatch any function and bypass name-matched rules (the policy hook fires with name `bridge::trigger`, not the inner function). Recommended denylist for solo local dev: `bridge::trigger,shell::filesystem::rm,shell::filesystem::sed,shell::filesystem::edit,shell::filesystem::chmod,shell::filesystem::mv`. The demo script (`harness/scripts/demo.sh`) sets `bridge::trigger` automatically. +2. **`policy-denylist` (engine side)** — subscriber on `agent::before_function_call` that blocks by function id. Configured via `POLICY_DENIED_FUNCTIONS` env var (legacy `POLICY_DENIED_TOOLS` still honored). **Must include `bridge::trigger`** to prevent the LLM from calling `agent_call(function="bridge::trigger", payload={...})` to recursively dispatch any function and bypass name-matched rules (the policy hook fires with name `bridge::trigger`, not the inner function). Recommended denylist for solo local dev: `bridge::trigger,shell::filesystem::rm,shell::filesystem::sed,shell::filesystem::edit,shell::filesystem::chmod,shell::filesystem::mv`. The demo script (`harness/scripts/demo.sh`) sets `bridge::trigger` automatically. 3. **`` (chat UI)** — per-call user approval surfaced inline before any write reaches disk. `bridge::trigger` is the one bus surface reachable from the browser. It has **no** allowlist — any function id is callable — so the deployment must keep `:3111` private and rely on the three layers above. There is no per-user auth on `bridge::trigger`; the harness assumes a single-tenant local install. diff --git a/harness/Makefile b/harness/Makefile index afd1079dc..22dc631eb 100644 --- a/harness/Makefile +++ b/harness/Makefile @@ -79,7 +79,7 @@ engine: ensure-dirs exit 0; \ fi @echo "==> starting iii engine..." - @( cd "$(DEMO_DIR)" && nohup iii --use-default-config > engine.log 2>&1 & echo $$! > engine.pid ) + @cd "$(DEMO_DIR)" && { nohup iii --use-default-config > engine.log 2>&1 & echo $$! > "$(DEMO_DIR)/engine.pid"; } @for i in 1 2 3 4 5 6 7 8 9 10; do \ sleep 1; \ if iii --use-default-config trigger --function-id engine::queue::list_topics --timeout-ms 1000 >/dev/null 2>&1; then \ diff --git a/harness/docs/iii-skill.md b/harness/docs/iii-skill.md new file mode 100644 index 000000000..b11e92961 --- /dev/null +++ b/harness/docs/iii-skill.md @@ -0,0 +1,239 @@ +# iii functions + +How to discover and call functions on the iii engine. + +## If you're an agent calling through `agent_call` + +You don't call `iii.trigger` directly — you go through the `agent_call` tool. +Three differences from the SDK examples below: + +1. The argument is named **`function`**, not `function_id`. Same string, + different field name. + - Wrong: `agent_call({function_id: "...", payload: {...}})` → returns + `{error: "missing_function"}`. + - Right: `agent_call({function: "...", payload: {...}})`. +2. Errors arrive as **JSON envelopes inside the result**, not as thrown + `IIIError`. You will see `{error: "function_not_found", function}`, + `{error: "timeout", function}`, `{error: "trigger_failed", function, + message}`, or `{blocked: true}` (policy refusal). +3. `action` and `timeout_ms` are **not exposed** through `agent_call`. + Every call is synchronous with the bus default timeout. Putting these + fields in `payload` does nothing. + +`skill::fetch` is a real, callable function for loading skill bodies by +`iii://` URI — the blacklist below is about *function-listing* calls +only. + +Everything else in this document — discovery, schemas, listings — +applies as written. + +## TL;DR — there is exactly ONE way to list functions + +```json +{ "function_id": "engine::functions::list", + "payload": { "include_internal": false } } +``` + +That is the only function-listing call. It exists. It always works. +The response is `{ "functions": [ { function_id, description, request_format, response_format, metadata }, ... ] }`. + +**Do NOT guess any of these — none of them exist:** + +- ~~`skill::list`~~ → use `engine::functions::list` +- ~~`skills::list`~~ → that is a skills-registry CRUD call (lists skill bodies, not functions) +- ~~`iii::list`~~ → not a thing +- ~~`bus::list`~~ → not a thing +- ~~`function::list`~~ → wrong scope; the scope is `engine`, the noun is plural `functions` +- ~~`functions::list`~~ → missing the `engine::` prefix +- ~~`engine::list`~~ → missing the `functions` segment + +If a call returns `{"error":"function_not_found", ...}`, **do not retry +with another guess**. Call `engine::functions::list` first; pick a real +id from the response. + +## The mental model + +- **Worker** — a process connected to the engine over WebSocket. +- **Function** — a unit of work with a stable id `::` + (e.g. `state::set`, `harness::status`). JSON in, JSON out. +- **Trigger** — what causes a function to run (direct call, HTTP, + cron, queue, stream, custom). + +To use a function you need three things: its **id**, its **input +schema**, and its **output schema**. All three live in the +`engine::functions::list` response. Read that listing first. + +## Step 1 — Discover what exists + +```json +{ "function_id": "engine::functions::list", + "payload": { "include_internal": false } } +``` + +Returns: + +```json +{ + "functions": [ + { + "function_id": "harness::status", + "description": "Returns the harness bundle name, version, ...", + "request_format": { "type": "object", "properties": {}, ... }, + "response_format": { "type": "object", "properties": {...} }, + "metadata": null + }, + ... + ] +} +``` + +| Field | Meaning | +|---|---| +| `function_id` | The id you pass as `function_id` when calling. | +| `description` | One-line summary the worker registered. | +| `request_format` | JSON Schema of the accepted payload. **This is the contract.** | +| `response_format` | JSON Schema of the return value. | +| `metadata` | Optional free-form annotations the worker attached. | + +`include_internal: true` adds engine-private functions (the `engine::*` +namespace itself). Default `false` is what you want unless you're +debugging the engine. + +## Step 2 — Read the schema for the function you want to call + +Filter the listing to the one entry you care about, then read its +`request_format`: + +- `request_format.required` — array of property names that MUST be present. +- `request_format.properties` — keys = allowed fields, values = per-field schema. +- Each property's schema gives you the type, enum, `oneOf`/`anyOf`, etc. +- If `request_format` is `null` or absent, the function accepts `{}`. + +Read `response_format` too so you know the shape of what comes back. + +## Step 3 — Call it + +```rust +let result = iii.trigger(TriggerRequest { + function_id: "myworker::do_thing".into(), + payload: json!({ /* matches request_format */ }), + action: None, // sync. Use "fire-and-forget" or "enqueue" only when you mean it. + timeout_ms: Some(5_000), +}).await?; +``` + +```ts +const result = await iii.trigger({ + function_id: 'myworker::do_thing', + payload: { /* matches request_format */ }, +}) +``` + +```python +result = await iii.trigger( + function_id='myworker::do_thing', + payload={ ... }, +) +``` + +The result is the function's raw JSON output (matching +`response_format`). At the raw SDK layer, errors arrive as a thrown +`IIIError`. Through `agent_call` (the agent path), the dispatcher +converts those into JSON envelopes inside the result — see the +preamble above for the exact shapes. + +## Step 4 — Adjacent listings (same scope, plural noun, ::list) + +The same `engine::::list` shape works for three other things: + +| Function | Returns | +|---|---| +| `engine::workers::list` | Connected workers + their function_ids, runtime, status, metrics. | +| `engine::triggers::list` | Active triggers (HTTP routes, cron, queue subs) and the function each invokes. | +| `engine::trigger-types::list` | Built-in and custom trigger types with their config schemas. | + +All four (`functions`, `workers`, `triggers`, `trigger-types`) accept +`{ include_internal?: bool }` and return `{ : [ ... ] }`. + +## Step 5 — Attach metadata to a worker: `engine::workers::register` + +The engine knows a worker exists the moment it dials the WebSocket, +but it doesn't yet know its language, version, hostname, or framework. +`engine::workers::register` is the **write** call that fills those +fields in. It's what makes a row in `engine::workers::list` go from +"unknown runtime" to "node 20.x, project foo, framework express". + +```json +{ "function_id": "engine::workers::register", + "payload": { + "runtime": "rust", + "version": "0.3.1", + "name": "harness@host-12", + "os": "darwin 25.0", + "pid": 9876, + "isolation": "libkrun", + "telemetry": { + "language": "en-US", + "project_name": "my-project", + "framework": "express" + } + } +} +``` + +| Field | Meaning | +|---|---| +| `runtime` | `"node"`, `"python"`, `"rust"`, etc. Drives console grouping. | +| `version` | Worker's package/binary version. | +| `name` | Display name for the console (`@` is conventional). | +| `os` | OS string (e.g. `"darwin 25.0"`, `"linux 6.5"`). | +| `pid` | Process id. Optional. | +| `isolation` | `"libkrun"`, `"docker"`, `"none"`, … Optional. | +| `telemetry` | Free-form `{ language, project_name, framework }` block. | + +Returns `{ "success": true }`. Fires the custom trigger type +`engine::workers-available` so dashboards refresh. + +`_caller_worker_id` is **injected automatically** by the engine on +every call — never pass it yourself; the engine attributes the +metadata to whatever worker made the call. + +This is something a worker calls **about itself** at boot, normally +once. Most SDK `register_worker(...)` helpers already invoke it for +you; you only call it explicitly when you need to update metadata +mid-session or are speaking the bus protocol directly. + +## Built-in namespaces (real ids, copy-pasteable) + +These are always present because the engine itself registers them. + +| Prefix | Examples | +|---|---| +| `engine::*` | `engine::functions::list`, `engine::workers::list`, `engine::workers::register`, `engine::triggers::list`, `engine::trigger-types::list` | +| `state::*` | `state::get`, `state::set`, `state::list`, `state::delete` | +| `stream::*` | `stream::set`, `stream::get`, `stream::list`, `stream::update`, `stream::delete` | + +`stream::*` has no tail/range API — to consume a stream live, register +a `stream` trigger bound to your function. + +This bundle's harness adds: + +- `harness::status` — bundle name, version, expected workers. +- `bridge::trigger` — HTTP POST `/bridge/trigger` forwards `{function_id, payload}` onto the bus. + +## Discovery checklist (use before EVERY new call) + +1. `engine::functions::list { include_internal: false }` — confirm + the id exists. **Do not skip this.** +2. Read `request_format` for that entry — know exactly what fields + the payload needs. +3. Read `response_format` — know the shape of the return value before + you write code that consumes it. +4. (Optional) `engine::workers::list` filtered by the function's + worker — confirm the worker is connected and `status` is healthy. +5. Call `iii.trigger({ function_id, payload })` with a payload that + satisfies the schema. + +If a call fails with `function_not_found`, the function does NOT +exist under that id. Re-run step 1 and pick a real id from the +response — never invent another guess. diff --git a/harness/docs/sandbox-skill.md b/harness/docs/sandbox-skill.md new file mode 100644 index 000000000..13bf5a334 --- /dev/null +++ b/harness/docs/sandbox-skill.md @@ -0,0 +1,106 @@ +# sandbox + +Spawn ephemeral microVMs for isolated command execution and file ops. +Provided by the `iii-sandbox` worker (v0.11.x). Fourteen +`sandbox::*` / `sandbox::fs::*` functions cover the full lifecycle. + +## When to reach for sandbox + +- The user asks you to run untrusted code, build artifacts, or + execute a command whose side effects you don't want on the host. +- A long-running process (server, watcher) needs isolation from the + user's shell session. +- File-system writes need to be reversible — drop the sandbox and + every change is gone. + +For trusted local edits, use `shell::filesystem::*` / `shell::bash::*` +directly. The sandbox is heavier (microVM boot) and only worth it +when isolation matters. + +## Lifecycle (always: create → exec/fs → stop) + +``` +sandbox::create → sandbox::exec / sandbox::fs::* → sandbox::stop + \ / + → sandbox::list (any time, read-only) +``` + +A sandbox handle (returned by `create`, listed by `list`) is the +identifier you pass to every other call. `stop` removes the VM and +frees the resources — call it when you're done; don't leak VMs. + +## The 14 functions + +| Function | Purpose | +|---|---| +| `sandbox::create` | Create an ephemeral sandbox VM from a preset image. | +| `sandbox::list` | List active sandboxes. | +| `sandbox::exec` | Execute a command inside a live sandbox. | +| `sandbox::stop` | Stop and remove a running sandbox. | +| `sandbox::ls` | List directory contents inside a sandbox. | +| `sandbox::fs::read` | Stream-download a file from a sandbox. | +| `sandbox::fs::write` | Stream-upload a file into a sandbox. | +| `sandbox::fs::stat` | Stat a path inside a sandbox. | +| `sandbox::fs::mkdir` | Create a directory inside a sandbox. | +| `sandbox::fs::rm` | Remove a file or directory inside a sandbox. | +| `sandbox::fs::mv` | Move or rename a path inside a sandbox. | +| `sandbox::fs::chmod` | Change file permissions inside a sandbox. | +| `sandbox::fs::grep` | Search for a pattern in files inside a sandbox. | +| `sandbox::fs::sed` | Search-and-replace in files inside a sandbox. | + +## Read the schema before every call + +The published `request_format` lives in the live function listing, +not in this skill. Before using any function id above: + +```json +{ "function_id": "engine::functions::list", + "payload": { "include_internal": false } } +``` + +Filter the response array to the entry whose `function_id` matches, +then read its `request_format` for the exact payload shape and +`response_format` for the return shape. This is the iii-engine +discovery pattern — see the `iii` skill for the full walkthrough. + +## Common shape (use the schema; this is just orientation) + +Every non-`create`/`list` function expects a sandbox handle. Most +use `id` or `sandbox_id` — check `request_format` to confirm. A +typical exec call looks like: + +```json +{ "function_id": "sandbox::exec", + "payload": { + "id": "", + "command": ["bash", "-lc", "echo hi"] + } +} +``` + +A typical filesystem read: + +```json +{ "function_id": "sandbox::fs::read", + "payload": { + "id": "", + "path": "/work/output.txt" + } +} +``` + +If a call returns `function_not_found`, the worker isn't running. +The harness lists `iii-sandbox` in its expected workers — check +`engine::workers::list` to confirm it's connected. + +## Gotchas + +- Paths are **inside the sandbox**, not on the host. `/work` and + `/tmp` exist; the host filesystem does not. +- `sandbox::stop` is destructive — every change to the VM's + filesystem is gone. Read out anything you need with + `sandbox::fs::read` first. +- `create` may take a few seconds (microVM cold start). Don't + set a tight `timeout_ms`; let the engine default apply. +- Always pair `create` with `stop`. A failed task should still + call `stop` so the VM doesn't linger. diff --git a/harness/iii.worker.yaml b/harness/iii.worker.yaml index 4897a69da..9019d8bc3 100644 --- a/harness/iii.worker.yaml +++ b/harness/iii.worker.yaml @@ -22,3 +22,4 @@ dependencies: llm-budget: "^0.1.0" skills: "^0.1.0" approval-gate: "^0.1.0" + iii-sandbox: "^0.11.0" diff --git a/harness/scripts/demo.sh b/harness/scripts/demo.sh index 94d1e675a..6c35ac14a 100755 --- a/harness/scripts/demo.sh +++ b/harness/scripts/demo.sh @@ -101,7 +101,7 @@ spawn_one() { ) fi - # Per-worker env. policy-denylist needs POLICY_DENIED_TOOLS to include + # Per-worker env. policy-denylist needs POLICY_DENIED_FUNCTIONS to include # bridge::trigger — without it the LLM can call bridge::trigger to # recursively dispatch any function and bypass name-matched policy # rules (the policy hook fires with name "bridge::trigger", not the @@ -110,7 +110,7 @@ spawn_one() { local -a extra_env=() case "$w" in policy-denylist) - extra_env+=(POLICY_DENIED_TOOLS="bridge::trigger") + extra_env+=(POLICY_DENIED_FUNCTIONS="bridge::trigger") ;; esac @@ -128,8 +128,11 @@ cmd_engine() { return 0 fi echo "==> starting iii engine..." - (cd "$DEMO_DIR" && nohup iii --use-default-config > engine.log 2>&1 & - echo $! > engine.pid) + ( + cd "$DEMO_DIR" || exit 1 + nohup iii --use-default-config > engine.log 2>&1 & + echo $! > "$DEMO_DIR/engine.pid" + ) for i in 1 2 3 4 5 6 7 8 9 10; do sleep 1 if iii --use-default-config trigger --function-id engine::queue::list_topics --timeout-ms 1000 >/dev/null 2>&1; then diff --git a/harness/src/fanout.rs b/harness/src/fanout.rs index c16f7ab29..c723f2402 100644 --- a/harness/src/fanout.rs +++ b/harness/src/fanout.rs @@ -33,6 +33,18 @@ use serde_json::{json, Value}; /// Identity of a connected browser worker. Caller-supplied; we don't mint it. pub type BrowserId = String; +/// True if `e` is the engine's "no worker has registered this function" error. +/// We match both the structured `Remote { code: "function_not_found", .. }` +/// shape and the `Display` form, since some SDK paths surface it as a flat +/// runtime/handler error string. +pub(crate) fn is_function_not_found(e: &IIIError) -> bool { + match e { + IIIError::Remote { code, .. } => code == "function_not_found", + IIIError::Runtime(s) | IIIError::Handler(s) => s.contains("function_not_found"), + _ => false, + } +} + /// `None` means "subscribe to all sessions / non-session topics". pub type Subscription = Option; @@ -127,6 +139,16 @@ impl FanoutState { } } + /// Drop a browser entirely (all sessions, all outbound budget). Used when + /// the browser's per-browser handler `ui::session::event::` no longer + /// exists on the engine — the browser closed without calling + /// `ui::unsubscribe`. Returns `true` if anything was evicted. + pub fn evict_browser(&mut self, browser: &str) -> bool { + let removed = self.subs.remove(browser).is_some(); + self.outbound.remove(browser); + removed + } + /// Get-or-insert the per-browser outbound budget. Used by every push /// path to gate inflight + coalesce cost ticks. pub fn outbound_for(&mut self, browser: &str) -> Arc { @@ -249,9 +271,14 @@ fn register_agent_event_pump(iii: &III, fanout: SharedFanout) -> FunctionRef { let function_id = format!("ui::session::event::{browser_id}"); let frame = frame.clone(); let iii_for_push = iii.clone(); + let fanout_for_gc = Arc::clone(&fanout); + let browser_for_gc = browser_id.clone(); // Fire-and-forget. The browser is allowed to be slow // or absent; we don't want one stale browser to - // back up the whole pump. + // back up the whole pump. If the per-browser handler + // is gone (browser closed without `ui::unsubscribe`), + // garbage-collect its subscription so the engine + // stops logging `function_not_found` on every event. tokio::spawn(async move { if let Err(e) = iii_for_push .trigger(TriggerRequest { @@ -262,7 +289,20 @@ fn register_agent_event_pump(iii: &III, fanout: SharedFanout) -> FunctionRef { }) .await { - tracing::trace!(error = %e, "ui push failed (browser likely gone)"); + if is_function_not_found(&e) { + let evicted = { + let mut state = fanout_for_gc.write().await; + state.evict_browser(&browser_for_gc) + }; + if evicted { + tracing::debug!( + browser_id = %browser_for_gc, + "evicted stale browser subscription (handler gone)" + ); + } + } else { + tracing::trace!(error = %e, "ui push failed (browser likely slow)"); + } } }); } @@ -566,7 +606,11 @@ fn spawn_approval_poll(iii: Arc, fanout: SharedFanout) -> tokio::task::Join continue; }; for entry in arr { - let Some(id) = entry.get("tool_call_id").and_then(|v| v.as_str()) else { + let Some(id) = entry + .get("function_call_id") + .or_else(|| entry.get("tool_call_id")) + .and_then(|v| v.as_str()) + else { continue; }; // Annotate with session_id so the UI can group/filter. @@ -605,7 +649,7 @@ fn spawn_approval_poll(iii: Arc, fanout: SharedFanout) -> tokio::task::Join &fanout, browser_id, format!("ui::approval::resolved::{browser_id}"), - json!({ "tool_call_id": id }), + json!({ "function_call_id": id, "tool_call_id": id }), PushKind::Standard, ); } @@ -829,6 +873,63 @@ mod tests { assert_eq!(s.browser_count(), 0); } + #[test] + fn evict_browser_drops_all_sessions_at_once() { + let mut s = FanoutState::default(); + s.subscribe("browser-a".into(), Some("sess-1".into())); + s.subscribe("browser-a".into(), Some("sess-2".into())); + s.subscribe("browser-a".into(), None); + // Touch outbound so the eviction path has something to clean up. + let _ = s.outbound_for("browser-a"); + assert_eq!(s.browser_count(), 1); + + let removed = s.evict_browser("browser-a"); + assert!( + removed, + "evict must report success when the browser was present" + ); + assert_eq!(s.browser_count(), 0); + assert!( + s.subscribers_for("sess-1").is_empty(), + "evicted browser must not appear in subscribers_for" + ); + } + + #[test] + fn evict_browser_is_noop_when_unknown() { + let mut s = FanoutState::default(); + let removed = s.evict_browser("ghost"); + assert!(!removed); + assert_eq!(s.browser_count(), 0); + } + + #[test] + fn is_function_not_found_matches_remote_code() { + let e = IIIError::Remote { + code: "function_not_found".into(), + message: "Function not found".into(), + stacktrace: None, + }; + assert!(super::is_function_not_found(&e)); + } + + #[test] + fn is_function_not_found_matches_runtime_string_form() { + let e = IIIError::Runtime("function_not_found: ui::session::event::xyz".into()); + assert!(super::is_function_not_found(&e)); + } + + #[test] + fn is_function_not_found_rejects_unrelated_errors() { + assert!(!super::is_function_not_found(&IIIError::Timeout)); + assert!(!super::is_function_not_found(&IIIError::NotConnected)); + assert!(!super::is_function_not_found(&IIIError::Remote { + code: "internal_error".into(), + message: "boom".into(), + stacktrace: None, + })); + } + #[test] fn all_sessions_subscribers_returns_only_global_subs() { let mut s = FanoutState::default(); @@ -969,7 +1070,7 @@ mod tests { let mut prev: HashMap = HashMap::new(); prev.insert( "tc-1".into(), - json!({ "tool_call_id": "tc-1", "tool_name": "write" }), + json!({ "function_call_id": "tc-1", "tool_call_id": "tc-1", "function_id": "write", "tool_name": "write" }), ); let next: HashMap = HashMap::new(); let (requested, resolved) = diff_approvals(&prev, &next); @@ -984,7 +1085,7 @@ mod tests { let mut after_request: HashMap = HashMap::new(); after_request.insert( "tc-1".into(), - json!({ "tool_call_id": "tc-1", "tool_name": "rm" }), + json!({ "function_call_id": "tc-1", "tool_call_id": "tc-1", "function_id": "rm", "tool_name": "rm" }), ); let (added, removed) = diff_approvals(&initial, &after_request); assert_eq!(added.len(), 1); diff --git a/harness/src/lib.rs b/harness/src/lib.rs index 4ee5cb589..0e977b387 100644 --- a/harness/src/lib.rs +++ b/harness/src/lib.rs @@ -93,6 +93,7 @@ pub const EXPECTED_WORKERS: &[&str] = &[ "llm-budget", "skills", "approval-gate", + "iii-sandbox", ]; /// Build the payload sent to skills::register at boot. Pure helper so the @@ -107,6 +108,36 @@ pub fn build_skills_register_payload() -> serde_json::Value { }) } +// TEMP(iii-skill): the harness ships a generic iii-orientation skill body +// at boot so agents always have the `iii://iii` document available, even +// before the engine grows a dedicated skill worker that publishes its own. +// Revert by deleting: +// 1. harness/docs/iii-skill.md +// 2. `build_iii_skill_register_payload` below +// 3. the second `skills::register` call in `register_with_iii_with_engine_url` +// 4. the matching test in tests/skills_register.rs +pub fn build_iii_skill_register_payload() -> serde_json::Value { + serde_json::json!({ + "id": "iii", + "skill": include_str!("../docs/iii-skill.md"), + }) +} + +// TEMP(sandbox-skill): same stopgap as iii-skill above, but for the +// sandbox surface. The `iii-sandbox` worker registers 14 functions; this +// body teaches the agent when to reach for them and how to discover +// their schemas via `engine::functions::list`. Revert by deleting: +// 1. harness/docs/sandbox-skill.md +// 2. `build_sandbox_skill_register_payload` below +// 3. the third `skills::register` call in `register_with_iii_with_engine_url` +// 4. the matching test in tests/skills_register.rs +pub fn build_sandbox_skill_register_payload() -> serde_json::Value { + serde_json::json!({ + "id": "sandbox", + "skill": include_str!("../docs/sandbox-skill.md"), + }) +} + pub struct HarnessFunctionRefs { pub status: FunctionRef, pub bridge: FunctionRef, @@ -370,6 +401,30 @@ pub async fn register_with_iii_with_engine_url( }) .await; + // TEMP(iii-skill): publish a generic iii-orientation body until the + // engine ships its own skill worker. See `build_iii_skill_register_payload` + // for revert instructions. + let _ = iii + .trigger(TriggerRequest { + function_id: "skills::register".into(), + payload: build_iii_skill_register_payload(), + action: None, + timeout_ms: Some(10_000), + }) + .await; + + // TEMP(sandbox-skill): publish the sandbox orientation body until + // iii-sandbox ships its own. See `build_sandbox_skill_register_payload` + // for revert instructions. + let _ = iii + .trigger(TriggerRequest { + function_id: "skills::register".into(), + payload: build_sandbox_skill_register_payload(), + action: None, + timeout_ms: Some(10_000), + }) + .await; + // Wire the upstream fanout pumps: // - agent::events stream subscriber → ui::session::event:: // - state::list poll → ui::sessions::changed:: diff --git a/harness/tests/skills_register.rs b/harness/tests/skills_register.rs index e0c75bdd0..bf1001c5f 100644 --- a/harness/tests/skills_register.rs +++ b/harness/tests/skills_register.rs @@ -11,3 +11,33 @@ fn build_skills_payload_has_expected_shape() { assert!(workers.iter().all(serde_json::Value::is_string)); assert!(workers.contains(&Value::String("turn-orchestrator".to_string()))); } + +// TEMP(iii-skill): delete this test when the dedicated iii skill worker lands. +#[test] +fn build_iii_skill_payload_matches_skills_register_contract() { + let payload: Value = harness::build_iii_skill_register_payload(); + assert_eq!(payload["id"], "iii"); + let skill = payload["skill"].as_str().expect("skill must be a string"); + assert!(!skill.trim().is_empty(), "skill body must be non-empty"); + assert!( + skill.starts_with("# iii"), + "skill must lead with an H1 title" + ); +} + +// TEMP(sandbox-skill): delete this test when iii-sandbox publishes its own. +#[test] +fn build_sandbox_skill_payload_matches_skills_register_contract() { + let payload: Value = harness::build_sandbox_skill_register_payload(); + assert_eq!(payload["id"], "sandbox"); + let skill = payload["skill"].as_str().expect("skill must be a string"); + assert!(!skill.trim().is_empty(), "skill body must be non-empty"); + assert!( + skill.starts_with("# sandbox"), + "skill must lead with an H1 title" + ); + assert!( + skill.contains("sandbox::create") && skill.contains("sandbox::fs::read"), + "skill must mention the lifecycle and at least one fs:: function" + ); +} diff --git a/harness/web/package.json b/harness/web/package.json index c017dc0bd..f86a14e12 100644 --- a/harness/web/package.json +++ b/harness/web/package.json @@ -14,7 +14,9 @@ "dependencies": { "iii-browser-sdk": "0.11.7-next.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@playwright/test": "^1.49.0", diff --git a/harness/web/src/App.tsx b/harness/web/src/App.tsx index 463872060..07e6f788e 100644 --- a/harness/web/src/App.tsx +++ b/harness/web/src/App.tsx @@ -32,15 +32,16 @@ import type { type Tab = "chat" | "cost" | "files" | "status"; -// Tool catalog: a single `agent_call` tool plus server-built system prompt — +// Function catalog: a single `agent_call` tool plus server-built system prompt — // see turn-orchestrator `agent_call.rs` and `system_prompt.rs`. The client // does not send `tools` or `system_prompt` on `run::start` (override still // accepted if you pass a non-empty `system_prompt` for experiments). // // Permission still lives in `policy-denylist`, which subscribes to -// `agent::before_tool_call` and refuses by name. Set its env var when +// `agent::before_function_call` and refuses by function id. Set its env var when // starting the worker, e.g.: -// POLICY_DENIED_TOOLS=shell::filesystem::rm,shell::filesystem::sed,shell::filesystem::edit,shell::filesystem::chmod,shell::filesystem::mv +// POLICY_DENIED_FUNCTIONS=shell::filesystem::rm,shell::filesystem::sed,shell::filesystem::edit,shell::filesystem::chmod,shell::filesystem::mv +// (Legacy `POLICY_DENIED_TOOLS` is still read if the new name is unset.) // Providers we have actual workers for in iii.worker.yaml. Don't add others // here — they'd appear in the UI but every send would error with "function diff --git a/harness/web/src/components/ApprovalRow.tsx b/harness/web/src/components/ApprovalRow.tsx index 0d26a74e8..25ef1b995 100644 --- a/harness/web/src/components/ApprovalRow.tsx +++ b/harness/web/src/components/ApprovalRow.tsx @@ -13,13 +13,14 @@ export function ApprovalRow({ sessionId, pending }: Props) { if (pending.length === 0) return null; - const resolve = async (toolCallId: string, decision: "allow" | "deny") => { - setBusyId(toolCallId); + const resolve = async (functionCallId: string, decision: "allow" | "deny") => { + setBusyId(functionCallId); setErr(null); try { await bridge<{ ok: boolean }>("approval::resolve", { session_id: sessionId, - tool_call_id: toolCallId, + function_call_id: functionCallId, + tool_call_id: functionCallId, decision, }); } catch (e) { @@ -31,33 +32,38 @@ export function ApprovalRow({ sessionId, pending }: Props) { return (
- {pending.map((a) => ( -
+ {pending.map((a) => { + const callId = a.function_call_id ?? a.tool_call_id; + const fnId = a.function_id ?? a.tool_name ?? ""; + if (!callId) return null; + return ( +
approval needed - {a.tool_name} + {fnId}
{JSON.stringify(a.args, null, 2)}
- ))} + ); + })} {err ? (

{err} diff --git a/harness/web/src/components/FunctionCallBlock.tsx b/harness/web/src/components/FunctionCallBlock.tsx new file mode 100644 index 000000000..f4093fa6c --- /dev/null +++ b/harness/web/src/components/FunctionCallBlock.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; + +interface Props { + functionId: string; + args: unknown; +} + +function unwrapAgentCall( + functionId: string, + args: unknown, +): { eyebrow: string; title: string } { + if ( + functionId === "agent_call" && + args && + typeof args === "object" && + typeof (args as { function?: unknown }).function === "string" + ) { + return { + eyebrow: "function", + title: (args as { function: string }).function, + }; + } + return { eyebrow: "function", title: functionId }; +} + +export function FunctionCallBlock({ functionId, args }: Props) { + const [open, setOpen] = useState(false); + const { eyebrow, title } = unwrapAgentCall(functionId, args); + return ( +

+ + {open ? ( +
{JSON.stringify(args, null, 2)}
+ ) : null} +
+ ); +} diff --git a/harness/web/src/components/FunctionPalette.tsx b/harness/web/src/components/FunctionPalette.tsx index 7a9452bc0..73dc33ace 100644 --- a/harness/web/src/components/FunctionPalette.tsx +++ b/harness/web/src/components/FunctionPalette.tsx @@ -41,8 +41,8 @@ interface Props { type View = { kind: "list" } | { kind: "drill"; entry: FunctionEntry }; -// Same denylist semantics the harness uses server-side for the LLM tool -// catalog (see turn-orchestrator/src/tools_catalog.rs). The palette is a +// Same denylist semantics the harness uses server-side for the LLM `agent_call` +// surface (see turn-orchestrator/src/agent_call.rs). The palette is a // power-user surface, so we show ONE more layer than the LLM gets: // `auth::*`, `policy::*`, `shell::*` ARE shown here (with sensitive-call // gating). Only pure engine plumbing is hidden. @@ -59,7 +59,7 @@ function isHidden(fnId: string): boolean { /** * engine::functions::list returns either an array directly or a - * `{functions: [...]}` envelope (see tools_catalog.rs::unwrap_function_list). + * `{functions: [...]}` envelope (see agent_call.rs / engine list helpers). * Normalize to a plain array of FunctionEntry, dropping anything without a * function_id string and any hidden plumbing prefixes. */ diff --git a/harness/web/src/components/FunctionResultBlock.tsx b/harness/web/src/components/FunctionResultBlock.tsx new file mode 100644 index 000000000..1603027a2 --- /dev/null +++ b/harness/web/src/components/FunctionResultBlock.tsx @@ -0,0 +1,106 @@ +import { useMemo, useState } from "react"; +import { Markdown } from "./Markdown"; + +interface Props { + functionId: string; + isError: boolean; + output: string; +} + +const COLLAPSED_LIMIT = 4000; + +type Format = "json" | "markdown" | "text"; + +function detectFormat(s: string): { format: Format; pretty: string } { + const trimmed = s.trim(); + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + const parsed = JSON.parse(trimmed); + if (parsed !== null && typeof parsed === "object") { + return { format: "json", pretty: JSON.stringify(parsed, null, 2) }; + } + } catch { + // fall through + } + } + if ( + /^#{1,6}\s/m.test(trimmed) || + /^[-*+]\s/m.test(trimmed) || + /^\d+\.\s/m.test(trimmed) || + /```/.test(trimmed) || + /\[[^\]]+\]\([^)]+\)/.test(trimmed) + ) { + return { format: "markdown", pretty: s }; + } + return { format: "text", pretty: s }; +} + +function previewLine(s: string, format: Format): string { + if (format === "json") { + return s.replace(/\s+/g, " ").trim(); + } + const lines = s.trim().split("\n"); + const first = lines.find((l) => l.trim().length > 0) ?? ""; + if (format === "markdown") { + return first.replace(/^#{1,6}\s+/, "").replace(/^[-*+]\s+/, ""); + } + return first; +} + +export function FunctionResultBlock({ functionId, isError, output }: Props) { + const [open, setOpen] = useState(false); + const [expanded, setExpanded] = useState(false); + const { format, pretty } = useMemo(() => detectFormat(output), [output]); + const truncated = pretty.length > COLLAPSED_LIMIT; + const visible = + expanded || !truncated ? pretty : pretty.slice(0, COLLAPSED_LIMIT) + "…"; + const preview = previewLine(output, format); + + return ( +
+ + {open ? ( + <> + {format === "markdown" && !truncated ? ( +
+ +
+ ) : ( +
{visible}
+ )} + {truncated ? ( + + ) : null} + + ) : null} +
+ ); +} diff --git a/harness/web/src/components/Markdown.tsx b/harness/web/src/components/Markdown.tsx new file mode 100644 index 000000000..004251e02 --- /dev/null +++ b/harness/web/src/components/Markdown.tsx @@ -0,0 +1,25 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +interface Props { + text: string; +} + +export function Markdown({ text }: Props) { + return ( +
+ ( + + {children} + + ), + }} + > + {text} + +
+ ); +} diff --git a/harness/web/src/components/SessionView.tsx b/harness/web/src/components/SessionView.tsx index c714adeff..f4072d231 100644 --- a/harness/web/src/components/SessionView.tsx +++ b/harness/web/src/components/SessionView.tsx @@ -1,7 +1,12 @@ +import { useEffect, useRef } from "react"; +import type { UIEvent } from "react"; import type { AgentMessage } from "../types"; import { MessageActions } from "./MessageActions"; -import { ToolUseBlock } from "./ToolUseBlock"; -import { ToolResultBlock } from "./ToolResultBlock"; +import { FunctionCallBlock } from "./FunctionCallBlock"; +import { FunctionResultBlock } from "./FunctionResultBlock"; +import { Markdown } from "./Markdown"; + +const STICK_THRESHOLD_PX = 64; interface Props { sessionId: string; @@ -16,29 +21,91 @@ interface Props { onForkFromMessage: (entryId: string) => void | Promise; } +function shortModel(model: string): string { + // claude-opus-4-7 → opus 4.7, claude-3-5-sonnet → sonnet 3.5 + const claudeNamed = model.match(/^claude-([a-z]+)-(\d+)-(\d+)$/i); + if (claudeNamed) return `${claudeNamed[1]} ${claudeNamed[2]}.${claudeNamed[3]}`.toLowerCase(); + const claudeDated = model.match(/^claude-(\d+)-(\d+)-([a-z]+)/i); + if (claudeDated) return `${claudeDated[3]} ${claudeDated[1]}.${claudeDated[2]}`.toLowerCase(); + const gpt = model.match(/^gpt-(.+)$/i); + if (gpt) return `gpt-${gpt[1].replace(/-/g, " ")}`.toLowerCase(); + return model; +} + function roleLabel(m: AgentMessage): string { if (m.role === "user") return "you"; - if (m.role === "assistant") return m.model ? `${m.model}` : "assistant"; - return "tool"; + if (m.role === "assistant") return m.model ? shortModel(m.model) : "the agent"; + if (m.role === "tool_result" || m.role === "function_result") return "function"; + return "message"; +} + +function blockText(b: any): string { + if (typeof b === "string") return b; + if (b && typeof b === "object") { + if (typeof b.text === "string") return b.text; + if (typeof b.content === "string") return b.content; + if (Array.isArray(b.content)) return b.content.map(blockText).join("\n"); + } + return JSON.stringify(b); } function renderBlocks(m: AgentMessage) { + // Function-result rows: render the whole message as a single collapsible + // result block instead of a wall of body text. + if (m.role === "tool_result" || m.role === "function_result") { + const fid = + (m as { function_id?: string }).function_id ?? + (m as { tool_name?: string }).tool_name ?? + "function"; + const output = m.content.map(blockText).join("\n"); + return ( + + ); + } + return m.content.map((b: any, i: number) => { - if (b.type === "text") return

{b.text}

; - if (b.type === "tool_use" || b.type === "tool_call") { + if (b.type === "text") { + if (m.role === "assistant") { + return ( +
+ +
+ ); + } + return

{b.text}

; + } + if ( + b.type === "tool_use" || + b.type === "tool_call" || + b.type === "functionCall" || + b.type === "function_call" + ) { const args = b.input ?? b.arguments ?? {}; - return ; + const fid = + typeof b.function_id === "string" + ? b.function_id + : typeof b.name === "string" + ? b.name + : "unknown"; + return ; } - if (b.type === "tool_result") { - const text = Array.isArray(b.content) - ? b.content.map((c: any) => (typeof c === "string" ? c : c.text ?? JSON.stringify(c))).join("\n") - : typeof b.content === "string" - ? b.content - : JSON.stringify(b.content); + if (b.type === "tool_result" || b.type === "function_result" || b.type === "functionResult") { + const text = blockText(b); + const fid = + typeof b.function_id === "string" + ? b.function_id + : typeof b.tool_name === "string" + ? b.tool_name + : "function"; return ( - @@ -55,6 +122,27 @@ export function SessionView({ loading, onForkFromMessage, }: Props) { + const viewRef = useRef(null); + const stickRef = useRef(true); + + useEffect(() => { + stickRef.current = true; + const el = viewRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [sessionId]); + + useEffect(() => { + if (!stickRef.current) return; + const el = viewRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages, loading]); + + function onScroll(e: UIEvent) { + const el = e.currentTarget; + const dist = el.scrollHeight - el.scrollTop - el.clientHeight; + stickRef.current = dist < STICK_THRESHOLD_PX; + } + if (!sessionId) { return (
@@ -70,33 +158,45 @@ export function SessionView({ ); } return ( -
+
session

{sessionId}

    - {messages.map((m, i) => ( -
  1. + {messages.map((m, i) => { + const prevRole = i > 0 ? messages[i - 1].role : null; + const isTurnChange = prevRole !== null && prevRole !== m.role; + return ( +
  2. {roleLabel(m)} - {renderBlocks(m)} - {m.role === "assistant" && m.usage ? ( -

    - {m.usage.input ?? 0}↓ · {m.usage.output ?? 0}↑ tokens - {m.stop_reason ? ` · stop: ${m.stop_reason}` : null} -

    - ) : null} - +
    + {renderBlocks(m)} + {m.role === "assistant" && m.usage ? ( +

    + {m.usage.input ?? 0}↓ · {m.usage.output ?? 0}↑ tokens + {m.stop_reason ? ` · stop: ${m.stop_reason}` : null} +

    + ) : null} + +
  3. - ))} + ); + })} {loading ? (
  4. -

    running turn…

    +
    +

    running turn…

    +
  5. ) : null}
diff --git a/harness/web/src/components/StatusTab.tsx b/harness/web/src/components/StatusTab.tsx index e10ddc711..7b9c686fb 100644 --- a/harness/web/src/components/StatusTab.tsx +++ b/harness/web/src/components/StatusTab.tsx @@ -6,7 +6,7 @@ // // Rules of thumb encoded here: // - Workers table: text status (a11y), color secondary. -// - Events feed: rolling 200, filter chips (agent/state/tool/error), pause. +// - Events feed: rolling 200, filter chips (agent/state/function/error), pause. // - Budget breakdown: hydrated once from budget::list + budget::usage, // patched in-place from ui::cost::tick. // - Disconnected >5s → "live updates paused" banner; on reconnect, refetch. @@ -57,8 +57,14 @@ function summarizeEvent(ev: StatusEvent): string { if (!p) return ev.kind; switch (ev.kind) { case "approval": { - const id = (p.tool_call_id as string | undefined) ?? "?"; - const name = (p.tool_name as string | undefined) ?? ""; + const id = + (p.function_call_id as string | undefined) ?? + (p.tool_call_id as string | undefined) ?? + "?"; + const name = + (p.function_id as string | undefined) ?? + (p.tool_name as string | undefined) ?? + ""; const decision = p.decision as string | undefined; return decision ? `approval ${decision} · ${id}` diff --git a/harness/web/src/components/ToolResultBlock.tsx b/harness/web/src/components/ToolResultBlock.tsx deleted file mode 100644 index b4fafdc9d..000000000 --- a/harness/web/src/components/ToolResultBlock.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useState } from "react"; - -interface Props { - toolName: string; - isError: boolean; - output: string; -} - -const COLLAPSED_LIMIT = 600; - -export function ToolResultBlock({ toolName, isError, output }: Props) { - const [expanded, setExpanded] = useState(false); - const truncated = output.length > COLLAPSED_LIMIT; - const visible = expanded || !truncated ? output : output.slice(0, COLLAPSED_LIMIT) + "…"; - return ( -
-
- {isError ? "error" : "result"} - {toolName} -
-
{visible}
- {truncated ? ( - - ) : null} -
- ); -} diff --git a/harness/web/src/components/ToolUseBlock.tsx b/harness/web/src/components/ToolUseBlock.tsx deleted file mode 100644 index 5ab43fff0..000000000 --- a/harness/web/src/components/ToolUseBlock.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useState } from "react"; - -interface Props { - name: string; - args: unknown; -} - -export function ToolUseBlock({ name, args }: Props) { - const [open, setOpen] = useState(false); - return ( -
- - {open ? ( -
{JSON.stringify(args, null, 2)}
- ) : null} -
- ); -} diff --git a/harness/web/src/export.test.ts b/harness/web/src/export.test.ts index 148a0fc7e..5b960b18c 100644 --- a/harness/web/src/export.test.ts +++ b/harness/web/src/export.test.ts @@ -148,7 +148,7 @@ describe("export", () => { messages: [{ entry_id: "e1", message: toolResult }], }); await exportMd("s2"); - expect(captured[0].content).toContain("## Tool result (tc1)"); + expect(captured[0].content).toContain("## Function result (tc1)"); expect(captured[0].content).toContain("```json"); }); }); diff --git a/harness/web/src/export.ts b/harness/web/src/export.ts index af21f50be..ecd2e43b1 100644 --- a/harness/web/src/export.ts +++ b/harness/web/src/export.ts @@ -2,8 +2,9 @@ // bridge and stream the result through a Blob → temporary click. // The browser owns the file dialog; we never round-trip bytes to the server. // -// Markdown: human-readable transcript with role headings; tool results are -// fenced JSON since their content shape is open-ended. +// Markdown: human-readable transcript with role headings; function results are +// fenced JSON since their content shape is open-ended. Assistant blocks may still +// carry upstream `tool_use` shapes on the wire — we render those as fenced JSON. // JSON: structured wrapper { session_id, exported_at, messages, tree } — // tree fetch is best-effort (drift case returns null). @@ -60,11 +61,14 @@ export async function exportMd(sessionId: string): Promise { lines.push("## Assistant\n"); lines.push(formatContent(message.content)); } else { - // tool_result and any other roles fall through here. The JSON dump - // preserves the full envelope (tool_call_id, is_error, content blocks). - const toolId = - (message as { tool_call_id?: string }).tool_call_id ?? "unknown"; - lines.push(`## Tool result (${toolId})\n`); + // Legacy `tool_result` role and other roles fall through here. The JSON dump + // preserves the full envelope (function_call_id / tool_call_id, is_error, content). + const rid = + (message as { function_call_id?: string; tool_call_id?: string }) + .function_call_id ?? + (message as { tool_call_id?: string }).tool_call_id ?? + "unknown"; + lines.push(`## Function result (${rid})\n`); lines.push(`\`\`\`json\n${JSON.stringify(message, null, 2)}\n\`\`\`\n`); } } diff --git a/harness/web/src/reducer.test.ts b/harness/web/src/reducer.test.ts index 48769b155..3140340ac 100644 --- a/harness/web/src/reducer.test.ts +++ b/harness/web/src/reducer.test.ts @@ -67,15 +67,16 @@ describe("reducer (entry-id keyed)", () => { it("approval_requested + approval_resolved manage pendingApprovals", () => { let s = applyEvent(INITIAL_STREAM_STATE, { type: "approval_requested", - tool_call_id: "t1", - tool_name: "shell::filesystem::write", + function_call_id: "t1", + function_id: "shell::filesystem::write", args: {}, expires_at: 0, }); expect(s.pendingApprovals.length).toBe(1); + expect(s.pendingApprovals[0].function_call_id).toBe("t1"); s = applyEvent(s, { type: "approval_resolved", - tool_call_id: "t1", + function_call_id: "t1", decision: "allow", }); expect(s.pendingApprovals.length).toBe(0); diff --git a/harness/web/src/reducer.ts b/harness/web/src/reducer.ts index 41612adc9..3c1fba765 100644 --- a/harness/web/src/reducer.ts +++ b/harness/web/src/reducer.ts @@ -108,30 +108,43 @@ export function applyEvent(state: StreamState, event: AgentEvent): StreamState { } case "approval_requested": { + const id = + event.function_call_id || + ("tool_call_id" in event && event.tool_call_id ? event.tool_call_id : ""); + const name = + event.function_id || + ("tool_name" in event && event.tool_name ? event.tool_name : ""); + if (!id) return state; const entry: PendingApproval = { - tool_call_id: event.tool_call_id, - tool_name: event.tool_name, + function_call_id: id, + function_id: name, args: event.args, expires_at: event.expires_at, }; - if (state.pendingApprovals.some((a) => a.tool_call_id === entry.tool_call_id)) { + if (state.pendingApprovals.some((a) => a.function_call_id === entry.function_call_id)) { return state; } return { ...state, pendingApprovals: [...state.pendingApprovals, entry] }; } - case "approval_resolved": + case "approval_resolved": { + const rid = + event.function_call_id || + ("tool_call_id" in event && event.tool_call_id ? event.tool_call_id : ""); + if (!rid) return state; return { ...state, - pendingApprovals: state.pendingApprovals.filter( - (a) => a.tool_call_id !== event.tool_call_id, - ), + pendingApprovals: state.pendingApprovals.filter((a) => a.function_call_id !== rid), }; + } case "turn_start": case "tool_execution_start": case "tool_execution_update": case "tool_execution_end": + case "function_execution_start": + case "function_execution_update": + case "function_execution_end": return state; default: diff --git a/harness/web/src/styles.css b/harness/web/src/styles.css index fd8fbafe7..b96170b09 100644 --- a/harness/web/src/styles.css +++ b/harness/web/src/styles.css @@ -575,7 +575,7 @@ code, /* ─── view ───────────────────────────────────────────────────────────────── */ .view { - padding: var(--space-6) var(--space-6) var(--space-5); + padding: var(--space-4) var(--space-6) var(--space-3); overflow-y: auto; min-height: 0; } @@ -625,9 +625,9 @@ code, } .view-head { - margin-bottom: var(--space-5); + margin-bottom: var(--space-3); border-bottom: 1px solid var(--rule); - padding-bottom: var(--space-4); + padding-bottom: var(--space-2); } .view-eyebrow { @@ -654,18 +654,25 @@ code, margin: 0; padding: 0; display: grid; - gap: var(--space-5); + gap: var(--space-3); max-width: 72ch; } .msg { display: grid; - grid-template-columns: 96px 1fr; - gap: var(--space-4); + grid-template-columns: 96px minmax(0, 1fr); + gap: var(--space-3); align-items: baseline; animation: fade-in 240ms cubic-bezier(0.22, 1, 0.36, 1); } +.msg-body { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} + @keyframes fade-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } @@ -684,19 +691,212 @@ code, text-overflow: ellipsis; } -.msg[data-role="assistant"] .msg-role { - color: var(--accent-ink); +.msg[data-role="assistant"] .msg-role::before { + content: "•"; + color: var(--accent); + margin-right: 4px; +} + +.msg-turn-change { + border-top: 1px solid var(--rule); + padding-top: var(--space-3); } .msg-text { margin: 0; + font-size: 15px; + line-height: 1.5; + color: var(--ink); + max-width: 65ch; +} + +p.msg-text { white-space: pre-wrap; - font-size: 15.5px; - line-height: 1.6; +} + +/* ─── markdown (assistant text) ─────────────────────────────────────────── */ + +.md > :first-child { + margin-top: 0; +} + +.md > :last-child { + margin-bottom: 0; +} + +.md p { + margin: 0 0 var(--space-2); + font-size: 15px; + line-height: 1.5; color: var(--ink); +} + +.md h1, +.md h2, +.md h3, +.md h4, +.md h5, +.md h6 { + margin: var(--space-4) 0 var(--space-2); + color: var(--ink); + letter-spacing: -0.01em; +} + +.md h1, +.md h2 { + font-family: var(--display); + font-style: italic; + font-weight: 500; +} + +.md h1 { + font-size: 22px; + line-height: 1.2; +} + +.md h2 { + font-size: 18px; + line-height: 1.25; +} + +.md h3 { + font-family: var(--body); + font-size: 16px; + font-weight: 600; + line-height: 1.3; +} + +.md h4, +.md h5, +.md h6 { + font-family: var(--body); + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-2); +} + +.md a { + color: var(--accent-ink); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + +.md a:hover { + color: var(--ink); +} + +.md strong { + font-weight: 600; + color: var(--ink); +} + +.md em { + font-style: italic; +} + +.md del { + text-decoration: line-through; + color: var(--ink-3); +} + +.md ul, +.md ol { + margin: 0 0 var(--space-2); + padding-left: var(--space-5); +} + +.md li { + margin: 0 0 4px; + line-height: 1.5; +} + +.md li > p { + margin: 0; +} + +.md li::marker { + color: var(--ink-3); +} + +.md hr { + border: 0; + border-top: 1px solid var(--rule); + margin: var(--space-4) 0; +} + +.md blockquote { + margin: var(--space-3) 0; + padding: 0 0 0 var(--space-3); + font-style: italic; + color: var(--ink-2); +} + +.md blockquote p::before { + content: "— "; + color: var(--ink-3); + font-style: normal; +} + +.md code { + font-family: var(--mono); + font-size: 0.88em; + background: var(--paper-3); + padding: 1px 5px; + color: var(--ink); + word-break: break-word; +} + +.md pre { + margin: var(--space-2) 0; + padding: var(--space-3); + background: var(--paper-3); + border: 1px solid var(--rule); + overflow-x: auto; + max-width: 65ch; + max-height: 360px; +} + +.md pre code { + background: transparent; + padding: 0; + font-size: 12px; + line-height: 1.5; + color: var(--ink-2); + white-space: pre; +} + +.md table { + margin: var(--space-3) 0; + border-collapse: collapse; + font-size: 13px; max-width: 65ch; } +.md th, +.md td { + padding: 4px var(--space-3) 4px 0; + border-bottom: 1px solid var(--rule); + text-align: left; + vertical-align: top; +} + +.md th { + font-family: var(--mono); + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ink-3); +} + +.md input[type="checkbox"] { + margin-right: var(--space-2); + accent-color: var(--accent); +} + .msg-usage { margin: 6px 0 0; font-family: var(--mono); @@ -710,6 +910,171 @@ code, font-style: italic; } +/* ─── function call/result blocks ────────────────────────────────────────── */ + +.block { + margin: var(--space-2) 0 0; + border: 1px solid var(--rule); + background: var(--paper-2); + font-family: var(--mono); + max-width: 65ch; +} + +.block-head { + display: flex; + align-items: baseline; + gap: var(--space-3); + width: 100%; + padding: 6px var(--space-3); + margin: 0; + background: transparent; + border: 0; + text-align: left; + cursor: pointer; + color: inherit; + font: inherit; + font-family: var(--mono); +} + +.block-head:hover { + background: var(--paper-3); +} + +.block-head:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -1px; +} + +.block-eyebrow { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--ink-3); + white-space: nowrap; + flex: 0 0 auto; +} + +.block-title { + flex: 0 1 auto; + font-size: 12px; + letter-spacing: 0.01em; + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.block-preview { + flex: 1 1 0; + min-width: 0; + font-size: 11.5px; + color: var(--ink-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.block-preview::before { + content: "·"; + margin-right: var(--space-2); + color: var(--rule-strong); +} + +.block-toggle { + font-size: 13px; + color: var(--ink-3); + flex: 0 0 auto; + width: 1ch; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.block[data-open="true"] { + background: var(--paper-3); +} + +.block-tool-result[data-error="true"][data-open="true"] { + background: color-mix(in oklch, var(--accent-wash) 80%, var(--paper-2)); +} + +.block[data-open="true"] > .block-head .block-toggle { + color: var(--ink); +} + +.block-body { + margin: 0; + padding: var(--space-2) var(--space-3) var(--space-3); + border-top: 1px solid var(--rule); + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.5; + color: var(--ink-2); + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + max-height: 360px; + overflow-y: auto; +} + +.block-body-md { + font-family: var(--body); + font-size: 13.5px; + color: var(--ink); + white-space: normal; +} + +.block-body-md .md pre { + background: var(--paper); + border-color: var(--rule); + max-height: none; + max-width: none; +} + +.block-body-md .md code { + background: var(--paper); +} + +.block[data-open="true"] .block-body-md .md pre, +.block[data-open="true"] .block-body-md .md code { + background: var(--paper-2); +} + +.block-tool-result[data-error="true"] { + background: color-mix(in oklch, var(--accent-wash) 70%, var(--paper-2)); + border-color: color-mix(in oklch, var(--accent) 30%, var(--rule)); +} + +.block-tool-result[data-error="true"] .block-eyebrow { + color: var(--accent-ink); +} + +.block-more { + display: block; + width: 100%; + margin: 0; + padding: 4px var(--space-3); + background: transparent; + border: 0; + border-top: 1px solid var(--rule); + font-family: var(--mono); + font-size: 10.5px; + text-transform: lowercase; + letter-spacing: 0.12em; + color: var(--ink-3); + cursor: pointer; + text-align: left; + transition: + color 120ms ease, + background 120ms ease; +} + +.block-more:hover, +.block-more:focus-visible { + color: var(--ink); + background: var(--paper-3); + outline: 0; +} + /* ─── context meter ──────────────────────────────────────────────────────── */ .ctx-meter { @@ -1736,7 +2101,7 @@ code, .msg-actions { display: flex; gap: var(--space-1); - margin-top: var(--space-2); + margin-top: var(--space-1); opacity: 0; transition: opacity 120ms ease; } @@ -1761,8 +2126,9 @@ code, } .msg-action:hover:not(:disabled) { - background: var(--accent-wash); - color: var(--accent-ink); + background: var(--paper-2); + color: var(--ink); + border-color: var(--rule-strong); } .msg-action:disabled { diff --git a/harness/web/src/types.ts b/harness/web/src/types.ts index 2608bf49f..96eaf0e8a 100644 --- a/harness/web/src/types.ts +++ b/harness/web/src/types.ts @@ -26,16 +26,21 @@ export interface AssistantMessage { }; } -export interface ToolResultMessage { - role: "tool_result"; +/** Function result row on the transcript (`role` stays `tool_result` on the wire). */ +export interface FunctionResultMessage { + role: "tool_result" | "function_result"; content: ContentBlock[]; - tool_call_id: string; - tool_name: string; + /** New field names — prefer these when present. */ + function_call_id?: string; + function_id?: string; + /** Legacy field names (one release of stream replay). */ + tool_call_id?: string; + tool_name?: string; is_error: boolean; timestamp: number; } -export type AgentMessage = UserMessage | AssistantMessage | ToolResultMessage; +export type AgentMessage = UserMessage | AssistantMessage | FunctionResultMessage; export interface SessionRow { session_id: string; @@ -163,7 +168,7 @@ export type AgentEvent = // backend that threads entry_ids through. | { type: "agent_end"; messages: (AgentMessage | { entry_id?: EntryId; message: AgentMessage })[] } | { type: "turn_start" } - | { type: "turn_end"; message: AgentMessage; tool_results: unknown[]; entry_id?: EntryId } + | { type: "turn_end"; message: AgentMessage; tool_results?: unknown[]; function_results?: unknown[]; entry_id?: EntryId } | { type: "message_start"; message: AgentMessage; entry_id?: EntryId } | { type: "message_update"; message: AgentMessage; llm_event: unknown; entry_id?: EntryId } | { type: "message_end"; message: AgentMessage; entry_id?: EntryId } @@ -187,25 +192,51 @@ export type AgentEvent = result: unknown; is_error: boolean; } + | { + type: "function_execution_start"; + function_call_id: string; + function_id: string; + args: unknown; + } + | { + type: "function_execution_update"; + function_call_id: string; + function_id: string; + args: unknown; + partial_result: unknown; + } + | { + type: "function_execution_end"; + function_call_id: string; + function_id: string; + result: unknown; + is_error: boolean; + } | { type: "approval_requested"; - tool_call_id: string; - tool_name: string; + function_call_id: string; + function_id: string; args: unknown; expires_at: number; + /** Legacy keys — optional for one release of mixed clients. */ + tool_call_id?: string; + tool_name?: string; } | { type: "approval_resolved"; - tool_call_id: string; + function_call_id: string; decision: "allow" | "deny"; reason?: string | null; + tool_call_id?: string; }; export interface PendingApproval { - tool_call_id: string; - tool_name: string; - args: unknown; - expires_at: number; + function_call_id?: string; + tool_call_id?: string; + function_id?: string; + tool_name?: string; + args?: unknown; + expires_at?: number; } export interface StreamState { diff --git a/harness/web/src/useStatus.test.ts b/harness/web/src/useStatus.test.ts index 29c6ea0a8..660a326d5 100644 --- a/harness/web/src/useStatus.test.ts +++ b/harness/web/src/useStatus.test.ts @@ -102,17 +102,17 @@ describe("useStatus", () => { await flush(); await fire("ui::approval::requested", { - tool_call_id: "tc-1", - tool_name: "shell::filesystem::write", + function_call_id: "tc-1", + function_id: "shell::filesystem::write", args: { path: "/tmp/x" }, expires_at: 9999, session_id: "s1", }); expect(result.current.pendingApprovals).toHaveLength(1); - expect(result.current.pendingApprovals[0].tool_call_id).toBe("tc-1"); + expect(result.current.pendingApprovals[0].function_call_id).toBe("tc-1"); await fire("ui::approval::resolved", { - tool_call_id: "tc-1", + function_call_id: "tc-1", decision: "allow", }); expect(result.current.pendingApprovals).toHaveLength(0); @@ -123,12 +123,12 @@ describe("useStatus", () => { await flush(); await fire("ui::approval::requested", { - tool_call_id: "tc-1", - tool_name: "x", + function_call_id: "tc-1", + function_id: "x", }); await fire("ui::approval::requested", { - tool_call_id: "tc-1", - tool_name: "x", + function_call_id: "tc-1", + function_id: "x", }); expect(result.current.pendingApprovals).toHaveLength(1); }); @@ -200,6 +200,27 @@ describe("useStatus", () => { expect(result.current.events).toHaveLength(0); }); + it("still accepts legacy ui::approval fields (tool_call_id / tool_name)", async () => { + const { result } = renderHook(() => useStatus()); + await flush(); + + await fire("ui::approval::requested", { + tool_call_id: "legacy-1", + tool_name: "shell::x", + }); + expect(result.current.pendingApprovals).toHaveLength(1); + expect( + result.current.pendingApprovals[0].function_call_id ?? + result.current.pendingApprovals[0].tool_call_id, + ).toBe("legacy-1"); + + await fire("ui::approval::resolved", { + tool_call_id: "legacy-1", + decision: "deny", + }); + expect(result.current.pendingApprovals).toHaveLength(0); + }); + it("flips hydrated=true after the first push of any kind", async () => { const { result } = renderHook(() => useStatus()); await flush(); diff --git a/harness/web/src/useStatus.ts b/harness/web/src/useStatus.ts index 867c795b8..94f0a3f0a 100644 --- a/harness/web/src/useStatus.ts +++ b/harness/web/src/useStatus.ts @@ -43,13 +43,19 @@ export interface WorkersSnapshot { /** Pending approval as pushed by the fanout's approval poll. */ export interface PendingApprovalSummary { - tool_call_id: string; + function_call_id?: string; + tool_call_id?: string; + function_id?: string; tool_name?: string; args?: unknown; expires_at?: number; session_id?: string; } +function approvalCallId(p: PendingApprovalSummary): string | undefined { + return p.function_call_id ?? p.tool_call_id; +} + /** A single rolling-buffer entry. Compact on purpose — the StatusTab feed * formats each line, so we keep the raw payload for filter chips to inspect. */ export interface StatusEvent { @@ -89,10 +95,15 @@ export interface UseStatusValue { } interface ResolvedPayload { - tool_call_id: string; + function_call_id?: string; + tool_call_id?: string; decision?: "allow" | "deny"; } +function resolvedCallId(p: ResolvedPayload): string | undefined { + return p.function_call_id ?? p.tool_call_id; +} + export function useStatus(): UseStatusValue { const [pendingApprovals, setPendingApprovals] = useState< PendingApprovalSummary[] @@ -144,9 +155,10 @@ export function useStatus(): UseStatusValue { client.on( "ui::approval::requested", (payload) => { - if (!payload?.tool_call_id) return; + if (!approvalCallId(payload)) return; setPendingApprovals((prev) => { - if (prev.some((p) => p.tool_call_id === payload.tool_call_id)) { + const id = approvalCallId(payload)!; + if (prev.some((p) => approvalCallId(p) === id)) { return prev; } return prev.concat(payload); @@ -159,9 +171,10 @@ export function useStatus(): UseStatusValue { offs.push( client.on("ui::approval::resolved", (payload) => { - if (!payload?.tool_call_id) return; + const rid = resolvedCallId(payload); + if (!rid) return; setPendingApprovals((prev) => - prev.filter((p) => p.tool_call_id !== payload.tool_call_id), + prev.filter((p) => approvalCallId(p) !== rid), ); pushEvent("approval", payload); setHydrated(true); diff --git a/harness/web/tests/e2e/approval.spec.ts b/harness/web/tests/e2e/approval.spec.ts index 01197b0bc..628ad2938 100644 --- a/harness/web/tests/e2e/approval.spec.ts +++ b/harness/web/tests/e2e/approval.spec.ts @@ -7,7 +7,7 @@ test.describe("approval flow", () => { await page.goto("/"); }); - test("allow path writes the file and renders ToolResultBlock", async ({ page }) => { + test("allow path writes the file and renders function result block", async ({ page }) => { await page.getByPlaceholder(/say something/i).fill(PROMPT); await page.getByRole("button", { name: /send/i }).click(); const approval = page.locator(".approval"); diff --git a/hook-fanout/README.md b/hook-fanout/README.md index cce28d267..083f0ce41 100644 --- a/hook-fanout/README.md +++ b/hook-fanout/README.md @@ -28,8 +28,8 @@ async fn main() -> anyhow::Result<()> { .trigger(TriggerRequest { function_id: "hook-fanout::publish_collect".into(), payload: json!({ - "topic": "agent::before_tool_call", - "payload": { "tool_call": { "id": "t1" } }, + "topic": "agent::before_function_call", + "payload": { "function_call": { "id": "t1" } }, "merge_rule": "first_block_wins", "timeout_ms": 5000, }), diff --git a/hook-fanout/src/lib.rs b/hook-fanout/src/lib.rs index 09103d213..c3cdf67ac 100644 --- a/hook-fanout/src/lib.rs +++ b/hook-fanout/src/lib.rs @@ -208,14 +208,14 @@ mod tests { #[test] fn build_publish_envelope_matches_existing_subscribers() { let envelope = build_publish_envelope( - "agent::before_tool_call", + "agent::before_function_call", "event-1", - json!({"tool_call": {"id": "t1"}}), + json!({"function_call": {"id": "t1"}}), ); - assert_eq!(envelope["topic"], "agent::before_tool_call"); + assert_eq!(envelope["topic"], "agent::before_function_call"); assert_eq!(envelope["data"]["event_id"], "event-1"); assert_eq!(envelope["data"]["reply_stream"], HOOK_REPLY_STREAM); - assert_eq!(envelope["data"]["payload"]["tool_call"]["id"], "t1"); + assert_eq!(envelope["data"]["payload"]["function_call"]["id"], "t1"); } } diff --git a/policy-denylist/README.md b/policy-denylist/README.md index 72edbb2ee..cad8e09bc 100644 --- a/policy-denylist/README.md +++ b/policy-denylist/README.md @@ -1,7 +1,7 @@ # policy-denylist -Subscribes to `agent::before_tool_call` and blocks any tool call whose -`tool_call.name` is on a configured denylist (exact string match, case-sensitive). +Subscribes to `agent::before_function_call` and blocks any call whose +`function_call.function_id` is on a configured denylist (exact string match, case-sensitive). The engine and other workers publish that hook topic so you get a second line of defense after client-side allowlists. @@ -20,7 +20,7 @@ This worker does not expose a separate HTTP tool surface: it registers `policy::denylist` and binds a `subscribe` trigger to the configured topic. From another process on the bus you only need the engine running and this worker started; hook traffic is driven by `provider-router` (or any publisher of -`agent::before_tool_call`). +`agent::before_function_call`). ```rust use iii_sdk::{register_worker, InitOptions, TriggerRequest}; @@ -44,25 +44,27 @@ async fn main() -> anyhow::Result<()> { } ``` -In practice the function is invoked by the bus when a `before_tool_call` event +In practice the function is invoked by the bus when a `before_function_call` event arrives; the snippet above is only useful to verify registration in a dev setup. ## Configuration ```yaml -topic: agent::before_tool_call # hook topic to subscribe to -denied_tools: # tool names to block (exact match) +topic: agent::before_function_call # hook topic to subscribe to +denied_functions: # iii function ids to block (exact match) - "bash:rm -rf" - sudo - curl-pipe-bash ``` +The key `denied_tools` is accepted as an alias for one release (serde). + If the engine wraps settings under a `config:` key, that nested block is accepted as well. Other keys (and their defaults) live in [`src/config.rs`](src/config.rs). -`POLICY_DENYLIST_TOPIC` and `POLICY_DENIED_TOOLS` (comma-separated list, same -semantics as before) override the file when set. +`POLICY_DENYLIST_TOPIC` and `POLICY_DENIED_FUNCTIONS` (comma-separated list; legacy +`POLICY_DENIED_TOOLS` still read) override the file when set. ## Workspace allowlist composition @@ -73,11 +75,11 @@ Chat clients (e.g. `iii-console`) layer a workspace allowlist on top of - Absolute paths outside the workspace are rejected by the SDK wrapper *before* the bus call is dispatched. -`policy-denylist` remains the second layer (deny by tool name regardless of +`policy-denylist` remains the second layer (deny by function id regardless of arguments). The two layers compose: 1. SDK wrapper (chat client side) — workspace allowlist on path arguments. -2. `policy-denylist` (engine side) — deny by tool name (exact match, case-sensitive). +2. `policy-denylist` (engine side) — deny by function id (exact match, case-sensitive). 3. `` (chat UI) — per-call user approval surfaced inline before any write reaches disk. ## Registered functions @@ -88,7 +90,7 @@ arguments). The two layers compose: ## Runtime expectations -By default, the worker subscribes to `agent::before_tool_call`. That topic is +By default, the worker subscribes to `agent::before_function_call`. That topic is published by `provider-router` while the agent loop is executing — for the denylist to fire, `provider-router` (or any worker emitting the same topic) must be running on the bus. diff --git a/policy-denylist/iii.worker.yaml b/policy-denylist/iii.worker.yaml index 720b58c35..aa64a4bbf 100644 --- a/policy-denylist/iii.worker.yaml +++ b/policy-denylist/iii.worker.yaml @@ -4,10 +4,10 @@ language: rust deploy: binary manifest: Cargo.toml bin: iii-policy-denylist -description: Hook subscriber on agent::before_tool_call that blocks calls whose name matches a configured denylist. +description: Hook subscriber on agent::before_function_call that blocks calls whose function id matches a configured denylist. config: - topic: agent::before_tool_call - denied_tools: + topic: agent::before_function_call + denied_functions: - "bash:rm -rf" - sudo - curl-pipe-bash diff --git a/policy-denylist/src/config.rs b/policy-denylist/src/config.rs index 7d9cd14a6..c48c6fe7e 100644 --- a/policy-denylist/src/config.rs +++ b/policy-denylist/src/config.rs @@ -5,28 +5,28 @@ use serde::{Deserialize, Serialize}; pub struct WorkerConfig { #[serde(default = "default_topic")] pub topic: String, - #[serde(default = "default_denied_tools_vec")] - pub denied_tools: Vec, + #[serde(default = "default_denied_functions_vec", alias = "denied_tools")] + pub denied_functions: Vec, } fn default_topic() -> String { policy_denylist::DEFAULT_TOPIC.to_string() } -fn default_denied_tools_vec() -> Vec { - policy_denylist::default_denied_tools() +fn default_denied_functions_vec() -> Vec { + policy_denylist::default_denied_functions() } impl Default for WorkerConfig { fn default() -> Self { Self { topic: default_topic(), - denied_tools: default_denied_tools_vec(), + denied_functions: default_denied_functions_vec(), } } } -/// Load operator config: flat `{ topic, denied_tools }`, or iii-style `{ config: { ... } }`. +/// Load operator config: flat `{ topic, denied_functions }`, or iii-style `{ config: { ... } }`. pub fn load_config(path: &str) -> Result { let raw = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?; let root: serde_yaml::Value = @@ -45,7 +45,10 @@ mod tests { fn defaults_from_empty_yaml_mapping() { let cfg: WorkerConfig = serde_yaml::from_str("{}").unwrap(); assert_eq!(cfg.topic, policy_denylist::DEFAULT_TOPIC); - assert_eq!(cfg.denied_tools, policy_denylist::default_denied_tools()); + assert_eq!( + cfg.denied_functions, + policy_denylist::default_denied_functions() + ); } #[test] @@ -53,13 +56,25 @@ mod tests { let cfg: WorkerConfig = serde_yaml::from_str( r" topic: agent::custom -denied_tools: +denied_functions: - risky ", ) .unwrap(); assert_eq!(cfg.topic, "agent::custom"); - assert_eq!(cfg.denied_tools, vec!["risky".to_string()]); + assert_eq!(cfg.denied_functions, vec!["risky".to_string()]); + } + + #[test] + fn denied_tools_field_alias_deserializes_as_denied_functions() { + let cfg: WorkerConfig = serde_yaml::from_str( + r" +denied_tools: + - legacy-entry +", + ) + .unwrap(); + assert_eq!(cfg.denied_functions, vec!["legacy-entry".to_string()]); } #[test] @@ -71,12 +86,12 @@ denied_tools: #[test] fn deserialize_nested_config_block() { let root: serde_yaml::Value = serde_yaml::from_str( - "config:\n topic: agent::nested\n denied_tools:\n - bash:rm -rf", + "config:\n topic: agent::nested\n denied_functions:\n - bash:rm -rf", ) .unwrap(); let node = root.get("config").cloned().unwrap_or(root); let cfg: WorkerConfig = serde_yaml::from_value(node).unwrap(); assert_eq!(cfg.topic, "agent::nested"); - assert_eq!(cfg.denied_tools, vec!["bash:rm -rf".to_string()]); + assert_eq!(cfg.denied_functions, vec!["bash:rm -rf".to_string()]); } } diff --git a/policy-denylist/src/lib.rs b/policy-denylist/src/lib.rs index 4c87f2252..f8b2da8b8 100644 --- a/policy-denylist/src/lib.rs +++ b/policy-denylist/src/lib.rs @@ -1,5 +1,5 @@ -//! Denylist subscriber for `agent::before_tool_call`. Blocks any call whose -//! `tool_call.name` is on a configured denylist. +//! Denylist subscriber for `agent::before_function_call`. Blocks any call whose +//! `function_call.function_id` is on a configured denylist. use std::sync::Arc; @@ -11,8 +11,8 @@ use iii_sdk::{ use serde_json::{json, Value}; const FN_DENYLIST: &str = "policy::denylist"; -pub const DEFAULT_TOPIC: &str = "agent::before_tool_call"; -pub const DEFAULT_DENIED_TOOLS: &[&str] = &["bash:rm -rf", "sudo", "curl-pipe-bash"]; +pub const DEFAULT_TOPIC: &str = "agent::before_function_call"; +pub const DEFAULT_DENIED_FUNCTIONS: &[&str] = &["bash:rm -rf", "sudo", "curl-pipe-bash"]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PolicyDenylistConfig { @@ -27,8 +27,8 @@ impl Default for PolicyDenylistConfig { } } -pub fn default_denied_tools() -> Vec { - DEFAULT_DENIED_TOOLS +pub fn default_denied_functions() -> Vec { + DEFAULT_DENIED_FUNCTIONS .iter() .map(ToString::to_string) .collect() @@ -88,7 +88,7 @@ impl ReplyBus for IiiSdkBus { /// Build the canonical [`RegisterFunctionMessage`] for the denylist function. pub(crate) fn denylist_function_message() -> RegisterFunctionMessage { RegisterFunctionMessage::with_id(FN_DENYLIST.into()) - .with_description("Block tool calls whose name is on a configured denylist.".into()) + .with_description("Block function calls whose id is on a configured denylist.".into()) } /// Build the canonical [`RegisterTriggerInput`] for the denylist subscriber. @@ -120,9 +120,9 @@ pub(crate) fn unwrap_envelope(payload: &Value) -> (String, String, Value) { (event_id, reply_stream, inner) } -/// Pure check: is `tool_name` on the denylist? -pub(crate) fn check_denylist(tool_name: &str, denied: &[String]) -> bool { - denied.iter().any(|d| d == tool_name) +/// Pure check: is `function_id` on the denylist? +pub(crate) fn check_denylist(function_id: &str, denied: &[String]) -> bool { + denied.iter().any(|d| d == function_id) } /// Run the denylist handler logic against an arbitrary [`ReplyBus`]. Used by @@ -130,16 +130,18 @@ pub(crate) fn check_denylist(tool_name: &str, denied: &[String]) -> bool { /// in-memory bus). pub(crate) async fn handle_event(bus: &dyn ReplyBus, denied: &[String], payload: Value) -> Value { let (event_id, reply_stream, inner) = unwrap_envelope(&payload); - let tool_name = inner - .get("tool_call") - .and_then(|tc| tc.get("name")) + let fc = inner + .get("function_call") + .or_else(|| inner.get("tool_call")); + let function_id = fc + .and_then(|v| v.get("function_id").or_else(|| v.get("name"))) .and_then(Value::as_str) .unwrap_or("") .to_string(); - let reply = if check_denylist(&tool_name, denied) { + let reply = if check_denylist(&function_id, denied) { json!({ "block": true, - "reason": format!("policy::denylist blocked '{tool_name}'"), + "reason": format!("policy::denylist blocked '{function_id}'"), }) } else { json!({ "block": false }) @@ -148,17 +150,20 @@ pub(crate) async fn handle_event(bus: &dyn ReplyBus, denied: &[String], payload: reply } -pub fn subscribe_denylist(iii: &III, denied_tools: Vec) -> Result { - subscribe_denylist_with_config(iii, denied_tools, PolicyDenylistConfig::default()) +pub fn subscribe_denylist( + iii: &III, + denied_functions: Vec, +) -> Result { + subscribe_denylist_with_config(iii, denied_functions, PolicyDenylistConfig::default()) } pub fn subscribe_denylist_with_config( iii: &III, - denied_tools: Vec, + denied_functions: Vec, config: PolicyDenylistConfig, ) -> Result { let bus: Arc = Arc::new(IiiSdkBus(iii.clone())); - let denied: Arc> = Arc::new(denied_tools); + let denied: Arc> = Arc::new(denied_functions); let fn_msg = denylist_function_message(); bus.record_function(&fn_msg); @@ -308,7 +313,7 @@ mod tests { async fn wiring_uses_configured_trigger_topic() { let bus = InMemoryBus::new(); let config = PolicyDenylistConfig { - topic: "agent::custom_before_tool_call".into(), + topic: "agent::custom_before_function_call".into(), }; record_wiring_with_config(&bus, &config); @@ -316,7 +321,7 @@ mod tests { assert_eq!(trigs.len(), 1); assert_eq!( trigs[0].config.get("topic").and_then(Value::as_str), - Some("agent::custom_before_tool_call") + Some("agent::custom_before_function_call") ); } @@ -327,7 +332,7 @@ mod tests { let payload = envelope( "e1", "rs", - json!({ "tool_call": { "name": "dangerous_tool" } }), + json!({ "function_call": { "function_id": "dangerous_tool" } }), ); let reply = handle_event(&bus, &denied, payload).await; @@ -349,7 +354,11 @@ mod tests { async fn handler_allows_unlisted_tool_name() { let bus = InMemoryBus::new(); let denied = vec!["dangerous_tool".to_string()]; - let payload = envelope("e1", "rs", json!({ "tool_call": { "name": "safe_tool" } })); + let payload = envelope( + "e1", + "rs", + json!({ "function_call": { "function_id": "safe_tool" } }), + ); let reply = handle_event(&bus, &denied, payload).await; assert_eq!(reply, json!({ "block": false })); @@ -362,7 +371,7 @@ mod tests { async fn handler_treats_missing_tool_name_as_allowed() { let bus = InMemoryBus::new(); let denied = vec!["dangerous_tool".to_string()]; - let payload = envelope("e1", "rs", json!({ "tool_call": {} })); + let payload = envelope("e1", "rs", json!({ "function_call": {} })); let reply = handle_event(&bus, &denied, payload).await; assert_eq!(reply, json!({ "block": false })); @@ -374,7 +383,7 @@ mod tests { let denied = vec!["dangerous_tool".to_string()]; let payload = json!({ "reply_stream": "rs", - "payload": { "tool_call": { "name": "dangerous_tool" } }, + "payload": { "function_call": { "function_id": "dangerous_tool" } }, }); let reply = handle_event(&bus, &denied, payload).await; @@ -382,6 +391,15 @@ mod tests { assert!(bus.recorded_replies().is_empty()); } + #[tokio::test] + async fn handler_reads_legacy_tool_call_envelope_name_field() { + let bus = InMemoryBus::new(); + let denied = vec!["legacy_id".to_string()]; + let payload = envelope("e1", "rs", json!({ "tool_call": { "name": "legacy_id" } })); + let reply = handle_event(&bus, &denied, payload).await; + assert_eq!(reply.get("block"), Some(&Value::Bool(true))); + } + #[test] fn check_denylist_empty_list_allows_everything() { assert!(!check_denylist("anything", &[])); diff --git a/policy-denylist/src/main.rs b/policy-denylist/src/main.rs index 507238025..5e03efb2c 100644 --- a/policy-denylist/src/main.rs +++ b/policy-denylist/src/main.rs @@ -12,7 +12,7 @@ use std::sync::Arc; #[derive(Parser, Debug)] #[command( name = "iii-policy-denylist", - about = "Denylist subscriber for agent::before_tool_call" + about = "Denylist subscriber for agent::before_function_call" )] struct Cli { #[arg(long, default_value = "./config.yaml")] @@ -32,15 +32,20 @@ fn apply_runtime_env_overrides(cfg: &mut config::WorkerConfig) { cfg.topic = topic.to_string(); } } - if let Ok(denied) = std::env::var("POLICY_DENIED_TOOLS") { - let denied_tools = parse_denied_tools(&denied); - if !denied_tools.is_empty() { - cfg.denied_tools = denied_tools; + if let Ok(denied) = std::env::var("POLICY_DENIED_FUNCTIONS") { + let denied_functions = parse_denied_functions(&denied); + if !denied_functions.is_empty() { + cfg.denied_functions = denied_functions; + } + } else if let Ok(denied) = std::env::var("POLICY_DENIED_TOOLS") { + let denied_functions = parse_denied_functions(&denied); + if !denied_functions.is_empty() { + cfg.denied_functions = denied_functions; } } } -fn parse_denied_tools(raw: &str) -> Vec { +fn parse_denied_functions(raw: &str) -> Vec { let raw = raw.trim(); // Brackets must be balanced. A single unmatched bracket previously // leaked into the first/last token (e.g. `[tool1,tool2` parsed as @@ -52,7 +57,7 @@ fn parse_denied_tools(raw: &str) -> Vec { _ => { tracing::warn!( input = %raw, - "POLICY_DENIED_TOOLS has unmatched bracket; ignoring brackets and parsing as comma-separated" + "POLICY_DENIED_FUNCTIONS has unmatched bracket; ignoring brackets and parsing as comma-separated" ); raw.trim_matches(|c| c == '[' || c == ']') } @@ -138,7 +143,7 @@ async fn main() -> Result<()> { let _sub = subscribe_denylist_with_config( &iii, - cfg.denied_tools.clone(), + cfg.denied_functions.clone(), PolicyDenylistConfig { topic: cfg.topic.clone(), }, @@ -147,7 +152,7 @@ async fn main() -> Result<()> { tracing::info!( topic = %cfg.topic, - denied_tools = %cfg.denied_tools.join(","), + denied_functions = %cfg.denied_functions.join(","), "policy-denylist subscribed (policy::denylist)", ); @@ -160,35 +165,35 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::parse_denied_tools; + use super::parse_denied_functions; // ── Adversarial unit tests added per plan // /Users/ytallolayon/.claude/plans/let-s-implement-more-tests-refactored-flask.md #[test] - fn parse_denied_tools_handles_empty_string() { - assert!(parse_denied_tools("").is_empty()); + fn parse_denied_functions_handles_empty_string() { + assert!(parse_denied_functions("").is_empty()); } #[test] - fn parse_denied_tools_strips_whitespace_and_filters_empty() { + fn parse_denied_functions_strips_whitespace_and_filters_empty() { assert_eq!( - parse_denied_tools(" tool1 , , tool2 "), + parse_denied_functions(" tool1 , , tool2 "), vec!["tool1".to_string(), "tool2".to_string()] ); } #[test] - fn parse_denied_tools_accepts_json_array_syntax() { + fn parse_denied_functions_accepts_json_array_syntax() { // The Tier 2 demo.sh injects `bridge::trigger` as a single-value // env, but operators may still pass JSON-array form. This pins // the parser's tolerance for both quoting forms. assert_eq!( - parse_denied_tools(r#"["tool1", "tool2"]"#), + parse_denied_functions(r#"["tool1", "tool2"]"#), vec!["tool1".to_string(), "tool2".to_string()] ); assert_eq!( - parse_denied_tools("['tool1', 'tool2']"), + parse_denied_functions("['tool1', 'tool2']"), vec!["tool1".to_string(), "tool2".to_string()] ); } @@ -200,14 +205,14 @@ mod tests { /// and falls back to a bracket-tolerant strip + warning when one side /// is missing. #[test] - fn parse_denied_tools_handles_malformed_unclosed_bracket() { + fn parse_denied_functions_handles_malformed_unclosed_bracket() { assert_eq!( - parse_denied_tools("[tool1,tool2"), + parse_denied_functions("[tool1,tool2"), vec!["tool1".to_string(), "tool2".to_string()], "open bracket without close must not leak '[' into the first token" ); assert_eq!( - parse_denied_tools("tool1,tool2]"), + parse_denied_functions("tool1,tool2]"), vec!["tool1".to_string(), "tool2".to_string()], "close bracket without open must not leak ']' into the last token" ); diff --git a/policy-denylist/src/manifest.rs b/policy-denylist/src/manifest.rs index cd04574d7..af4bb2869 100644 --- a/policy-denylist/src/manifest.rs +++ b/policy-denylist/src/manifest.rs @@ -14,7 +14,7 @@ pub fn build_manifest() -> ModuleManifest { name: env!("CARGO_PKG_NAME").to_string(), version: env!("CARGO_PKG_VERSION").to_string(), description: - "Hook subscriber on agent::before_tool_call that blocks calls whose name matches a configured denylist." + "Hook subscriber on agent::before_function_call that blocks calls whose function id matches a configured denylist." .to_string(), default_config: serde_json::to_value(crate::config::WorkerConfig::default()).unwrap_or_else(|_| serde_json::json!({})), supported_targets: vec![env!("TARGET").to_string()], diff --git a/provider-anthropic/crates/harness-types/src/agent_event.rs b/provider-anthropic/crates/harness-types/src/agent_event.rs index 8040e33ee..ed74af98a 100644 --- a/provider-anthropic/crates/harness-types/src/agent_event.rs +++ b/provider-anthropic/crates/harness-types/src/agent_event.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; -use crate::agent_message::{AgentMessage, ToolResultMessage}; +use crate::agent_message::{AgentMessage, FunctionResultMessage}; +use crate::function::FunctionResult; use crate::stream_event::AssistantMessageEvent; -use crate::tool::ToolResult; /// Outcome of an approval gate. Wire format is the lowercase string /// `"allow"` or `"deny"`; the typed enum prevents constructing illegal values. @@ -23,15 +23,16 @@ pub enum AgentEvent { /// Loop has completed; carries the full message tail produced. AgentEnd { messages: Vec }, - /// One assistant turn (LLM response + any tool calls/results) has begun. + /// One assistant turn (LLM response + any function calls/results) has begun. TurnStart, /// One assistant turn has completed. TurnEnd { message: AgentMessage, - tool_results: Vec, + #[serde(alias = "tool_results")] + function_results: Vec, }, - /// A user, assistant, or tool-result message is about to be added to the transcript. + /// A user, assistant, or function-result message is about to be added to the transcript. MessageStart { message: AgentMessage }, /// Streaming update on the in-flight assistant message. Only emitted while the /// LLM is producing the current response. @@ -42,40 +43,49 @@ pub enum AgentEvent { /// The message is final and committed to the transcript. MessageEnd { message: AgentMessage }, - /// A tool call has been validated and dispatch has begun. - ToolExecutionStart { - tool_call_id: String, - tool_name: String, + /// A function call has been validated and dispatch has begun. + #[serde(rename = "function_execution_start", alias = "tool_execution_start")] + FunctionExecutionStart { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, }, - /// Streaming partial result from a long-running tool. - ToolExecutionUpdate { - tool_call_id: String, - tool_name: String, + /// Streaming partial result from a long-running function. + #[serde(rename = "function_execution_update", alias = "tool_execution_update")] + FunctionExecutionUpdate { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, partial_result: serde_json::Value, }, - /// Tool execution has finished. `result` is post-`after_tool_call` merged. - ToolExecutionEnd { - tool_call_id: String, - tool_name: String, - result: ToolResult, + /// Function execution has finished. `result` is post-`after_function_call` merged. + #[serde(rename = "function_execution_end", alias = "tool_execution_end")] + FunctionExecutionEnd { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, + result: FunctionResult, is_error: bool, }, - /// A tool call is paused by an approval subscriber, awaiting user decision. - /// `tool_call_id`, `tool_name`, and `args` intentionally duplicate the fields on - /// `ToolExecutionStart` so consumers subscribing only to approval events have full - /// context without replaying the rest of the stream. + /// A function call is paused by an approval subscriber, awaiting user decision. ApprovalRequested { - tool_call_id: String, - tool_name: String, + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, /// Unix milliseconds. After this point the gate auto-denies. expires_at: u64, }, /// Approval gate has resolved a previously-requested approval. ApprovalResolved { - tool_call_id: String, + #[serde(alias = "tool_call_id")] + function_call_id: String, decision: ApprovalDecision, /// Free-form reason — populated for "deny" (e.g. "timeout", "user"). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -86,6 +96,7 @@ pub enum AgentEvent { #[cfg(test)] mod tests { use super::*; + use crate::agent_message::UserMessage; #[test] fn agent_start_serialises_with_tag() { @@ -94,28 +105,43 @@ mod tests { } #[test] - fn tool_start_carries_args() { - let ev = AgentEvent::ToolExecutionStart { - tool_call_id: "id".into(), - tool_name: "read".into(), + fn function_start_carries_args() { + let ev = AgentEvent::FunctionExecutionStart { + function_call_id: "id".into(), + function_id: "read".into(), args: serde_json::json!({ "path": "/x" }), }; let json = serde_json::to_string(&ev).unwrap(); + assert!(json.contains("function_execution_start")); let back: AgentEvent = serde_json::from_str(&json).unwrap(); assert_eq!(ev, back); } + #[test] + fn function_execution_start_legacy_type_deserializes() { + let json = r#"{"type":"tool_execution_start","tool_call_id":"id","tool_name":"read","args":{"path":"/x"}}"#; + let back: AgentEvent = serde_json::from_str(json).unwrap(); + assert_eq!( + back, + AgentEvent::FunctionExecutionStart { + function_call_id: "id".into(), + function_id: "read".into(), + args: serde_json::json!({ "path": "/x" }), + } + ); + } + #[test] fn approval_requested_round_trips() { let evt = AgentEvent::ApprovalRequested { - tool_call_id: "tc-9".into(), - tool_name: "shell::filesystem::write".into(), + function_call_id: "tc-9".into(), + function_id: "shell::filesystem::write".into(), args: serde_json::json!({ "path": "/tmp/x" }), expires_at: 1_700_000_000_000, }; let json = serde_json::to_value(&evt).unwrap(); assert_eq!(json["type"], "approval_requested"); - assert_eq!(json["tool_call_id"], "tc-9"); + assert_eq!(json["function_call_id"], "tc-9"); let back: AgentEvent = serde_json::from_value(json).unwrap(); assert_eq!(back, evt); } @@ -123,7 +149,7 @@ mod tests { #[test] fn approval_resolved_round_trips_with_optional_reason() { let evt = AgentEvent::ApprovalResolved { - tool_call_id: "tc-9".into(), + function_call_id: "tc-9".into(), decision: ApprovalDecision::Deny, reason: Some("timeout".into()), }; @@ -134,7 +160,7 @@ mod tests { assert_eq!(back, evt); let none_reason = AgentEvent::ApprovalResolved { - tool_call_id: "tc-9".into(), + function_call_id: "tc-9".into(), decision: ApprovalDecision::Allow, reason: None, }; @@ -145,4 +171,19 @@ mod tests { "reason should be omitted when None: {json}" ); } + + #[test] + fn turn_end_legacy_tool_results_field() { + let msg = AgentMessage::User(UserMessage { + content: vec![], + timestamp: 0, + }); + let json = serde_json::json!({ + "type": "turn_end", + "message": msg, + "tool_results": [] + }); + let evt: AgentEvent = serde_json::from_value(json).unwrap(); + assert!(matches!(evt, AgentEvent::TurnEnd { .. })); + } } diff --git a/provider-anthropic/crates/harness-types/src/agent_message.rs b/provider-anthropic/crates/harness-types/src/agent_message.rs index c29823068..8cd6af982 100644 --- a/provider-anthropic/crates/harness-types/src/agent_message.rs +++ b/provider-anthropic/crates/harness-types/src/agent_message.rs @@ -1,9 +1,9 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; +use crate::function::AgentFunction; use crate::stream_event::{ErrorKind, StopReason, Usage}; use crate::thinking::ThinkingLevel; -use crate::tool::AgentTool; /// Transcript message. Superset of LLM message types plus app-defined custom entries. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -11,7 +11,8 @@ use crate::tool::AgentTool; pub enum AgentMessage { User(UserMessage), Assistant(AssistantMessage), - ToolResult(ToolResultMessage), + #[serde(rename = "function_result", alias = "tool_result")] + FunctionResult(FunctionResultMessage), Custom(CustomMessage), } @@ -37,9 +38,11 @@ pub struct AssistantMessage { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolResultMessage { - pub tool_call_id: String, - pub tool_name: String, +pub struct FunctionResultMessage { + #[serde(alias = "tool_call_id")] + pub function_call_id: String, + #[serde(alias = "tool_name")] + pub function_id: String, pub content: Vec, pub details: serde_json::Value, pub is_error: bool, @@ -64,8 +67,8 @@ pub struct CustomMessage { pub struct AgentContext { pub system_prompt: String, pub messages: Vec, - #[serde(default)] - pub tools: Vec, + #[serde(default, alias = "tools")] + pub functions: Vec, } /// Persisted session state. Lives at `agent::session//state` on iii state. @@ -102,4 +105,15 @@ mod tests { let m: AgentMessage = serde_json::from_str(json).unwrap(); assert!(matches!(m, AgentMessage::User(_))); } + + #[test] + fn function_result_legacy_tool_result_role() { + let json = r#"{"role":"tool_result","function_call_id":"c1","function_id":"x","content":[],"details":{},"is_error":false,"timestamp":0}"#; + // Old persisted shape used tool_call_id / tool_name + let json_old = r#"{"role":"tool_result","tool_call_id":"c1","tool_name":"x","content":[],"details":{},"is_error":false,"timestamp":0}"#; + let m: AgentMessage = serde_json::from_str(json).unwrap(); + let m_old: AgentMessage = serde_json::from_str(json_old).unwrap(); + assert!(matches!(m, AgentMessage::FunctionResult(_))); + assert!(matches!(m_old, AgentMessage::FunctionResult(_))); + } } diff --git a/provider-anthropic/crates/harness-types/src/content.rs b/provider-anthropic/crates/harness-types/src/content.rs index da2cb7d01..52e678751 100644 --- a/provider-anthropic/crates/harness-types/src/content.rs +++ b/provider-anthropic/crates/harness-types/src/content.rs @@ -2,19 +2,23 @@ use serde::{Deserialize, Serialize}; use crate::thinking::TextSignature; -/// A block of content. Carried by user, assistant, and tool-result messages. +/// A block of content. Carried by user, assistant, and function-result messages. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum ContentBlock { Text(TextContent), Image(ImageContent), - ToolCall { + #[serde(rename = "functionCall", alias = "toolCall")] + FunctionCall { id: String, - name: String, + #[serde(alias = "name")] + function_id: String, arguments: serde_json::Value, }, - ToolResult { - tool_call_id: String, + #[serde(rename = "functionResult", alias = "toolResult")] + FunctionResult { + #[serde(alias = "tool_call_id")] + function_call_id: String, content: Vec, is_error: bool, }, @@ -53,14 +57,29 @@ mod tests { } #[test] - fn tool_call_block_roundtrip() { - let block = ContentBlock::ToolCall { + fn function_call_block_roundtrip() { + let block = ContentBlock::FunctionCall { id: "call_1".into(), - name: "read".into(), + function_id: "read".into(), arguments: serde_json::json!({ "path": "/tmp/x" }), }; let json = serde_json::to_string(&block).unwrap(); let back: ContentBlock = serde_json::from_str(&json).unwrap(); assert_eq!(block, back); } + + #[test] + fn function_call_block_legacy_tool_call_type() { + let json = + r#"{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"/tmp/x"}}"#; + let back: ContentBlock = serde_json::from_str(json).unwrap(); + assert_eq!( + back, + ContentBlock::FunctionCall { + id: "call_1".into(), + function_id: "read".into(), + arguments: serde_json::json!({ "path": "/tmp/x" }), + } + ); + } } diff --git a/provider-router/crates/harness-types/src/tool.rs b/provider-anthropic/crates/harness-types/src/function.rs similarity index 56% rename from provider-router/crates/harness-types/src/tool.rs rename to provider-anthropic/crates/harness-types/src/function.rs index 92c29df98..5620a04ec 100644 --- a/provider-router/crates/harness-types/src/tool.rs +++ b/provider-anthropic/crates/harness-types/src/function.rs @@ -2,9 +2,9 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; -/// A tool definition advertised to the model. +/// A function slot advertised to the model (harness uses `agent_call`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentTool { +pub struct AgentFunction { pub name: String, pub description: String, /// JSON schema for the parameters object. @@ -16,14 +16,14 @@ pub struct AgentTool { pub prepare_arguments_supported: bool, } -/// How tool calls in a single assistant message are scheduled. +/// How function calls in a single assistant message are scheduled. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ExecutionMode { - /// Tool calls run concurrently. Default. + /// Calls run concurrently. Default. #[default] Parallel, - /// Tool calls run one at a time. Any tool flagged sequential forces the + /// Calls run one at a time. Any call flagged sequential forces the /// whole batch to run sequentially. Sequential, } @@ -46,45 +46,48 @@ pub enum CacheRetention { Long, } -/// A single tool-call request emitted by an assistant message. +/// A single function-call request emitted by an assistant message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolCall { +pub struct FunctionCall { pub id: String, - pub name: String, + /// iii function id (e.g. `shell::filesystem::ls`). + #[serde(alias = "name")] + pub function_id: String, pub arguments: serde_json::Value, } -/// Result of executing a tool. `terminate` is a hint that the loop should end -/// after the current batch; honored only when EVERY tool in the batch sets it. +/// Result of executing a function. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolResult { +pub struct FunctionResult { pub content: Vec, pub details: serde_json::Value, #[serde(default)] pub terminate: bool, } -/// Outcome of `prepare_tool`. Either ready to execute, or short-circuited by -/// validation failure or a `before_tool_call` block. +/// Outcome of prepare. Either ready to execute, or short-circuited. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] -pub enum PreparedToolCall { +pub enum PreparedFunctionCall { Prepared { - tool_call: ToolCall, - tool: AgentTool, + #[serde(alias = "tool_call")] + function_call: FunctionCall, + #[serde(alias = "tool")] + function: AgentFunction, args: serde_json::Value, }, Immediate { - result: ToolResult, + result: FunctionResult, is_error: bool, }, } -/// Tool call after `after_tool_call` subscribers have run and merged results. +/// After `after_function_call` subscribers have merged results. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FinalizedToolCall { - pub tool_call: ToolCall, - pub result: ToolResult, +pub struct FinalizedFunctionCall { + #[serde(alias = "tool_call")] + pub function_call: FunctionCall, + pub result: FunctionResult, pub is_error: bool, } @@ -98,14 +101,21 @@ mod tests { } #[test] - fn tool_call_roundtrip() { - let call = ToolCall { + fn function_call_roundtrip() { + let call = FunctionCall { id: "x".into(), - name: "read".into(), + function_id: "read".into(), arguments: serde_json::json!({}), }; let json = serde_json::to_string(&call).unwrap(); - let back: ToolCall = serde_json::from_str(&json).unwrap(); + let back: FunctionCall = serde_json::from_str(&json).unwrap(); assert_eq!(call, back); } + + #[test] + fn function_call_deserializes_legacy_name_field() { + let json = r#"{"id":"x","name":"shell::filesystem::ls","arguments":{}}"#; + let call: FunctionCall = serde_json::from_str(json).unwrap(); + assert_eq!(call.function_id, "shell::filesystem::ls"); + } } diff --git a/provider-anthropic/crates/harness-types/src/lib.rs b/provider-anthropic/crates/harness-types/src/lib.rs index 7ec421cbc..904fccbba 100644 --- a/provider-anthropic/crates/harness-types/src/lib.rs +++ b/provider-anthropic/crates/harness-types/src/lib.rs @@ -6,19 +6,19 @@ mod agent_event; mod agent_message; mod content; +mod function; mod stream_event; mod thinking; -mod tool; pub use agent_event::{AgentEvent, ApprovalDecision}; pub use agent_message::{ AgentContext, AgentMessage, AgentSessionState, AssistantMessage, CustomMessage, - ToolResultMessage, UserMessage, + FunctionResultMessage, UserMessage, }; pub use content::{ContentBlock, ImageContent, TextContent}; +pub use function::{ + AgentFunction, CacheRetention, ExecutionMode, FinalizedFunctionCall, FunctionCall, + FunctionResult, PreparedFunctionCall, Transport, +}; pub use stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; -pub use tool::{ - AgentTool, CacheRetention, ExecutionMode, FinalizedToolCall, PreparedToolCall, ToolCall, - ToolResult, Transport, -}; diff --git a/provider-anthropic/crates/harness-types/src/stream_event.rs b/provider-anthropic/crates/harness-types/src/stream_event.rs index 729e39ade..dd0db8961 100644 --- a/provider-anthropic/crates/harness-types/src/stream_event.rs +++ b/provider-anthropic/crates/harness-types/src/stream_event.rs @@ -8,7 +8,8 @@ use crate::agent_message::AssistantMessage; pub enum StopReason { End, Length, - Tool, + #[serde(rename = "function_call", alias = "tool")] + FunctionCall, Aborted, Error, } @@ -72,14 +73,17 @@ pub enum AssistantMessageEvent { ThinkingEnd { partial: AssistantMessage, }, - ToolcallStart { + #[serde(rename = "functioncall_start", alias = "toolcall_start")] + FunctioncallStart { partial: AssistantMessage, }, - ToolcallDelta { + #[serde(rename = "functioncall_delta", alias = "toolcall_delta")] + FunctioncallDelta { partial: AssistantMessage, delta: String, }, - ToolcallEnd { + #[serde(rename = "functioncall_end", alias = "toolcall_end")] + FunctioncallEnd { partial: AssistantMessage, }, Usage(Usage), diff --git a/provider-anthropic/crates/provider-base/src/iii_register.rs b/provider-anthropic/crates/provider-base/src/iii_register.rs index d3414e9e0..e61e2ff34 100644 --- a/provider-anthropic/crates/provider-base/src/iii_register.rs +++ b/provider-anthropic/crates/provider-base/src/iii_register.rs @@ -21,7 +21,7 @@ use std::sync::Arc; use auth_credentials::Credential; use harness_types::{ - AgentMessage, AgentTool, AssistantMessage, AssistantMessageEvent, ContentBlock, ErrorKind, + AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, ErrorKind, StopReason, TextContent, }; use iii_sdk::{FunctionRef, IIIError, RegisterFunctionMessage, III}; @@ -43,9 +43,9 @@ async fn collect_final( | AssistantMessageEvent::TextStart { partial } | AssistantMessageEvent::TextDelta { partial, .. } | AssistantMessageEvent::TextEnd { partial } - | AssistantMessageEvent::ToolcallStart { partial } - | AssistantMessageEvent::ToolcallDelta { partial, .. } - | AssistantMessageEvent::ToolcallEnd { partial } + | AssistantMessageEvent::FunctioncallStart { partial } + | AssistantMessageEvent::FunctioncallDelta { partial, .. } + | AssistantMessageEvent::FunctioncallEnd { partial } | AssistantMessageEvent::ThinkingStart { partial } | AssistantMessageEvent::ThinkingDelta { partial, .. } | AssistantMessageEvent::ThinkingEnd { partial } => { @@ -92,7 +92,11 @@ where C: Send + Sync + 'static, B: Fn(&str, &Credential) -> Result + Clone + Send + Sync + 'static, BErr: std::fmt::Display + Send + Sync + 'static, - F: Fn(Arc, String, Vec, Vec) -> Fut + Copy + Send + Sync + 'static, + F: Fn(Arc, String, Vec, Vec) -> Fut + + Copy + + Send + + Sync + + 'static, Fut: Future> + Send + 'static, { register_provider_with_id( @@ -115,7 +119,11 @@ where C: Send + Sync + 'static, B: Fn(&str, &Credential) -> Result + Clone + Send + Sync + 'static, BErr: std::fmt::Display + Send + Sync + 'static, - F: Fn(Arc, String, Vec, Vec) -> Fut + Copy + Send + Sync + 'static, + F: Fn(Arc, String, Vec, Vec) -> Fut + + Copy + + Send + + Sync + + 'static, Fut: Future> + Send + 'static, { let description = format!( @@ -147,7 +155,7 @@ where .transpose() .map_err(|e| IIIError::Handler(format!("invalid messages: {e}")))? .unwrap_or_default(); - let tools: Vec = payload + let tools: Vec = payload .get("tools") .cloned() .map(serde_json::from_value) @@ -237,7 +245,7 @@ mod tests { cfg: Arc, _system_prompt: String, _messages: Vec, - _tools: Vec, + _tools: Vec, ) -> ReceiverStream { let (tx, rx) = mpsc::channel(4); tokio::spawn(async move { diff --git a/provider-anthropic/crates/provider-base/src/openai_compat.rs b/provider-anthropic/crates/provider-base/src/openai_compat.rs index 3d79fa1e1..e415aab8e 100644 --- a/provider-anthropic/crates/provider-base/src/openai_compat.rs +++ b/provider-anthropic/crates/provider-base/src/openai_compat.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use bytes::Bytes; use futures::StreamExt; use harness_types::{ - AgentMessage, AgentTool, AssistantMessage, AssistantMessageEvent, ContentBlock, StopReason, - TextContent, ToolCall, Usage, + AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, StopReason, + TextContent, Usage, }; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; @@ -108,15 +108,15 @@ pub fn to_openai_messages( .content .iter() .filter_map(|c| match c { - ContentBlock::ToolCall { + ContentBlock::FunctionCall { id, - name, + function_id, arguments, } => Some(serde_json::json!({ "id": id, "type": "function", "function": { - "name": name, + "name": function_id, "arguments": arguments.to_string(), } })), @@ -132,7 +132,7 @@ pub fn to_openai_messages( } out.push(entry); } - AgentMessage::ToolResult(t) => { + AgentMessage::FunctionResult(t) => { let text: String = t .content .iter() @@ -144,7 +144,7 @@ pub fn to_openai_messages( .join("\n"); out.push(serde_json::json!({ "role": "tool", - "tool_call_id": t.tool_call_id, + "tool_call_id": t.function_call_id, "content": text, })); } @@ -154,9 +154,9 @@ pub fn to_openai_messages( out } -/// Tool definitions in OpenAI wire shape. -pub fn tools_to_openai(tools: &[AgentTool]) -> Vec { - tools +/// Tool definitions in OpenAI wire shape (HTTP field remains `"tools"`). +pub fn functions_to_openai(functions: &[AgentFunction]) -> Vec { + functions .iter() .map(|t| { serde_json::json!({ @@ -176,7 +176,7 @@ pub fn tools_to_openai(tools: &[AgentTool]) -> Vec { pub struct OpenAICompatRequest { pub system_prompt: String, pub messages: Vec, - pub tools: Vec, + pub tools: Vec, } /// Stream a Chat Completions response. Implementations of providers using the @@ -212,7 +212,7 @@ struct PartialState { #[derive(Debug, Default)] struct PartialToolCall { id: String, - name: String, + function_id: String, args_json: String, } @@ -229,7 +229,7 @@ async fn stream_inner( "stream_options": { "include_usage": true }, }); if !request.tools.is_empty() { - body["tools"] = serde_json::Value::Array(tools_to_openai(&request.tools)); + body["tools"] = serde_json::Value::Array(functions_to_openai(&request.tools)); } let client = reqwest::Client::builder() @@ -376,15 +376,15 @@ async fn handle_chunk( } } if let Some(func) = tc.get("function") { - if let Some(name) = func.get("name").and_then(|v| v.as_str()) { - if !name.is_empty() { - entry.name = name.to_string(); + if let Some(fname) = func.get("name").and_then(|v| v.as_str()) { + if !fname.is_empty() { + entry.function_id = fname.to_string(); } } if let Some(args) = func.get("arguments").and_then(|v| v.as_str()) { entry.args_json.push_str(args); let _ = tx - .send(AssistantMessageEvent::ToolcallDelta { + .send(AssistantMessageEvent::FunctioncallDelta { partial: build_partial(state, &cfg.model, &cfg.provider_name), delta: args.to_string(), }) @@ -426,7 +426,7 @@ fn map_finish_reason(reason: &str) -> StopReason { match reason { "stop" => StopReason::End, "length" => StopReason::Length, - "tool_calls" | "function_call" => StopReason::Tool, + "tool_calls" | "function_call" => StopReason::FunctionCall, _ => StopReason::End, } } @@ -439,7 +439,7 @@ fn build_content(state: &PartialState) -> Vec { })); } for tc in &state.tool_calls { - if tc.name.is_empty() { + if tc.function_id.is_empty() { continue; } let args = if tc.args_json.is_empty() { @@ -448,9 +448,9 @@ fn build_content(state: &PartialState) -> Vec { serde_json::from_str::(&tc.args_json) .unwrap_or(serde_json::Value::Null) }; - out.push(ContentBlock::ToolCall { + out.push(ContentBlock::FunctionCall { id: tc.id.clone(), - name: tc.name.clone(), + function_id: tc.function_id.clone(), arguments: args, }); } @@ -475,6 +475,6 @@ fn build_final(state: &PartialState, model: &str, provider: &str) -> AssistantMe } #[allow(dead_code)] -fn _kept(_: ToolCall, _: fn(&str, Option) -> harness_types::ErrorKind) { +fn _kept(_: harness_types::FunctionCall, _: fn(&str, Option) -> harness_types::ErrorKind) { let _ = classify_provider_error; } diff --git a/provider-anthropic/src/lib.rs b/provider-anthropic/src/lib.rs index 23094bf79..b95f8f38b 100644 --- a/provider-anthropic/src/lib.rs +++ b/provider-anthropic/src/lib.rs @@ -136,7 +136,7 @@ pub fn to_wire_messages(messages: &[harness_types::AgentMessage]) -> Vec>(); out.push(serde_json::json!({ "role": "assistant", "content": content })); } - harness_types::AgentMessage::ToolResult(t) => { + harness_types::AgentMessage::FunctionResult(t) => { let text = t .content .iter() @@ -150,7 +150,7 @@ pub fn to_wire_messages(messages: &[harness_types::AgentMessage]) -> Vec Vec Option { match b { ContentBlock::Text(t) => Some(serde_json::json!({ "type": "text", "text": t.text })), - ContentBlock::ToolCall { + ContentBlock::FunctionCall { id, - name, + function_id, arguments, } => Some(serde_json::json!({ "type": "tool_use", "id": id, - "name": name, + "name": encode_tool_name(function_id), "input": arguments, })), _ => None, @@ -192,7 +192,7 @@ pub(crate) fn decode_tool_name(name: &str) -> String { } /// Tool definitions in Anthropic wire shape. -pub fn tools_to_wire(tools: &[harness_types::AgentTool]) -> Vec { +pub fn functions_to_wire(tools: &[harness_types::AgentFunction]) -> Vec { tools .iter() .map(|t| { @@ -211,7 +211,7 @@ pub async fn stream( cfg: Arc, system_prompt: String, messages: Vec, - tools: Vec, + tools: Vec, ) -> ReceiverStream { let (tx, rx) = mpsc::channel(64); tokio::spawn(async move { @@ -240,16 +240,16 @@ pub async fn stream( #[derive(Debug, Default)] struct PartialState { text_blocks: Vec, - tool_calls: Vec, + function_calls: Vec, usage: Usage, stop_reason: Option, error_message: Option, } #[derive(Debug, Default)] -struct PartialToolCall { +struct PartialFunctionCall { id: String, - name: String, + function_id: String, args_json: String, } @@ -257,7 +257,7 @@ async fn stream_inner( cfg: Arc, system_prompt: String, messages: Vec, - tools: Vec, + tools: Vec, tx: mpsc::Sender, ) -> Result<(), AnthropicError> { let body = serde_json::json!({ @@ -265,7 +265,7 @@ async fn stream_inner( "max_tokens": cfg.max_tokens, "system": system_prompt, "messages": to_wire_messages(&messages), - "tools": tools_to_wire(&tools), + "tools": functions_to_wire(&tools), "stream": true, }); @@ -390,18 +390,18 @@ async fn handle_sse_event( .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let name = block + let function_id = block .and_then(|b| b.get("name")) .and_then(|v| v.as_str()) .map(decode_tool_name) .unwrap_or_default(); - state.tool_calls.push(PartialToolCall { + state.function_calls.push(PartialFunctionCall { id, - name, + function_id, args_json: String::new(), }); let _ = tx - .send(AssistantMessageEvent::ToolcallStart { + .send(AssistantMessageEvent::FunctioncallStart { partial: build_partial(state, model), }) .await; @@ -435,11 +435,11 @@ async fn handle_sse_event( .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - if let Some(last) = state.tool_calls.last_mut() { + if let Some(last) = state.function_calls.last_mut() { last.args_json.push_str(&json); } let _ = tx - .send(AssistantMessageEvent::ToolcallDelta { + .send(AssistantMessageEvent::FunctioncallDelta { partial: build_partial(state, model), delta: json, }) @@ -450,7 +450,8 @@ async fn handle_sse_event( } "content_block_stop" => { // Either text or tool — emit the right end event using the most recent block. - if !state.tool_calls.is_empty() && state.text_blocks.last().is_none_or(String::is_empty) + if !state.function_calls.is_empty() + && state.text_blocks.last().is_none_or(String::is_empty) { // tool call just stopped (heuristic; Anthropic guarantees ordering) } @@ -522,7 +523,7 @@ fn map_stop_reason(s: &str) -> StopReason { match s { "end_turn" => StopReason::End, "max_tokens" => StopReason::Length, - "tool_use" => StopReason::Tool, + "tool_use" => StopReason::FunctionCall, "stop_sequence" => StopReason::End, _ => StopReason::End, } @@ -556,16 +557,16 @@ pub(crate) fn build_content(state: &PartialState) -> Vec { })); } } - for tc in &state.tool_calls { + for tc in &state.function_calls { let args = if tc.args_json.is_empty() { serde_json::Value::Object(serde_json::Map::new()) } else { serde_json::from_str::(&tc.args_json) .unwrap_or(serde_json::Value::Null) }; - content.push(ContentBlock::ToolCall { + content.push(ContentBlock::FunctionCall { id: tc.id.clone(), - name: tc.name.clone(), + function_id: tc.function_id.clone(), arguments: args, }); } @@ -608,9 +609,9 @@ pub async fn collect(mut stream: ReceiverStream) -> Assis | AssistantMessageEvent::TextStart { partial } | AssistantMessageEvent::TextDelta { partial, .. } | AssistantMessageEvent::TextEnd { partial } - | AssistantMessageEvent::ToolcallStart { partial } - | AssistantMessageEvent::ToolcallDelta { partial, .. } - | AssistantMessageEvent::ToolcallEnd { partial } + | AssistantMessageEvent::FunctioncallStart { partial } + | AssistantMessageEvent::FunctioncallDelta { partial, .. } + | AssistantMessageEvent::FunctioncallEnd { partial } | AssistantMessageEvent::ThinkingStart { partial } | AssistantMessageEvent::ThinkingDelta { partial, .. } | AssistantMessageEvent::ThinkingEnd { partial } => { @@ -653,14 +654,16 @@ mod tests { #[test] fn tool_result_converts_to_user_with_tool_result_block() { - let msgs = vec![AgentMessage::ToolResult(harness_types::ToolResultMessage { - tool_call_id: "tc1".into(), - tool_name: "read".into(), - content: vec![ContentBlock::Text(TextContent { text: "ok".into() })], - details: serde_json::json!({}), - is_error: false, - timestamp: 2, - })]; + let msgs = vec![AgentMessage::FunctionResult( + harness_types::FunctionResultMessage { + function_call_id: "tc1".into(), + function_id: "read".into(), + content: vec![ContentBlock::Text(TextContent { text: "ok".into() })], + details: serde_json::json!({}), + is_error: false, + timestamp: 2, + }, + )]; let wire = to_wire_messages(&msgs); assert_eq!(wire[0]["role"], "user"); assert_eq!(wire[0]["content"][0]["type"], "tool_result"); @@ -671,7 +674,10 @@ mod tests { fn map_stop_reason_known_values() { assert!(matches!(map_stop_reason("end_turn"), StopReason::End)); assert!(matches!(map_stop_reason("max_tokens"), StopReason::Length)); - assert!(matches!(map_stop_reason("tool_use"), StopReason::Tool)); + assert!(matches!( + map_stop_reason("tool_use"), + StopReason::FunctionCall + )); } #[test] @@ -744,9 +750,9 @@ mod tests { fn build_content_mixed_text_and_tool_partial_state() { let state = PartialState { text_blocks: vec!["hello".into(), String::new(), "world".into()], - tool_calls: vec![PartialToolCall { + function_calls: vec![PartialFunctionCall { id: "tc1".into(), - name: "read".into(), + function_id: "read".into(), args_json: "{\"path\":\"/tmp/x\"}".into(), }], usage: Usage::default(), @@ -765,13 +771,13 @@ mod tests { other => panic!("expected text, got {other:?}"), } match &content[2] { - ContentBlock::ToolCall { + ContentBlock::FunctionCall { id, - name, + function_id, arguments, } => { assert_eq!(id, "tc1"); - assert_eq!(name, "read"); + assert_eq!(function_id, "read"); assert_eq!(arguments["path"], "/tmp/x"); } other => panic!("expected tool call, got {other:?}"), @@ -782,9 +788,9 @@ mod tests { fn build_content_invalid_args_json_falls_back_to_null() { let state = PartialState { text_blocks: vec![], - tool_calls: vec![PartialToolCall { + function_calls: vec![PartialFunctionCall { id: "tc1".into(), - name: "read".into(), + function_id: "read".into(), args_json: "not-json".into(), }], usage: Usage::default(), @@ -794,7 +800,7 @@ mod tests { let content = build_content(&state); assert_eq!(content.len(), 1); match &content[0] { - ContentBlock::ToolCall { arguments, .. } => { + ContentBlock::FunctionCall { arguments, .. } => { assert!(arguments.is_null()); } other => panic!("expected tool call, got {other:?}"), diff --git a/provider-anthropic/tests/integration.rs b/provider-anthropic/tests/integration.rs index 2e2a59da8..0a1e7a34c 100644 --- a/provider-anthropic/tests/integration.rs +++ b/provider-anthropic/tests/integration.rs @@ -36,13 +36,13 @@ fn content_block_to_wire_text_round_trips() { } #[test] -fn content_block_to_wire_tool_call_round_trips() { - let block = ContentBlock::ToolCall { +fn content_block_to_wire_function_call_round_trips() { + let block = ContentBlock::FunctionCall { id: "tc1".into(), - name: "read".into(), + function_id: "read".into(), arguments: serde_json::json!({"path": "/tmp/x"}), }; - let wire = content_block_to_wire(&block).expect("tool_call serializes"); + let wire = content_block_to_wire(&block).expect("function_call serializes"); assert_eq!(wire["type"], "tool_use"); assert_eq!(wire["id"], "tc1"); assert_eq!(wire["name"], "read"); diff --git a/provider-openai/crates/harness-types/src/agent_event.rs b/provider-openai/crates/harness-types/src/agent_event.rs index 8040e33ee..ed74af98a 100644 --- a/provider-openai/crates/harness-types/src/agent_event.rs +++ b/provider-openai/crates/harness-types/src/agent_event.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; -use crate::agent_message::{AgentMessage, ToolResultMessage}; +use crate::agent_message::{AgentMessage, FunctionResultMessage}; +use crate::function::FunctionResult; use crate::stream_event::AssistantMessageEvent; -use crate::tool::ToolResult; /// Outcome of an approval gate. Wire format is the lowercase string /// `"allow"` or `"deny"`; the typed enum prevents constructing illegal values. @@ -23,15 +23,16 @@ pub enum AgentEvent { /// Loop has completed; carries the full message tail produced. AgentEnd { messages: Vec }, - /// One assistant turn (LLM response + any tool calls/results) has begun. + /// One assistant turn (LLM response + any function calls/results) has begun. TurnStart, /// One assistant turn has completed. TurnEnd { message: AgentMessage, - tool_results: Vec, + #[serde(alias = "tool_results")] + function_results: Vec, }, - /// A user, assistant, or tool-result message is about to be added to the transcript. + /// A user, assistant, or function-result message is about to be added to the transcript. MessageStart { message: AgentMessage }, /// Streaming update on the in-flight assistant message. Only emitted while the /// LLM is producing the current response. @@ -42,40 +43,49 @@ pub enum AgentEvent { /// The message is final and committed to the transcript. MessageEnd { message: AgentMessage }, - /// A tool call has been validated and dispatch has begun. - ToolExecutionStart { - tool_call_id: String, - tool_name: String, + /// A function call has been validated and dispatch has begun. + #[serde(rename = "function_execution_start", alias = "tool_execution_start")] + FunctionExecutionStart { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, }, - /// Streaming partial result from a long-running tool. - ToolExecutionUpdate { - tool_call_id: String, - tool_name: String, + /// Streaming partial result from a long-running function. + #[serde(rename = "function_execution_update", alias = "tool_execution_update")] + FunctionExecutionUpdate { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, partial_result: serde_json::Value, }, - /// Tool execution has finished. `result` is post-`after_tool_call` merged. - ToolExecutionEnd { - tool_call_id: String, - tool_name: String, - result: ToolResult, + /// Function execution has finished. `result` is post-`after_function_call` merged. + #[serde(rename = "function_execution_end", alias = "tool_execution_end")] + FunctionExecutionEnd { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, + result: FunctionResult, is_error: bool, }, - /// A tool call is paused by an approval subscriber, awaiting user decision. - /// `tool_call_id`, `tool_name`, and `args` intentionally duplicate the fields on - /// `ToolExecutionStart` so consumers subscribing only to approval events have full - /// context without replaying the rest of the stream. + /// A function call is paused by an approval subscriber, awaiting user decision. ApprovalRequested { - tool_call_id: String, - tool_name: String, + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, /// Unix milliseconds. After this point the gate auto-denies. expires_at: u64, }, /// Approval gate has resolved a previously-requested approval. ApprovalResolved { - tool_call_id: String, + #[serde(alias = "tool_call_id")] + function_call_id: String, decision: ApprovalDecision, /// Free-form reason — populated for "deny" (e.g. "timeout", "user"). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -86,6 +96,7 @@ pub enum AgentEvent { #[cfg(test)] mod tests { use super::*; + use crate::agent_message::UserMessage; #[test] fn agent_start_serialises_with_tag() { @@ -94,28 +105,43 @@ mod tests { } #[test] - fn tool_start_carries_args() { - let ev = AgentEvent::ToolExecutionStart { - tool_call_id: "id".into(), - tool_name: "read".into(), + fn function_start_carries_args() { + let ev = AgentEvent::FunctionExecutionStart { + function_call_id: "id".into(), + function_id: "read".into(), args: serde_json::json!({ "path": "/x" }), }; let json = serde_json::to_string(&ev).unwrap(); + assert!(json.contains("function_execution_start")); let back: AgentEvent = serde_json::from_str(&json).unwrap(); assert_eq!(ev, back); } + #[test] + fn function_execution_start_legacy_type_deserializes() { + let json = r#"{"type":"tool_execution_start","tool_call_id":"id","tool_name":"read","args":{"path":"/x"}}"#; + let back: AgentEvent = serde_json::from_str(json).unwrap(); + assert_eq!( + back, + AgentEvent::FunctionExecutionStart { + function_call_id: "id".into(), + function_id: "read".into(), + args: serde_json::json!({ "path": "/x" }), + } + ); + } + #[test] fn approval_requested_round_trips() { let evt = AgentEvent::ApprovalRequested { - tool_call_id: "tc-9".into(), - tool_name: "shell::filesystem::write".into(), + function_call_id: "tc-9".into(), + function_id: "shell::filesystem::write".into(), args: serde_json::json!({ "path": "/tmp/x" }), expires_at: 1_700_000_000_000, }; let json = serde_json::to_value(&evt).unwrap(); assert_eq!(json["type"], "approval_requested"); - assert_eq!(json["tool_call_id"], "tc-9"); + assert_eq!(json["function_call_id"], "tc-9"); let back: AgentEvent = serde_json::from_value(json).unwrap(); assert_eq!(back, evt); } @@ -123,7 +149,7 @@ mod tests { #[test] fn approval_resolved_round_trips_with_optional_reason() { let evt = AgentEvent::ApprovalResolved { - tool_call_id: "tc-9".into(), + function_call_id: "tc-9".into(), decision: ApprovalDecision::Deny, reason: Some("timeout".into()), }; @@ -134,7 +160,7 @@ mod tests { assert_eq!(back, evt); let none_reason = AgentEvent::ApprovalResolved { - tool_call_id: "tc-9".into(), + function_call_id: "tc-9".into(), decision: ApprovalDecision::Allow, reason: None, }; @@ -145,4 +171,19 @@ mod tests { "reason should be omitted when None: {json}" ); } + + #[test] + fn turn_end_legacy_tool_results_field() { + let msg = AgentMessage::User(UserMessage { + content: vec![], + timestamp: 0, + }); + let json = serde_json::json!({ + "type": "turn_end", + "message": msg, + "tool_results": [] + }); + let evt: AgentEvent = serde_json::from_value(json).unwrap(); + assert!(matches!(evt, AgentEvent::TurnEnd { .. })); + } } diff --git a/provider-openai/crates/harness-types/src/agent_message.rs b/provider-openai/crates/harness-types/src/agent_message.rs index c29823068..8cd6af982 100644 --- a/provider-openai/crates/harness-types/src/agent_message.rs +++ b/provider-openai/crates/harness-types/src/agent_message.rs @@ -1,9 +1,9 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; +use crate::function::AgentFunction; use crate::stream_event::{ErrorKind, StopReason, Usage}; use crate::thinking::ThinkingLevel; -use crate::tool::AgentTool; /// Transcript message. Superset of LLM message types plus app-defined custom entries. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -11,7 +11,8 @@ use crate::tool::AgentTool; pub enum AgentMessage { User(UserMessage), Assistant(AssistantMessage), - ToolResult(ToolResultMessage), + #[serde(rename = "function_result", alias = "tool_result")] + FunctionResult(FunctionResultMessage), Custom(CustomMessage), } @@ -37,9 +38,11 @@ pub struct AssistantMessage { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolResultMessage { - pub tool_call_id: String, - pub tool_name: String, +pub struct FunctionResultMessage { + #[serde(alias = "tool_call_id")] + pub function_call_id: String, + #[serde(alias = "tool_name")] + pub function_id: String, pub content: Vec, pub details: serde_json::Value, pub is_error: bool, @@ -64,8 +67,8 @@ pub struct CustomMessage { pub struct AgentContext { pub system_prompt: String, pub messages: Vec, - #[serde(default)] - pub tools: Vec, + #[serde(default, alias = "tools")] + pub functions: Vec, } /// Persisted session state. Lives at `agent::session//state` on iii state. @@ -102,4 +105,15 @@ mod tests { let m: AgentMessage = serde_json::from_str(json).unwrap(); assert!(matches!(m, AgentMessage::User(_))); } + + #[test] + fn function_result_legacy_tool_result_role() { + let json = r#"{"role":"tool_result","function_call_id":"c1","function_id":"x","content":[],"details":{},"is_error":false,"timestamp":0}"#; + // Old persisted shape used tool_call_id / tool_name + let json_old = r#"{"role":"tool_result","tool_call_id":"c1","tool_name":"x","content":[],"details":{},"is_error":false,"timestamp":0}"#; + let m: AgentMessage = serde_json::from_str(json).unwrap(); + let m_old: AgentMessage = serde_json::from_str(json_old).unwrap(); + assert!(matches!(m, AgentMessage::FunctionResult(_))); + assert!(matches!(m_old, AgentMessage::FunctionResult(_))); + } } diff --git a/provider-openai/crates/harness-types/src/content.rs b/provider-openai/crates/harness-types/src/content.rs index da2cb7d01..52e678751 100644 --- a/provider-openai/crates/harness-types/src/content.rs +++ b/provider-openai/crates/harness-types/src/content.rs @@ -2,19 +2,23 @@ use serde::{Deserialize, Serialize}; use crate::thinking::TextSignature; -/// A block of content. Carried by user, assistant, and tool-result messages. +/// A block of content. Carried by user, assistant, and function-result messages. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum ContentBlock { Text(TextContent), Image(ImageContent), - ToolCall { + #[serde(rename = "functionCall", alias = "toolCall")] + FunctionCall { id: String, - name: String, + #[serde(alias = "name")] + function_id: String, arguments: serde_json::Value, }, - ToolResult { - tool_call_id: String, + #[serde(rename = "functionResult", alias = "toolResult")] + FunctionResult { + #[serde(alias = "tool_call_id")] + function_call_id: String, content: Vec, is_error: bool, }, @@ -53,14 +57,29 @@ mod tests { } #[test] - fn tool_call_block_roundtrip() { - let block = ContentBlock::ToolCall { + fn function_call_block_roundtrip() { + let block = ContentBlock::FunctionCall { id: "call_1".into(), - name: "read".into(), + function_id: "read".into(), arguments: serde_json::json!({ "path": "/tmp/x" }), }; let json = serde_json::to_string(&block).unwrap(); let back: ContentBlock = serde_json::from_str(&json).unwrap(); assert_eq!(block, back); } + + #[test] + fn function_call_block_legacy_tool_call_type() { + let json = + r#"{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"/tmp/x"}}"#; + let back: ContentBlock = serde_json::from_str(json).unwrap(); + assert_eq!( + back, + ContentBlock::FunctionCall { + id: "call_1".into(), + function_id: "read".into(), + arguments: serde_json::json!({ "path": "/tmp/x" }), + } + ); + } } diff --git a/session-tree/crates/harness-types/src/tool.rs b/provider-openai/crates/harness-types/src/function.rs similarity index 56% rename from session-tree/crates/harness-types/src/tool.rs rename to provider-openai/crates/harness-types/src/function.rs index 92c29df98..5620a04ec 100644 --- a/session-tree/crates/harness-types/src/tool.rs +++ b/provider-openai/crates/harness-types/src/function.rs @@ -2,9 +2,9 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; -/// A tool definition advertised to the model. +/// A function slot advertised to the model (harness uses `agent_call`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentTool { +pub struct AgentFunction { pub name: String, pub description: String, /// JSON schema for the parameters object. @@ -16,14 +16,14 @@ pub struct AgentTool { pub prepare_arguments_supported: bool, } -/// How tool calls in a single assistant message are scheduled. +/// How function calls in a single assistant message are scheduled. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ExecutionMode { - /// Tool calls run concurrently. Default. + /// Calls run concurrently. Default. #[default] Parallel, - /// Tool calls run one at a time. Any tool flagged sequential forces the + /// Calls run one at a time. Any call flagged sequential forces the /// whole batch to run sequentially. Sequential, } @@ -46,45 +46,48 @@ pub enum CacheRetention { Long, } -/// A single tool-call request emitted by an assistant message. +/// A single function-call request emitted by an assistant message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolCall { +pub struct FunctionCall { pub id: String, - pub name: String, + /// iii function id (e.g. `shell::filesystem::ls`). + #[serde(alias = "name")] + pub function_id: String, pub arguments: serde_json::Value, } -/// Result of executing a tool. `terminate` is a hint that the loop should end -/// after the current batch; honored only when EVERY tool in the batch sets it. +/// Result of executing a function. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolResult { +pub struct FunctionResult { pub content: Vec, pub details: serde_json::Value, #[serde(default)] pub terminate: bool, } -/// Outcome of `prepare_tool`. Either ready to execute, or short-circuited by -/// validation failure or a `before_tool_call` block. +/// Outcome of prepare. Either ready to execute, or short-circuited. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] -pub enum PreparedToolCall { +pub enum PreparedFunctionCall { Prepared { - tool_call: ToolCall, - tool: AgentTool, + #[serde(alias = "tool_call")] + function_call: FunctionCall, + #[serde(alias = "tool")] + function: AgentFunction, args: serde_json::Value, }, Immediate { - result: ToolResult, + result: FunctionResult, is_error: bool, }, } -/// Tool call after `after_tool_call` subscribers have run and merged results. +/// After `after_function_call` subscribers have merged results. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FinalizedToolCall { - pub tool_call: ToolCall, - pub result: ToolResult, +pub struct FinalizedFunctionCall { + #[serde(alias = "tool_call")] + pub function_call: FunctionCall, + pub result: FunctionResult, pub is_error: bool, } @@ -98,14 +101,21 @@ mod tests { } #[test] - fn tool_call_roundtrip() { - let call = ToolCall { + fn function_call_roundtrip() { + let call = FunctionCall { id: "x".into(), - name: "read".into(), + function_id: "read".into(), arguments: serde_json::json!({}), }; let json = serde_json::to_string(&call).unwrap(); - let back: ToolCall = serde_json::from_str(&json).unwrap(); + let back: FunctionCall = serde_json::from_str(&json).unwrap(); assert_eq!(call, back); } + + #[test] + fn function_call_deserializes_legacy_name_field() { + let json = r#"{"id":"x","name":"shell::filesystem::ls","arguments":{}}"#; + let call: FunctionCall = serde_json::from_str(json).unwrap(); + assert_eq!(call.function_id, "shell::filesystem::ls"); + } } diff --git a/provider-openai/crates/harness-types/src/lib.rs b/provider-openai/crates/harness-types/src/lib.rs index 7ec421cbc..904fccbba 100644 --- a/provider-openai/crates/harness-types/src/lib.rs +++ b/provider-openai/crates/harness-types/src/lib.rs @@ -6,19 +6,19 @@ mod agent_event; mod agent_message; mod content; +mod function; mod stream_event; mod thinking; -mod tool; pub use agent_event::{AgentEvent, ApprovalDecision}; pub use agent_message::{ AgentContext, AgentMessage, AgentSessionState, AssistantMessage, CustomMessage, - ToolResultMessage, UserMessage, + FunctionResultMessage, UserMessage, }; pub use content::{ContentBlock, ImageContent, TextContent}; +pub use function::{ + AgentFunction, CacheRetention, ExecutionMode, FinalizedFunctionCall, FunctionCall, + FunctionResult, PreparedFunctionCall, Transport, +}; pub use stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; -pub use tool::{ - AgentTool, CacheRetention, ExecutionMode, FinalizedToolCall, PreparedToolCall, ToolCall, - ToolResult, Transport, -}; diff --git a/provider-openai/crates/harness-types/src/stream_event.rs b/provider-openai/crates/harness-types/src/stream_event.rs index 729e39ade..dd0db8961 100644 --- a/provider-openai/crates/harness-types/src/stream_event.rs +++ b/provider-openai/crates/harness-types/src/stream_event.rs @@ -8,7 +8,8 @@ use crate::agent_message::AssistantMessage; pub enum StopReason { End, Length, - Tool, + #[serde(rename = "function_call", alias = "tool")] + FunctionCall, Aborted, Error, } @@ -72,14 +73,17 @@ pub enum AssistantMessageEvent { ThinkingEnd { partial: AssistantMessage, }, - ToolcallStart { + #[serde(rename = "functioncall_start", alias = "toolcall_start")] + FunctioncallStart { partial: AssistantMessage, }, - ToolcallDelta { + #[serde(rename = "functioncall_delta", alias = "toolcall_delta")] + FunctioncallDelta { partial: AssistantMessage, delta: String, }, - ToolcallEnd { + #[serde(rename = "functioncall_end", alias = "toolcall_end")] + FunctioncallEnd { partial: AssistantMessage, }, Usage(Usage), diff --git a/provider-openai/crates/provider-base/src/iii_register.rs b/provider-openai/crates/provider-base/src/iii_register.rs index d3414e9e0..e61e2ff34 100644 --- a/provider-openai/crates/provider-base/src/iii_register.rs +++ b/provider-openai/crates/provider-base/src/iii_register.rs @@ -21,7 +21,7 @@ use std::sync::Arc; use auth_credentials::Credential; use harness_types::{ - AgentMessage, AgentTool, AssistantMessage, AssistantMessageEvent, ContentBlock, ErrorKind, + AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, ErrorKind, StopReason, TextContent, }; use iii_sdk::{FunctionRef, IIIError, RegisterFunctionMessage, III}; @@ -43,9 +43,9 @@ async fn collect_final( | AssistantMessageEvent::TextStart { partial } | AssistantMessageEvent::TextDelta { partial, .. } | AssistantMessageEvent::TextEnd { partial } - | AssistantMessageEvent::ToolcallStart { partial } - | AssistantMessageEvent::ToolcallDelta { partial, .. } - | AssistantMessageEvent::ToolcallEnd { partial } + | AssistantMessageEvent::FunctioncallStart { partial } + | AssistantMessageEvent::FunctioncallDelta { partial, .. } + | AssistantMessageEvent::FunctioncallEnd { partial } | AssistantMessageEvent::ThinkingStart { partial } | AssistantMessageEvent::ThinkingDelta { partial, .. } | AssistantMessageEvent::ThinkingEnd { partial } => { @@ -92,7 +92,11 @@ where C: Send + Sync + 'static, B: Fn(&str, &Credential) -> Result + Clone + Send + Sync + 'static, BErr: std::fmt::Display + Send + Sync + 'static, - F: Fn(Arc, String, Vec, Vec) -> Fut + Copy + Send + Sync + 'static, + F: Fn(Arc, String, Vec, Vec) -> Fut + + Copy + + Send + + Sync + + 'static, Fut: Future> + Send + 'static, { register_provider_with_id( @@ -115,7 +119,11 @@ where C: Send + Sync + 'static, B: Fn(&str, &Credential) -> Result + Clone + Send + Sync + 'static, BErr: std::fmt::Display + Send + Sync + 'static, - F: Fn(Arc, String, Vec, Vec) -> Fut + Copy + Send + Sync + 'static, + F: Fn(Arc, String, Vec, Vec) -> Fut + + Copy + + Send + + Sync + + 'static, Fut: Future> + Send + 'static, { let description = format!( @@ -147,7 +155,7 @@ where .transpose() .map_err(|e| IIIError::Handler(format!("invalid messages: {e}")))? .unwrap_or_default(); - let tools: Vec = payload + let tools: Vec = payload .get("tools") .cloned() .map(serde_json::from_value) @@ -237,7 +245,7 @@ mod tests { cfg: Arc, _system_prompt: String, _messages: Vec, - _tools: Vec, + _tools: Vec, ) -> ReceiverStream { let (tx, rx) = mpsc::channel(4); tokio::spawn(async move { diff --git a/provider-openai/crates/provider-base/src/openai_compat.rs b/provider-openai/crates/provider-base/src/openai_compat.rs index 851d965de..4253681d0 100644 --- a/provider-openai/crates/provider-base/src/openai_compat.rs +++ b/provider-openai/crates/provider-base/src/openai_compat.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use bytes::Bytes; use futures::StreamExt; use harness_types::{ - AgentMessage, AgentTool, AssistantMessage, AssistantMessageEvent, ContentBlock, StopReason, - TextContent, ToolCall, Usage, + AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, StopReason, + TextContent, Usage, }; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; @@ -108,15 +108,15 @@ pub fn to_openai_messages( .content .iter() .filter_map(|c| match c { - ContentBlock::ToolCall { + ContentBlock::FunctionCall { id, - name, + function_id, arguments, } => Some(serde_json::json!({ "id": id, "type": "function", "function": { - "name": name, + "name": function_id, "arguments": arguments.to_string(), } })), @@ -132,7 +132,7 @@ pub fn to_openai_messages( } out.push(entry); } - AgentMessage::ToolResult(t) => { + AgentMessage::FunctionResult(t) => { let text: String = t .content .iter() @@ -144,7 +144,7 @@ pub fn to_openai_messages( .join("\n"); out.push(serde_json::json!({ "role": "tool", - "tool_call_id": t.tool_call_id, + "tool_call_id": t.function_call_id, "content": text, })); } @@ -154,9 +154,9 @@ pub fn to_openai_messages( out } -/// Tool definitions in OpenAI wire shape. -pub fn tools_to_openai(tools: &[AgentTool]) -> Vec { - tools +/// Tool definitions in OpenAI wire shape (HTTP field remains `"tools"`). +pub fn functions_to_openai(functions: &[AgentFunction]) -> Vec { + functions .iter() .map(|t| { serde_json::json!({ @@ -176,7 +176,7 @@ pub fn tools_to_openai(tools: &[AgentTool]) -> Vec { pub struct OpenAICompatRequest { pub system_prompt: String, pub messages: Vec, - pub tools: Vec, + pub tools: Vec, } /// Stream a Chat Completions response. Implementations of providers using the @@ -212,7 +212,7 @@ struct PartialState { #[derive(Debug, Default)] struct PartialToolCall { id: String, - name: String, + function_id: String, args_json: String, } @@ -229,7 +229,7 @@ async fn stream_inner( "stream_options": { "include_usage": true }, }); if !request.tools.is_empty() { - body["tools"] = serde_json::Value::Array(tools_to_openai(&request.tools)); + body["tools"] = serde_json::Value::Array(functions_to_openai(&request.tools)); } let client = reqwest::Client::builder() @@ -376,15 +376,15 @@ async fn handle_chunk( } } if let Some(func) = tc.get("function") { - if let Some(name) = func.get("name").and_then(|v| v.as_str()) { - if !name.is_empty() { - entry.name = name.to_string(); + if let Some(fname) = func.get("name").and_then(|v| v.as_str()) { + if !fname.is_empty() { + entry.function_id = fname.to_string(); } } if let Some(args) = func.get("arguments").and_then(|v| v.as_str()) { entry.args_json.push_str(args); let _ = tx - .send(AssistantMessageEvent::ToolcallDelta { + .send(AssistantMessageEvent::FunctioncallDelta { partial: build_partial(state, &cfg.model, &cfg.provider_name), delta: args.to_string(), }) @@ -426,7 +426,7 @@ fn map_finish_reason(reason: &str) -> StopReason { match reason { "stop" => StopReason::End, "length" => StopReason::Length, - "tool_calls" | "function_call" => StopReason::Tool, + "tool_calls" | "function_call" => StopReason::FunctionCall, _ => StopReason::End, } } @@ -439,7 +439,7 @@ fn build_content(state: &PartialState) -> Vec { })); } for tc in &state.tool_calls { - if tc.name.is_empty() { + if tc.function_id.is_empty() { continue; } let args = if tc.args_json.is_empty() { @@ -448,9 +448,9 @@ fn build_content(state: &PartialState) -> Vec { serde_json::from_str::(&tc.args_json) .unwrap_or(serde_json::Value::Null) }; - out.push(ContentBlock::ToolCall { + out.push(ContentBlock::FunctionCall { id: tc.id.clone(), - name: tc.name.clone(), + function_id: tc.function_id.clone(), arguments: args, }); } @@ -475,6 +475,6 @@ fn build_final(state: &PartialState, model: &str, provider: &str) -> AssistantMe } #[allow(dead_code)] -fn _kept(_: ToolCall, _: fn(&str, Option) -> harness_types::ErrorKind) { +fn _kept(_: harness_types::FunctionCall, _: fn(&str, Option) -> harness_types::ErrorKind) { let _ = classify_provider_error; } diff --git a/provider-openai/src/lib.rs b/provider-openai/src/lib.rs index 3bd042f28..17c2cb93a 100644 --- a/provider-openai/src/lib.rs +++ b/provider-openai/src/lib.rs @@ -12,7 +12,7 @@ pub mod config; use std::sync::Arc; use harness_types::{ - AgentMessage, AgentTool, AssistantMessage, AssistantMessageEvent, ContentBlock, ErrorKind, + AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, ErrorKind, StopReason, }; use provider_base::{stream_chat_completions, ChatCompletionsConfig, OpenAICompatRequest}; @@ -87,7 +87,7 @@ pub async fn stream( cfg: Arc, system_prompt: String, messages: Vec, - tools: Vec, + tools: Vec, ) -> ReceiverStream { let base_cfg = Arc::new( ChatCompletionsConfig::new( @@ -141,9 +141,9 @@ pub async fn collect(mut stream: ReceiverStream) -> Assis | AssistantMessageEvent::TextStart { partial } | AssistantMessageEvent::TextDelta { partial, .. } | AssistantMessageEvent::TextEnd { partial } - | AssistantMessageEvent::ToolcallStart { partial } - | AssistantMessageEvent::ToolcallDelta { partial, .. } - | AssistantMessageEvent::ToolcallEnd { partial } + | AssistantMessageEvent::FunctioncallStart { partial } + | AssistantMessageEvent::FunctioncallDelta { partial, .. } + | AssistantMessageEvent::FunctioncallEnd { partial } | AssistantMessageEvent::ThinkingStart { partial } | AssistantMessageEvent::ThinkingDelta { partial, .. } | AssistantMessageEvent::ThinkingEnd { partial } => { diff --git a/provider-router/crates/harness-types/src/agent_event.rs b/provider-router/crates/harness-types/src/agent_event.rs index 8040e33ee..ed74af98a 100644 --- a/provider-router/crates/harness-types/src/agent_event.rs +++ b/provider-router/crates/harness-types/src/agent_event.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; -use crate::agent_message::{AgentMessage, ToolResultMessage}; +use crate::agent_message::{AgentMessage, FunctionResultMessage}; +use crate::function::FunctionResult; use crate::stream_event::AssistantMessageEvent; -use crate::tool::ToolResult; /// Outcome of an approval gate. Wire format is the lowercase string /// `"allow"` or `"deny"`; the typed enum prevents constructing illegal values. @@ -23,15 +23,16 @@ pub enum AgentEvent { /// Loop has completed; carries the full message tail produced. AgentEnd { messages: Vec }, - /// One assistant turn (LLM response + any tool calls/results) has begun. + /// One assistant turn (LLM response + any function calls/results) has begun. TurnStart, /// One assistant turn has completed. TurnEnd { message: AgentMessage, - tool_results: Vec, + #[serde(alias = "tool_results")] + function_results: Vec, }, - /// A user, assistant, or tool-result message is about to be added to the transcript. + /// A user, assistant, or function-result message is about to be added to the transcript. MessageStart { message: AgentMessage }, /// Streaming update on the in-flight assistant message. Only emitted while the /// LLM is producing the current response. @@ -42,40 +43,49 @@ pub enum AgentEvent { /// The message is final and committed to the transcript. MessageEnd { message: AgentMessage }, - /// A tool call has been validated and dispatch has begun. - ToolExecutionStart { - tool_call_id: String, - tool_name: String, + /// A function call has been validated and dispatch has begun. + #[serde(rename = "function_execution_start", alias = "tool_execution_start")] + FunctionExecutionStart { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, }, - /// Streaming partial result from a long-running tool. - ToolExecutionUpdate { - tool_call_id: String, - tool_name: String, + /// Streaming partial result from a long-running function. + #[serde(rename = "function_execution_update", alias = "tool_execution_update")] + FunctionExecutionUpdate { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, partial_result: serde_json::Value, }, - /// Tool execution has finished. `result` is post-`after_tool_call` merged. - ToolExecutionEnd { - tool_call_id: String, - tool_name: String, - result: ToolResult, + /// Function execution has finished. `result` is post-`after_function_call` merged. + #[serde(rename = "function_execution_end", alias = "tool_execution_end")] + FunctionExecutionEnd { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, + result: FunctionResult, is_error: bool, }, - /// A tool call is paused by an approval subscriber, awaiting user decision. - /// `tool_call_id`, `tool_name`, and `args` intentionally duplicate the fields on - /// `ToolExecutionStart` so consumers subscribing only to approval events have full - /// context without replaying the rest of the stream. + /// A function call is paused by an approval subscriber, awaiting user decision. ApprovalRequested { - tool_call_id: String, - tool_name: String, + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, /// Unix milliseconds. After this point the gate auto-denies. expires_at: u64, }, /// Approval gate has resolved a previously-requested approval. ApprovalResolved { - tool_call_id: String, + #[serde(alias = "tool_call_id")] + function_call_id: String, decision: ApprovalDecision, /// Free-form reason — populated for "deny" (e.g. "timeout", "user"). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -86,6 +96,7 @@ pub enum AgentEvent { #[cfg(test)] mod tests { use super::*; + use crate::agent_message::UserMessage; #[test] fn agent_start_serialises_with_tag() { @@ -94,28 +105,43 @@ mod tests { } #[test] - fn tool_start_carries_args() { - let ev = AgentEvent::ToolExecutionStart { - tool_call_id: "id".into(), - tool_name: "read".into(), + fn function_start_carries_args() { + let ev = AgentEvent::FunctionExecutionStart { + function_call_id: "id".into(), + function_id: "read".into(), args: serde_json::json!({ "path": "/x" }), }; let json = serde_json::to_string(&ev).unwrap(); + assert!(json.contains("function_execution_start")); let back: AgentEvent = serde_json::from_str(&json).unwrap(); assert_eq!(ev, back); } + #[test] + fn function_execution_start_legacy_type_deserializes() { + let json = r#"{"type":"tool_execution_start","tool_call_id":"id","tool_name":"read","args":{"path":"/x"}}"#; + let back: AgentEvent = serde_json::from_str(json).unwrap(); + assert_eq!( + back, + AgentEvent::FunctionExecutionStart { + function_call_id: "id".into(), + function_id: "read".into(), + args: serde_json::json!({ "path": "/x" }), + } + ); + } + #[test] fn approval_requested_round_trips() { let evt = AgentEvent::ApprovalRequested { - tool_call_id: "tc-9".into(), - tool_name: "shell::filesystem::write".into(), + function_call_id: "tc-9".into(), + function_id: "shell::filesystem::write".into(), args: serde_json::json!({ "path": "/tmp/x" }), expires_at: 1_700_000_000_000, }; let json = serde_json::to_value(&evt).unwrap(); assert_eq!(json["type"], "approval_requested"); - assert_eq!(json["tool_call_id"], "tc-9"); + assert_eq!(json["function_call_id"], "tc-9"); let back: AgentEvent = serde_json::from_value(json).unwrap(); assert_eq!(back, evt); } @@ -123,7 +149,7 @@ mod tests { #[test] fn approval_resolved_round_trips_with_optional_reason() { let evt = AgentEvent::ApprovalResolved { - tool_call_id: "tc-9".into(), + function_call_id: "tc-9".into(), decision: ApprovalDecision::Deny, reason: Some("timeout".into()), }; @@ -134,7 +160,7 @@ mod tests { assert_eq!(back, evt); let none_reason = AgentEvent::ApprovalResolved { - tool_call_id: "tc-9".into(), + function_call_id: "tc-9".into(), decision: ApprovalDecision::Allow, reason: None, }; @@ -145,4 +171,19 @@ mod tests { "reason should be omitted when None: {json}" ); } + + #[test] + fn turn_end_legacy_tool_results_field() { + let msg = AgentMessage::User(UserMessage { + content: vec![], + timestamp: 0, + }); + let json = serde_json::json!({ + "type": "turn_end", + "message": msg, + "tool_results": [] + }); + let evt: AgentEvent = serde_json::from_value(json).unwrap(); + assert!(matches!(evt, AgentEvent::TurnEnd { .. })); + } } diff --git a/provider-router/crates/harness-types/src/agent_message.rs b/provider-router/crates/harness-types/src/agent_message.rs index c29823068..8cd6af982 100644 --- a/provider-router/crates/harness-types/src/agent_message.rs +++ b/provider-router/crates/harness-types/src/agent_message.rs @@ -1,9 +1,9 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; +use crate::function::AgentFunction; use crate::stream_event::{ErrorKind, StopReason, Usage}; use crate::thinking::ThinkingLevel; -use crate::tool::AgentTool; /// Transcript message. Superset of LLM message types plus app-defined custom entries. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -11,7 +11,8 @@ use crate::tool::AgentTool; pub enum AgentMessage { User(UserMessage), Assistant(AssistantMessage), - ToolResult(ToolResultMessage), + #[serde(rename = "function_result", alias = "tool_result")] + FunctionResult(FunctionResultMessage), Custom(CustomMessage), } @@ -37,9 +38,11 @@ pub struct AssistantMessage { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolResultMessage { - pub tool_call_id: String, - pub tool_name: String, +pub struct FunctionResultMessage { + #[serde(alias = "tool_call_id")] + pub function_call_id: String, + #[serde(alias = "tool_name")] + pub function_id: String, pub content: Vec, pub details: serde_json::Value, pub is_error: bool, @@ -64,8 +67,8 @@ pub struct CustomMessage { pub struct AgentContext { pub system_prompt: String, pub messages: Vec, - #[serde(default)] - pub tools: Vec, + #[serde(default, alias = "tools")] + pub functions: Vec, } /// Persisted session state. Lives at `agent::session//state` on iii state. @@ -102,4 +105,15 @@ mod tests { let m: AgentMessage = serde_json::from_str(json).unwrap(); assert!(matches!(m, AgentMessage::User(_))); } + + #[test] + fn function_result_legacy_tool_result_role() { + let json = r#"{"role":"tool_result","function_call_id":"c1","function_id":"x","content":[],"details":{},"is_error":false,"timestamp":0}"#; + // Old persisted shape used tool_call_id / tool_name + let json_old = r#"{"role":"tool_result","tool_call_id":"c1","tool_name":"x","content":[],"details":{},"is_error":false,"timestamp":0}"#; + let m: AgentMessage = serde_json::from_str(json).unwrap(); + let m_old: AgentMessage = serde_json::from_str(json_old).unwrap(); + assert!(matches!(m, AgentMessage::FunctionResult(_))); + assert!(matches!(m_old, AgentMessage::FunctionResult(_))); + } } diff --git a/provider-router/crates/harness-types/src/content.rs b/provider-router/crates/harness-types/src/content.rs index da2cb7d01..52e678751 100644 --- a/provider-router/crates/harness-types/src/content.rs +++ b/provider-router/crates/harness-types/src/content.rs @@ -2,19 +2,23 @@ use serde::{Deserialize, Serialize}; use crate::thinking::TextSignature; -/// A block of content. Carried by user, assistant, and tool-result messages. +/// A block of content. Carried by user, assistant, and function-result messages. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum ContentBlock { Text(TextContent), Image(ImageContent), - ToolCall { + #[serde(rename = "functionCall", alias = "toolCall")] + FunctionCall { id: String, - name: String, + #[serde(alias = "name")] + function_id: String, arguments: serde_json::Value, }, - ToolResult { - tool_call_id: String, + #[serde(rename = "functionResult", alias = "toolResult")] + FunctionResult { + #[serde(alias = "tool_call_id")] + function_call_id: String, content: Vec, is_error: bool, }, @@ -53,14 +57,29 @@ mod tests { } #[test] - fn tool_call_block_roundtrip() { - let block = ContentBlock::ToolCall { + fn function_call_block_roundtrip() { + let block = ContentBlock::FunctionCall { id: "call_1".into(), - name: "read".into(), + function_id: "read".into(), arguments: serde_json::json!({ "path": "/tmp/x" }), }; let json = serde_json::to_string(&block).unwrap(); let back: ContentBlock = serde_json::from_str(&json).unwrap(); assert_eq!(block, back); } + + #[test] + fn function_call_block_legacy_tool_call_type() { + let json = + r#"{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"/tmp/x"}}"#; + let back: ContentBlock = serde_json::from_str(json).unwrap(); + assert_eq!( + back, + ContentBlock::FunctionCall { + id: "call_1".into(), + function_id: "read".into(), + arguments: serde_json::json!({ "path": "/tmp/x" }), + } + ); + } } diff --git a/provider-anthropic/crates/harness-types/src/tool.rs b/provider-router/crates/harness-types/src/function.rs similarity index 56% rename from provider-anthropic/crates/harness-types/src/tool.rs rename to provider-router/crates/harness-types/src/function.rs index 92c29df98..5620a04ec 100644 --- a/provider-anthropic/crates/harness-types/src/tool.rs +++ b/provider-router/crates/harness-types/src/function.rs @@ -2,9 +2,9 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; -/// A tool definition advertised to the model. +/// A function slot advertised to the model (harness uses `agent_call`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentTool { +pub struct AgentFunction { pub name: String, pub description: String, /// JSON schema for the parameters object. @@ -16,14 +16,14 @@ pub struct AgentTool { pub prepare_arguments_supported: bool, } -/// How tool calls in a single assistant message are scheduled. +/// How function calls in a single assistant message are scheduled. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ExecutionMode { - /// Tool calls run concurrently. Default. + /// Calls run concurrently. Default. #[default] Parallel, - /// Tool calls run one at a time. Any tool flagged sequential forces the + /// Calls run one at a time. Any call flagged sequential forces the /// whole batch to run sequentially. Sequential, } @@ -46,45 +46,48 @@ pub enum CacheRetention { Long, } -/// A single tool-call request emitted by an assistant message. +/// A single function-call request emitted by an assistant message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolCall { +pub struct FunctionCall { pub id: String, - pub name: String, + /// iii function id (e.g. `shell::filesystem::ls`). + #[serde(alias = "name")] + pub function_id: String, pub arguments: serde_json::Value, } -/// Result of executing a tool. `terminate` is a hint that the loop should end -/// after the current batch; honored only when EVERY tool in the batch sets it. +/// Result of executing a function. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolResult { +pub struct FunctionResult { pub content: Vec, pub details: serde_json::Value, #[serde(default)] pub terminate: bool, } -/// Outcome of `prepare_tool`. Either ready to execute, or short-circuited by -/// validation failure or a `before_tool_call` block. +/// Outcome of prepare. Either ready to execute, or short-circuited. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] -pub enum PreparedToolCall { +pub enum PreparedFunctionCall { Prepared { - tool_call: ToolCall, - tool: AgentTool, + #[serde(alias = "tool_call")] + function_call: FunctionCall, + #[serde(alias = "tool")] + function: AgentFunction, args: serde_json::Value, }, Immediate { - result: ToolResult, + result: FunctionResult, is_error: bool, }, } -/// Tool call after `after_tool_call` subscribers have run and merged results. +/// After `after_function_call` subscribers have merged results. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FinalizedToolCall { - pub tool_call: ToolCall, - pub result: ToolResult, +pub struct FinalizedFunctionCall { + #[serde(alias = "tool_call")] + pub function_call: FunctionCall, + pub result: FunctionResult, pub is_error: bool, } @@ -98,14 +101,21 @@ mod tests { } #[test] - fn tool_call_roundtrip() { - let call = ToolCall { + fn function_call_roundtrip() { + let call = FunctionCall { id: "x".into(), - name: "read".into(), + function_id: "read".into(), arguments: serde_json::json!({}), }; let json = serde_json::to_string(&call).unwrap(); - let back: ToolCall = serde_json::from_str(&json).unwrap(); + let back: FunctionCall = serde_json::from_str(&json).unwrap(); assert_eq!(call, back); } + + #[test] + fn function_call_deserializes_legacy_name_field() { + let json = r#"{"id":"x","name":"shell::filesystem::ls","arguments":{}}"#; + let call: FunctionCall = serde_json::from_str(json).unwrap(); + assert_eq!(call.function_id, "shell::filesystem::ls"); + } } diff --git a/provider-router/crates/harness-types/src/lib.rs b/provider-router/crates/harness-types/src/lib.rs index 7ec421cbc..904fccbba 100644 --- a/provider-router/crates/harness-types/src/lib.rs +++ b/provider-router/crates/harness-types/src/lib.rs @@ -6,19 +6,19 @@ mod agent_event; mod agent_message; mod content; +mod function; mod stream_event; mod thinking; -mod tool; pub use agent_event::{AgentEvent, ApprovalDecision}; pub use agent_message::{ AgentContext, AgentMessage, AgentSessionState, AssistantMessage, CustomMessage, - ToolResultMessage, UserMessage, + FunctionResultMessage, UserMessage, }; pub use content::{ContentBlock, ImageContent, TextContent}; +pub use function::{ + AgentFunction, CacheRetention, ExecutionMode, FinalizedFunctionCall, FunctionCall, + FunctionResult, PreparedFunctionCall, Transport, +}; pub use stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; -pub use tool::{ - AgentTool, CacheRetention, ExecutionMode, FinalizedToolCall, PreparedToolCall, ToolCall, - ToolResult, Transport, -}; diff --git a/provider-router/crates/harness-types/src/stream_event.rs b/provider-router/crates/harness-types/src/stream_event.rs index 729e39ade..dd0db8961 100644 --- a/provider-router/crates/harness-types/src/stream_event.rs +++ b/provider-router/crates/harness-types/src/stream_event.rs @@ -8,7 +8,8 @@ use crate::agent_message::AssistantMessage; pub enum StopReason { End, Length, - Tool, + #[serde(rename = "function_call", alias = "tool")] + FunctionCall, Aborted, Error, } @@ -72,14 +73,17 @@ pub enum AssistantMessageEvent { ThinkingEnd { partial: AssistantMessage, }, - ToolcallStart { + #[serde(rename = "functioncall_start", alias = "toolcall_start")] + FunctioncallStart { partial: AssistantMessage, }, - ToolcallDelta { + #[serde(rename = "functioncall_delta", alias = "toolcall_delta")] + FunctioncallDelta { partial: AssistantMessage, delta: String, }, - ToolcallEnd { + #[serde(rename = "functioncall_end", alias = "toolcall_end")] + FunctioncallEnd { partial: AssistantMessage, }, Usage(Usage), diff --git a/provider-router/src/register.rs b/provider-router/src/register.rs index df880d1e3..5bbb6393f 100644 --- a/provider-router/src/register.rs +++ b/provider-router/src/register.rs @@ -29,8 +29,8 @@ pub const EVENTS_STREAM: &str = "agent::events"; pub const STATE_SCOPE: &str = "agent"; /// Hook topic ids. -pub const TOPIC_BEFORE: &str = "agent::before_tool_call"; -pub const TOPIC_AFTER: &str = "agent::after_tool_call"; +pub const TOPIC_BEFORE: &str = "agent::before_function_call"; +pub const TOPIC_AFTER: &str = "agent::after_function_call"; async fn list_function_infos(iii: &III) -> Result, String> { let value = iii @@ -58,7 +58,7 @@ async fn list_function_infos(iii: &III) -> Result, St /// conventions for grep, not categories; the engine treats every id the /// same. The eight LLM-callable builtins (`read`, `write`, `edit`, `ls`, /// `grep`, `find`, `bash`, `run_subagent`) register under the same name -/// the LLM emits in `ContentBlock::ToolCall { name }`. The agent loop +/// the LLM emits in `ContentBlock::FunctionCall { function_id }`. The agent loop /// dispatches via `iii.trigger(name, payload)` directly; no prefix /// mapping, no wrapper. /// @@ -502,8 +502,8 @@ mod tests { #[test] fn constants_match_architecture_spec() { assert_eq!(STATE_SCOPE, "agent"); - assert_eq!(TOPIC_BEFORE, "agent::before_tool_call"); - assert_eq!(TOPIC_AFTER, "agent::after_tool_call"); + assert_eq!(TOPIC_BEFORE, "agent::before_function_call"); + assert_eq!(TOPIC_AFTER, "agent::after_function_call"); assert_eq!(EVENTS_STREAM, "agent::events"); } diff --git a/registry/index.json b/registry/index.json index c0396b8c4..cca59c0fb 100644 --- a/registry/index.json +++ b/registry/index.json @@ -164,7 +164,7 @@ }, "policy-denylist": { "type": "binary", - "description": "Hook subscriber on agent::before_tool_call that blocks calls whose name is on a configured denylist", + "description": "Hook subscriber on agent::before_function_call that blocks calls whose function id is on a configured denylist", "repo": "iii-hq/workers", "tag_prefix": "policy-denylist", "supported_targets": [ diff --git a/session-tree/crates/harness-types/src/agent_event.rs b/session-tree/crates/harness-types/src/agent_event.rs index 8040e33ee..ed74af98a 100644 --- a/session-tree/crates/harness-types/src/agent_event.rs +++ b/session-tree/crates/harness-types/src/agent_event.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; -use crate::agent_message::{AgentMessage, ToolResultMessage}; +use crate::agent_message::{AgentMessage, FunctionResultMessage}; +use crate::function::FunctionResult; use crate::stream_event::AssistantMessageEvent; -use crate::tool::ToolResult; /// Outcome of an approval gate. Wire format is the lowercase string /// `"allow"` or `"deny"`; the typed enum prevents constructing illegal values. @@ -23,15 +23,16 @@ pub enum AgentEvent { /// Loop has completed; carries the full message tail produced. AgentEnd { messages: Vec }, - /// One assistant turn (LLM response + any tool calls/results) has begun. + /// One assistant turn (LLM response + any function calls/results) has begun. TurnStart, /// One assistant turn has completed. TurnEnd { message: AgentMessage, - tool_results: Vec, + #[serde(alias = "tool_results")] + function_results: Vec, }, - /// A user, assistant, or tool-result message is about to be added to the transcript. + /// A user, assistant, or function-result message is about to be added to the transcript. MessageStart { message: AgentMessage }, /// Streaming update on the in-flight assistant message. Only emitted while the /// LLM is producing the current response. @@ -42,40 +43,49 @@ pub enum AgentEvent { /// The message is final and committed to the transcript. MessageEnd { message: AgentMessage }, - /// A tool call has been validated and dispatch has begun. - ToolExecutionStart { - tool_call_id: String, - tool_name: String, + /// A function call has been validated and dispatch has begun. + #[serde(rename = "function_execution_start", alias = "tool_execution_start")] + FunctionExecutionStart { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, }, - /// Streaming partial result from a long-running tool. - ToolExecutionUpdate { - tool_call_id: String, - tool_name: String, + /// Streaming partial result from a long-running function. + #[serde(rename = "function_execution_update", alias = "tool_execution_update")] + FunctionExecutionUpdate { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, partial_result: serde_json::Value, }, - /// Tool execution has finished. `result` is post-`after_tool_call` merged. - ToolExecutionEnd { - tool_call_id: String, - tool_name: String, - result: ToolResult, + /// Function execution has finished. `result` is post-`after_function_call` merged. + #[serde(rename = "function_execution_end", alias = "tool_execution_end")] + FunctionExecutionEnd { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, + result: FunctionResult, is_error: bool, }, - /// A tool call is paused by an approval subscriber, awaiting user decision. - /// `tool_call_id`, `tool_name`, and `args` intentionally duplicate the fields on - /// `ToolExecutionStart` so consumers subscribing only to approval events have full - /// context without replaying the rest of the stream. + /// A function call is paused by an approval subscriber, awaiting user decision. ApprovalRequested { - tool_call_id: String, - tool_name: String, + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, /// Unix milliseconds. After this point the gate auto-denies. expires_at: u64, }, /// Approval gate has resolved a previously-requested approval. ApprovalResolved { - tool_call_id: String, + #[serde(alias = "tool_call_id")] + function_call_id: String, decision: ApprovalDecision, /// Free-form reason — populated for "deny" (e.g. "timeout", "user"). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -86,6 +96,7 @@ pub enum AgentEvent { #[cfg(test)] mod tests { use super::*; + use crate::agent_message::UserMessage; #[test] fn agent_start_serialises_with_tag() { @@ -94,28 +105,43 @@ mod tests { } #[test] - fn tool_start_carries_args() { - let ev = AgentEvent::ToolExecutionStart { - tool_call_id: "id".into(), - tool_name: "read".into(), + fn function_start_carries_args() { + let ev = AgentEvent::FunctionExecutionStart { + function_call_id: "id".into(), + function_id: "read".into(), args: serde_json::json!({ "path": "/x" }), }; let json = serde_json::to_string(&ev).unwrap(); + assert!(json.contains("function_execution_start")); let back: AgentEvent = serde_json::from_str(&json).unwrap(); assert_eq!(ev, back); } + #[test] + fn function_execution_start_legacy_type_deserializes() { + let json = r#"{"type":"tool_execution_start","tool_call_id":"id","tool_name":"read","args":{"path":"/x"}}"#; + let back: AgentEvent = serde_json::from_str(json).unwrap(); + assert_eq!( + back, + AgentEvent::FunctionExecutionStart { + function_call_id: "id".into(), + function_id: "read".into(), + args: serde_json::json!({ "path": "/x" }), + } + ); + } + #[test] fn approval_requested_round_trips() { let evt = AgentEvent::ApprovalRequested { - tool_call_id: "tc-9".into(), - tool_name: "shell::filesystem::write".into(), + function_call_id: "tc-9".into(), + function_id: "shell::filesystem::write".into(), args: serde_json::json!({ "path": "/tmp/x" }), expires_at: 1_700_000_000_000, }; let json = serde_json::to_value(&evt).unwrap(); assert_eq!(json["type"], "approval_requested"); - assert_eq!(json["tool_call_id"], "tc-9"); + assert_eq!(json["function_call_id"], "tc-9"); let back: AgentEvent = serde_json::from_value(json).unwrap(); assert_eq!(back, evt); } @@ -123,7 +149,7 @@ mod tests { #[test] fn approval_resolved_round_trips_with_optional_reason() { let evt = AgentEvent::ApprovalResolved { - tool_call_id: "tc-9".into(), + function_call_id: "tc-9".into(), decision: ApprovalDecision::Deny, reason: Some("timeout".into()), }; @@ -134,7 +160,7 @@ mod tests { assert_eq!(back, evt); let none_reason = AgentEvent::ApprovalResolved { - tool_call_id: "tc-9".into(), + function_call_id: "tc-9".into(), decision: ApprovalDecision::Allow, reason: None, }; @@ -145,4 +171,19 @@ mod tests { "reason should be omitted when None: {json}" ); } + + #[test] + fn turn_end_legacy_tool_results_field() { + let msg = AgentMessage::User(UserMessage { + content: vec![], + timestamp: 0, + }); + let json = serde_json::json!({ + "type": "turn_end", + "message": msg, + "tool_results": [] + }); + let evt: AgentEvent = serde_json::from_value(json).unwrap(); + assert!(matches!(evt, AgentEvent::TurnEnd { .. })); + } } diff --git a/session-tree/crates/harness-types/src/agent_message.rs b/session-tree/crates/harness-types/src/agent_message.rs index c29823068..8cd6af982 100644 --- a/session-tree/crates/harness-types/src/agent_message.rs +++ b/session-tree/crates/harness-types/src/agent_message.rs @@ -1,9 +1,9 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; +use crate::function::AgentFunction; use crate::stream_event::{ErrorKind, StopReason, Usage}; use crate::thinking::ThinkingLevel; -use crate::tool::AgentTool; /// Transcript message. Superset of LLM message types plus app-defined custom entries. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -11,7 +11,8 @@ use crate::tool::AgentTool; pub enum AgentMessage { User(UserMessage), Assistant(AssistantMessage), - ToolResult(ToolResultMessage), + #[serde(rename = "function_result", alias = "tool_result")] + FunctionResult(FunctionResultMessage), Custom(CustomMessage), } @@ -37,9 +38,11 @@ pub struct AssistantMessage { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolResultMessage { - pub tool_call_id: String, - pub tool_name: String, +pub struct FunctionResultMessage { + #[serde(alias = "tool_call_id")] + pub function_call_id: String, + #[serde(alias = "tool_name")] + pub function_id: String, pub content: Vec, pub details: serde_json::Value, pub is_error: bool, @@ -64,8 +67,8 @@ pub struct CustomMessage { pub struct AgentContext { pub system_prompt: String, pub messages: Vec, - #[serde(default)] - pub tools: Vec, + #[serde(default, alias = "tools")] + pub functions: Vec, } /// Persisted session state. Lives at `agent::session//state` on iii state. @@ -102,4 +105,15 @@ mod tests { let m: AgentMessage = serde_json::from_str(json).unwrap(); assert!(matches!(m, AgentMessage::User(_))); } + + #[test] + fn function_result_legacy_tool_result_role() { + let json = r#"{"role":"tool_result","function_call_id":"c1","function_id":"x","content":[],"details":{},"is_error":false,"timestamp":0}"#; + // Old persisted shape used tool_call_id / tool_name + let json_old = r#"{"role":"tool_result","tool_call_id":"c1","tool_name":"x","content":[],"details":{},"is_error":false,"timestamp":0}"#; + let m: AgentMessage = serde_json::from_str(json).unwrap(); + let m_old: AgentMessage = serde_json::from_str(json_old).unwrap(); + assert!(matches!(m, AgentMessage::FunctionResult(_))); + assert!(matches!(m_old, AgentMessage::FunctionResult(_))); + } } diff --git a/session-tree/crates/harness-types/src/content.rs b/session-tree/crates/harness-types/src/content.rs index da2cb7d01..52e678751 100644 --- a/session-tree/crates/harness-types/src/content.rs +++ b/session-tree/crates/harness-types/src/content.rs @@ -2,19 +2,23 @@ use serde::{Deserialize, Serialize}; use crate::thinking::TextSignature; -/// A block of content. Carried by user, assistant, and tool-result messages. +/// A block of content. Carried by user, assistant, and function-result messages. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum ContentBlock { Text(TextContent), Image(ImageContent), - ToolCall { + #[serde(rename = "functionCall", alias = "toolCall")] + FunctionCall { id: String, - name: String, + #[serde(alias = "name")] + function_id: String, arguments: serde_json::Value, }, - ToolResult { - tool_call_id: String, + #[serde(rename = "functionResult", alias = "toolResult")] + FunctionResult { + #[serde(alias = "tool_call_id")] + function_call_id: String, content: Vec, is_error: bool, }, @@ -53,14 +57,29 @@ mod tests { } #[test] - fn tool_call_block_roundtrip() { - let block = ContentBlock::ToolCall { + fn function_call_block_roundtrip() { + let block = ContentBlock::FunctionCall { id: "call_1".into(), - name: "read".into(), + function_id: "read".into(), arguments: serde_json::json!({ "path": "/tmp/x" }), }; let json = serde_json::to_string(&block).unwrap(); let back: ContentBlock = serde_json::from_str(&json).unwrap(); assert_eq!(block, back); } + + #[test] + fn function_call_block_legacy_tool_call_type() { + let json = + r#"{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"/tmp/x"}}"#; + let back: ContentBlock = serde_json::from_str(json).unwrap(); + assert_eq!( + back, + ContentBlock::FunctionCall { + id: "call_1".into(), + function_id: "read".into(), + arguments: serde_json::json!({ "path": "/tmp/x" }), + } + ); + } } diff --git a/provider-openai/crates/harness-types/src/tool.rs b/session-tree/crates/harness-types/src/function.rs similarity index 56% rename from provider-openai/crates/harness-types/src/tool.rs rename to session-tree/crates/harness-types/src/function.rs index 92c29df98..5620a04ec 100644 --- a/provider-openai/crates/harness-types/src/tool.rs +++ b/session-tree/crates/harness-types/src/function.rs @@ -2,9 +2,9 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; -/// A tool definition advertised to the model. +/// A function slot advertised to the model (harness uses `agent_call`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentTool { +pub struct AgentFunction { pub name: String, pub description: String, /// JSON schema for the parameters object. @@ -16,14 +16,14 @@ pub struct AgentTool { pub prepare_arguments_supported: bool, } -/// How tool calls in a single assistant message are scheduled. +/// How function calls in a single assistant message are scheduled. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ExecutionMode { - /// Tool calls run concurrently. Default. + /// Calls run concurrently. Default. #[default] Parallel, - /// Tool calls run one at a time. Any tool flagged sequential forces the + /// Calls run one at a time. Any call flagged sequential forces the /// whole batch to run sequentially. Sequential, } @@ -46,45 +46,48 @@ pub enum CacheRetention { Long, } -/// A single tool-call request emitted by an assistant message. +/// A single function-call request emitted by an assistant message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolCall { +pub struct FunctionCall { pub id: String, - pub name: String, + /// iii function id (e.g. `shell::filesystem::ls`). + #[serde(alias = "name")] + pub function_id: String, pub arguments: serde_json::Value, } -/// Result of executing a tool. `terminate` is a hint that the loop should end -/// after the current batch; honored only when EVERY tool in the batch sets it. +/// Result of executing a function. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolResult { +pub struct FunctionResult { pub content: Vec, pub details: serde_json::Value, #[serde(default)] pub terminate: bool, } -/// Outcome of `prepare_tool`. Either ready to execute, or short-circuited by -/// validation failure or a `before_tool_call` block. +/// Outcome of prepare. Either ready to execute, or short-circuited. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] -pub enum PreparedToolCall { +pub enum PreparedFunctionCall { Prepared { - tool_call: ToolCall, - tool: AgentTool, + #[serde(alias = "tool_call")] + function_call: FunctionCall, + #[serde(alias = "tool")] + function: AgentFunction, args: serde_json::Value, }, Immediate { - result: ToolResult, + result: FunctionResult, is_error: bool, }, } -/// Tool call after `after_tool_call` subscribers have run and merged results. +/// After `after_function_call` subscribers have merged results. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FinalizedToolCall { - pub tool_call: ToolCall, - pub result: ToolResult, +pub struct FinalizedFunctionCall { + #[serde(alias = "tool_call")] + pub function_call: FunctionCall, + pub result: FunctionResult, pub is_error: bool, } @@ -98,14 +101,21 @@ mod tests { } #[test] - fn tool_call_roundtrip() { - let call = ToolCall { + fn function_call_roundtrip() { + let call = FunctionCall { id: "x".into(), - name: "read".into(), + function_id: "read".into(), arguments: serde_json::json!({}), }; let json = serde_json::to_string(&call).unwrap(); - let back: ToolCall = serde_json::from_str(&json).unwrap(); + let back: FunctionCall = serde_json::from_str(&json).unwrap(); assert_eq!(call, back); } + + #[test] + fn function_call_deserializes_legacy_name_field() { + let json = r#"{"id":"x","name":"shell::filesystem::ls","arguments":{}}"#; + let call: FunctionCall = serde_json::from_str(json).unwrap(); + assert_eq!(call.function_id, "shell::filesystem::ls"); + } } diff --git a/session-tree/crates/harness-types/src/lib.rs b/session-tree/crates/harness-types/src/lib.rs index 7ec421cbc..904fccbba 100644 --- a/session-tree/crates/harness-types/src/lib.rs +++ b/session-tree/crates/harness-types/src/lib.rs @@ -6,19 +6,19 @@ mod agent_event; mod agent_message; mod content; +mod function; mod stream_event; mod thinking; -mod tool; pub use agent_event::{AgentEvent, ApprovalDecision}; pub use agent_message::{ AgentContext, AgentMessage, AgentSessionState, AssistantMessage, CustomMessage, - ToolResultMessage, UserMessage, + FunctionResultMessage, UserMessage, }; pub use content::{ContentBlock, ImageContent, TextContent}; +pub use function::{ + AgentFunction, CacheRetention, ExecutionMode, FinalizedFunctionCall, FunctionCall, + FunctionResult, PreparedFunctionCall, Transport, +}; pub use stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; -pub use tool::{ - AgentTool, CacheRetention, ExecutionMode, FinalizedToolCall, PreparedToolCall, ToolCall, - ToolResult, Transport, -}; diff --git a/session-tree/crates/harness-types/src/stream_event.rs b/session-tree/crates/harness-types/src/stream_event.rs index 729e39ade..dd0db8961 100644 --- a/session-tree/crates/harness-types/src/stream_event.rs +++ b/session-tree/crates/harness-types/src/stream_event.rs @@ -8,7 +8,8 @@ use crate::agent_message::AssistantMessage; pub enum StopReason { End, Length, - Tool, + #[serde(rename = "function_call", alias = "tool")] + FunctionCall, Aborted, Error, } @@ -72,14 +73,17 @@ pub enum AssistantMessageEvent { ThinkingEnd { partial: AssistantMessage, }, - ToolcallStart { + #[serde(rename = "functioncall_start", alias = "toolcall_start")] + FunctioncallStart { partial: AssistantMessage, }, - ToolcallDelta { + #[serde(rename = "functioncall_delta", alias = "toolcall_delta")] + FunctioncallDelta { partial: AssistantMessage, delta: String, }, - ToolcallEnd { + #[serde(rename = "functioncall_end", alias = "toolcall_end")] + FunctioncallEnd { partial: AssistantMessage, }, Usage(Usage), diff --git a/session-tree/src/lib.rs b/session-tree/src/lib.rs index e8b587ba1..efe1ac8c6 100644 --- a/session-tree/src/lib.rs +++ b/session-tree/src/lib.rs @@ -505,7 +505,7 @@ fn extract_summary(message: &AgentMessage) -> Option { let blocks: &[ContentBlock] = match message { AgentMessage::User(m) => &m.content, AgentMessage::Assistant(m) => &m.content, - AgentMessage::ToolResult(_) | AgentMessage::Custom(_) => return None, + AgentMessage::FunctionResult(_) | AgentMessage::Custom(_) => return None, }; for block in blocks { if let ContentBlock::Text(text) = block { @@ -527,7 +527,7 @@ pub async fn load_context( Ok(AgentContext { system_prompt, messages, - tools: Vec::new(), + functions: Vec::new(), }) } @@ -816,11 +816,11 @@ fn render_message_html(message: &AgentMessage) -> String { html_escape(&a.model) ) } - AgentMessage::ToolResult(tr) => { + AgentMessage::FunctionResult(tr) => { let body = render_blocks_html(&tr.content); - let name = html_escape(&tr.tool_name); + let name = html_escape(&tr.function_id); format!( - "
tool result · {name}
{body}
\n" + "
function result · {name}
{body}
\n" ) } AgentMessage::Custom(c) => { @@ -855,16 +855,18 @@ fn render_blocks_html(blocks: &[ContentBlock]) -> String { out.push_str(&html_escape(&img.mime)); out.push_str("]"); } - ContentBlock::ToolCall { - name, arguments, .. + ContentBlock::FunctionCall { + function_id, + arguments, + .. } => { - out.push_str("
tool call: ");
-                out.push_str(&html_escape(name));
+                out.push_str("
function call: ");
+                out.push_str(&html_escape(function_id));
                 out.push(' ');
                 out.push_str(&html_escape(&arguments.to_string()));
                 out.push_str("
"); } - ContentBlock::ToolResult { content, .. } => { + ContentBlock::FunctionResult { content, .. } => { out.push_str(&render_blocks_html(content)); } } diff --git a/turn-orchestrator/README.md b/turn-orchestrator/README.md index 588cc0618..415100ca5 100644 --- a/turn-orchestrator/README.md +++ b/turn-orchestrator/README.md @@ -1,7 +1,7 @@ # turn-orchestrator Durable `run::start` state machine on the iii bus. Drives each agent -turn through provisioning → assistant → tools → steering → tearing-down, +turn through provisioning → assistant → functions → steering → tearing-down, checkpointing the session record on every step so a process crash or restart resumes from the last persisted node rather than restarting the run from scratch. Most users install this worker via the diff --git a/turn-orchestrator/crates/harness-types/src/agent_event.rs b/turn-orchestrator/crates/harness-types/src/agent_event.rs index 8040e33ee..ed74af98a 100644 --- a/turn-orchestrator/crates/harness-types/src/agent_event.rs +++ b/turn-orchestrator/crates/harness-types/src/agent_event.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; -use crate::agent_message::{AgentMessage, ToolResultMessage}; +use crate::agent_message::{AgentMessage, FunctionResultMessage}; +use crate::function::FunctionResult; use crate::stream_event::AssistantMessageEvent; -use crate::tool::ToolResult; /// Outcome of an approval gate. Wire format is the lowercase string /// `"allow"` or `"deny"`; the typed enum prevents constructing illegal values. @@ -23,15 +23,16 @@ pub enum AgentEvent { /// Loop has completed; carries the full message tail produced. AgentEnd { messages: Vec }, - /// One assistant turn (LLM response + any tool calls/results) has begun. + /// One assistant turn (LLM response + any function calls/results) has begun. TurnStart, /// One assistant turn has completed. TurnEnd { message: AgentMessage, - tool_results: Vec, + #[serde(alias = "tool_results")] + function_results: Vec, }, - /// A user, assistant, or tool-result message is about to be added to the transcript. + /// A user, assistant, or function-result message is about to be added to the transcript. MessageStart { message: AgentMessage }, /// Streaming update on the in-flight assistant message. Only emitted while the /// LLM is producing the current response. @@ -42,40 +43,49 @@ pub enum AgentEvent { /// The message is final and committed to the transcript. MessageEnd { message: AgentMessage }, - /// A tool call has been validated and dispatch has begun. - ToolExecutionStart { - tool_call_id: String, - tool_name: String, + /// A function call has been validated and dispatch has begun. + #[serde(rename = "function_execution_start", alias = "tool_execution_start")] + FunctionExecutionStart { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, }, - /// Streaming partial result from a long-running tool. - ToolExecutionUpdate { - tool_call_id: String, - tool_name: String, + /// Streaming partial result from a long-running function. + #[serde(rename = "function_execution_update", alias = "tool_execution_update")] + FunctionExecutionUpdate { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, partial_result: serde_json::Value, }, - /// Tool execution has finished. `result` is post-`after_tool_call` merged. - ToolExecutionEnd { - tool_call_id: String, - tool_name: String, - result: ToolResult, + /// Function execution has finished. `result` is post-`after_function_call` merged. + #[serde(rename = "function_execution_end", alias = "tool_execution_end")] + FunctionExecutionEnd { + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, + result: FunctionResult, is_error: bool, }, - /// A tool call is paused by an approval subscriber, awaiting user decision. - /// `tool_call_id`, `tool_name`, and `args` intentionally duplicate the fields on - /// `ToolExecutionStart` so consumers subscribing only to approval events have full - /// context without replaying the rest of the stream. + /// A function call is paused by an approval subscriber, awaiting user decision. ApprovalRequested { - tool_call_id: String, - tool_name: String, + #[serde(alias = "tool_call_id")] + function_call_id: String, + #[serde(alias = "tool_name")] + function_id: String, args: serde_json::Value, /// Unix milliseconds. After this point the gate auto-denies. expires_at: u64, }, /// Approval gate has resolved a previously-requested approval. ApprovalResolved { - tool_call_id: String, + #[serde(alias = "tool_call_id")] + function_call_id: String, decision: ApprovalDecision, /// Free-form reason — populated for "deny" (e.g. "timeout", "user"). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -86,6 +96,7 @@ pub enum AgentEvent { #[cfg(test)] mod tests { use super::*; + use crate::agent_message::UserMessage; #[test] fn agent_start_serialises_with_tag() { @@ -94,28 +105,43 @@ mod tests { } #[test] - fn tool_start_carries_args() { - let ev = AgentEvent::ToolExecutionStart { - tool_call_id: "id".into(), - tool_name: "read".into(), + fn function_start_carries_args() { + let ev = AgentEvent::FunctionExecutionStart { + function_call_id: "id".into(), + function_id: "read".into(), args: serde_json::json!({ "path": "/x" }), }; let json = serde_json::to_string(&ev).unwrap(); + assert!(json.contains("function_execution_start")); let back: AgentEvent = serde_json::from_str(&json).unwrap(); assert_eq!(ev, back); } + #[test] + fn function_execution_start_legacy_type_deserializes() { + let json = r#"{"type":"tool_execution_start","tool_call_id":"id","tool_name":"read","args":{"path":"/x"}}"#; + let back: AgentEvent = serde_json::from_str(json).unwrap(); + assert_eq!( + back, + AgentEvent::FunctionExecutionStart { + function_call_id: "id".into(), + function_id: "read".into(), + args: serde_json::json!({ "path": "/x" }), + } + ); + } + #[test] fn approval_requested_round_trips() { let evt = AgentEvent::ApprovalRequested { - tool_call_id: "tc-9".into(), - tool_name: "shell::filesystem::write".into(), + function_call_id: "tc-9".into(), + function_id: "shell::filesystem::write".into(), args: serde_json::json!({ "path": "/tmp/x" }), expires_at: 1_700_000_000_000, }; let json = serde_json::to_value(&evt).unwrap(); assert_eq!(json["type"], "approval_requested"); - assert_eq!(json["tool_call_id"], "tc-9"); + assert_eq!(json["function_call_id"], "tc-9"); let back: AgentEvent = serde_json::from_value(json).unwrap(); assert_eq!(back, evt); } @@ -123,7 +149,7 @@ mod tests { #[test] fn approval_resolved_round_trips_with_optional_reason() { let evt = AgentEvent::ApprovalResolved { - tool_call_id: "tc-9".into(), + function_call_id: "tc-9".into(), decision: ApprovalDecision::Deny, reason: Some("timeout".into()), }; @@ -134,7 +160,7 @@ mod tests { assert_eq!(back, evt); let none_reason = AgentEvent::ApprovalResolved { - tool_call_id: "tc-9".into(), + function_call_id: "tc-9".into(), decision: ApprovalDecision::Allow, reason: None, }; @@ -145,4 +171,19 @@ mod tests { "reason should be omitted when None: {json}" ); } + + #[test] + fn turn_end_legacy_tool_results_field() { + let msg = AgentMessage::User(UserMessage { + content: vec![], + timestamp: 0, + }); + let json = serde_json::json!({ + "type": "turn_end", + "message": msg, + "tool_results": [] + }); + let evt: AgentEvent = serde_json::from_value(json).unwrap(); + assert!(matches!(evt, AgentEvent::TurnEnd { .. })); + } } diff --git a/turn-orchestrator/crates/harness-types/src/agent_message.rs b/turn-orchestrator/crates/harness-types/src/agent_message.rs index c29823068..8cd6af982 100644 --- a/turn-orchestrator/crates/harness-types/src/agent_message.rs +++ b/turn-orchestrator/crates/harness-types/src/agent_message.rs @@ -1,9 +1,9 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; +use crate::function::AgentFunction; use crate::stream_event::{ErrorKind, StopReason, Usage}; use crate::thinking::ThinkingLevel; -use crate::tool::AgentTool; /// Transcript message. Superset of LLM message types plus app-defined custom entries. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -11,7 +11,8 @@ use crate::tool::AgentTool; pub enum AgentMessage { User(UserMessage), Assistant(AssistantMessage), - ToolResult(ToolResultMessage), + #[serde(rename = "function_result", alias = "tool_result")] + FunctionResult(FunctionResultMessage), Custom(CustomMessage), } @@ -37,9 +38,11 @@ pub struct AssistantMessage { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolResultMessage { - pub tool_call_id: String, - pub tool_name: String, +pub struct FunctionResultMessage { + #[serde(alias = "tool_call_id")] + pub function_call_id: String, + #[serde(alias = "tool_name")] + pub function_id: String, pub content: Vec, pub details: serde_json::Value, pub is_error: bool, @@ -64,8 +67,8 @@ pub struct CustomMessage { pub struct AgentContext { pub system_prompt: String, pub messages: Vec, - #[serde(default)] - pub tools: Vec, + #[serde(default, alias = "tools")] + pub functions: Vec, } /// Persisted session state. Lives at `agent::session//state` on iii state. @@ -102,4 +105,15 @@ mod tests { let m: AgentMessage = serde_json::from_str(json).unwrap(); assert!(matches!(m, AgentMessage::User(_))); } + + #[test] + fn function_result_legacy_tool_result_role() { + let json = r#"{"role":"tool_result","function_call_id":"c1","function_id":"x","content":[],"details":{},"is_error":false,"timestamp":0}"#; + // Old persisted shape used tool_call_id / tool_name + let json_old = r#"{"role":"tool_result","tool_call_id":"c1","tool_name":"x","content":[],"details":{},"is_error":false,"timestamp":0}"#; + let m: AgentMessage = serde_json::from_str(json).unwrap(); + let m_old: AgentMessage = serde_json::from_str(json_old).unwrap(); + assert!(matches!(m, AgentMessage::FunctionResult(_))); + assert!(matches!(m_old, AgentMessage::FunctionResult(_))); + } } diff --git a/turn-orchestrator/crates/harness-types/src/content.rs b/turn-orchestrator/crates/harness-types/src/content.rs index da2cb7d01..52e678751 100644 --- a/turn-orchestrator/crates/harness-types/src/content.rs +++ b/turn-orchestrator/crates/harness-types/src/content.rs @@ -2,19 +2,23 @@ use serde::{Deserialize, Serialize}; use crate::thinking::TextSignature; -/// A block of content. Carried by user, assistant, and tool-result messages. +/// A block of content. Carried by user, assistant, and function-result messages. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum ContentBlock { Text(TextContent), Image(ImageContent), - ToolCall { + #[serde(rename = "functionCall", alias = "toolCall")] + FunctionCall { id: String, - name: String, + #[serde(alias = "name")] + function_id: String, arguments: serde_json::Value, }, - ToolResult { - tool_call_id: String, + #[serde(rename = "functionResult", alias = "toolResult")] + FunctionResult { + #[serde(alias = "tool_call_id")] + function_call_id: String, content: Vec, is_error: bool, }, @@ -53,14 +57,29 @@ mod tests { } #[test] - fn tool_call_block_roundtrip() { - let block = ContentBlock::ToolCall { + fn function_call_block_roundtrip() { + let block = ContentBlock::FunctionCall { id: "call_1".into(), - name: "read".into(), + function_id: "read".into(), arguments: serde_json::json!({ "path": "/tmp/x" }), }; let json = serde_json::to_string(&block).unwrap(); let back: ContentBlock = serde_json::from_str(&json).unwrap(); assert_eq!(block, back); } + + #[test] + fn function_call_block_legacy_tool_call_type() { + let json = + r#"{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"/tmp/x"}}"#; + let back: ContentBlock = serde_json::from_str(json).unwrap(); + assert_eq!( + back, + ContentBlock::FunctionCall { + id: "call_1".into(), + function_id: "read".into(), + arguments: serde_json::json!({ "path": "/tmp/x" }), + } + ); + } } diff --git a/turn-orchestrator/crates/harness-types/src/function.rs b/turn-orchestrator/crates/harness-types/src/function.rs new file mode 100644 index 000000000..5620a04ec --- /dev/null +++ b/turn-orchestrator/crates/harness-types/src/function.rs @@ -0,0 +1,121 @@ +use serde::{Deserialize, Serialize}; + +use crate::content::ContentBlock; + +/// A function slot advertised to the model (harness uses `agent_call`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentFunction { + pub name: String, + pub description: String, + /// JSON schema for the parameters object. + pub parameters: serde_json::Value, + pub label: String, + #[serde(default)] + pub execution_mode: ExecutionMode, + #[serde(default)] + pub prepare_arguments_supported: bool, +} + +/// How function calls in a single assistant message are scheduled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExecutionMode { + /// Calls run concurrently. Default. + #[default] + Parallel, + /// Calls run one at a time. Any call flagged sequential forces the + /// whole batch to run sequentially. + Sequential, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Transport { + Sse, + Websocket, + #[default] + Auto, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CacheRetention { + None, + #[default] + Short, + Long, +} + +/// A single function-call request emitted by an assistant message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FunctionCall { + pub id: String, + /// iii function id (e.g. `shell::filesystem::ls`). + #[serde(alias = "name")] + pub function_id: String, + pub arguments: serde_json::Value, +} + +/// Result of executing a function. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FunctionResult { + pub content: Vec, + pub details: serde_json::Value, + #[serde(default)] + pub terminate: bool, +} + +/// Outcome of prepare. Either ready to execute, or short-circuited. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum PreparedFunctionCall { + Prepared { + #[serde(alias = "tool_call")] + function_call: FunctionCall, + #[serde(alias = "tool")] + function: AgentFunction, + args: serde_json::Value, + }, + Immediate { + result: FunctionResult, + is_error: bool, + }, +} + +/// After `after_function_call` subscribers have merged results. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FinalizedFunctionCall { + #[serde(alias = "tool_call")] + pub function_call: FunctionCall, + pub result: FunctionResult, + pub is_error: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn execution_mode_default_is_parallel() { + assert_eq!(ExecutionMode::default(), ExecutionMode::Parallel); + } + + #[test] + fn function_call_roundtrip() { + let call = FunctionCall { + id: "x".into(), + function_id: "read".into(), + arguments: serde_json::json!({}), + }; + let json = serde_json::to_string(&call).unwrap(); + let back: FunctionCall = serde_json::from_str(&json).unwrap(); + assert_eq!(call, back); + } + + #[test] + fn function_call_deserializes_legacy_name_field() { + let json = r#"{"id":"x","name":"shell::filesystem::ls","arguments":{}}"#; + let call: FunctionCall = serde_json::from_str(json).unwrap(); + assert_eq!(call.function_id, "shell::filesystem::ls"); + } +} diff --git a/turn-orchestrator/crates/harness-types/src/lib.rs b/turn-orchestrator/crates/harness-types/src/lib.rs index 7ec421cbc..904fccbba 100644 --- a/turn-orchestrator/crates/harness-types/src/lib.rs +++ b/turn-orchestrator/crates/harness-types/src/lib.rs @@ -6,19 +6,19 @@ mod agent_event; mod agent_message; mod content; +mod function; mod stream_event; mod thinking; -mod tool; pub use agent_event::{AgentEvent, ApprovalDecision}; pub use agent_message::{ AgentContext, AgentMessage, AgentSessionState, AssistantMessage, CustomMessage, - ToolResultMessage, UserMessage, + FunctionResultMessage, UserMessage, }; pub use content::{ContentBlock, ImageContent, TextContent}; +pub use function::{ + AgentFunction, CacheRetention, ExecutionMode, FinalizedFunctionCall, FunctionCall, + FunctionResult, PreparedFunctionCall, Transport, +}; pub use stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; -pub use tool::{ - AgentTool, CacheRetention, ExecutionMode, FinalizedToolCall, PreparedToolCall, ToolCall, - ToolResult, Transport, -}; diff --git a/turn-orchestrator/crates/harness-types/src/stream_event.rs b/turn-orchestrator/crates/harness-types/src/stream_event.rs index 729e39ade..dd0db8961 100644 --- a/turn-orchestrator/crates/harness-types/src/stream_event.rs +++ b/turn-orchestrator/crates/harness-types/src/stream_event.rs @@ -8,7 +8,8 @@ use crate::agent_message::AssistantMessage; pub enum StopReason { End, Length, - Tool, + #[serde(rename = "function_call", alias = "tool")] + FunctionCall, Aborted, Error, } @@ -72,14 +73,17 @@ pub enum AssistantMessageEvent { ThinkingEnd { partial: AssistantMessage, }, - ToolcallStart { + #[serde(rename = "functioncall_start", alias = "toolcall_start")] + FunctioncallStart { partial: AssistantMessage, }, - ToolcallDelta { + #[serde(rename = "functioncall_delta", alias = "toolcall_delta")] + FunctioncallDelta { partial: AssistantMessage, delta: String, }, - ToolcallEnd { + #[serde(rename = "functioncall_end", alias = "toolcall_end")] + FunctioncallEnd { partial: AssistantMessage, }, Usage(Usage), diff --git a/turn-orchestrator/crates/harness-types/src/tool.rs b/turn-orchestrator/crates/harness-types/src/tool.rs deleted file mode 100644 index 92c29df98..000000000 --- a/turn-orchestrator/crates/harness-types/src/tool.rs +++ /dev/null @@ -1,111 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::content::ContentBlock; - -/// A tool definition advertised to the model. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentTool { - pub name: String, - pub description: String, - /// JSON schema for the parameters object. - pub parameters: serde_json::Value, - pub label: String, - #[serde(default)] - pub execution_mode: ExecutionMode, - #[serde(default)] - pub prepare_arguments_supported: bool, -} - -/// How tool calls in a single assistant message are scheduled. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ExecutionMode { - /// Tool calls run concurrently. Default. - #[default] - Parallel, - /// Tool calls run one at a time. Any tool flagged sequential forces the - /// whole batch to run sequentially. - Sequential, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Transport { - Sse, - Websocket, - #[default] - Auto, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CacheRetention { - None, - #[default] - Short, - Long, -} - -/// A single tool-call request emitted by an assistant message. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolCall { - pub id: String, - pub name: String, - pub arguments: serde_json::Value, -} - -/// Result of executing a tool. `terminate` is a hint that the loop should end -/// after the current batch; honored only when EVERY tool in the batch sets it. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolResult { - pub content: Vec, - pub details: serde_json::Value, - #[serde(default)] - pub terminate: bool, -} - -/// Outcome of `prepare_tool`. Either ready to execute, or short-circuited by -/// validation failure or a `before_tool_call` block. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "lowercase")] -pub enum PreparedToolCall { - Prepared { - tool_call: ToolCall, - tool: AgentTool, - args: serde_json::Value, - }, - Immediate { - result: ToolResult, - is_error: bool, - }, -} - -/// Tool call after `after_tool_call` subscribers have run and merged results. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FinalizedToolCall { - pub tool_call: ToolCall, - pub result: ToolResult, - pub is_error: bool, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn execution_mode_default_is_parallel() { - assert_eq!(ExecutionMode::default(), ExecutionMode::Parallel); - } - - #[test] - fn tool_call_roundtrip() { - let call = ToolCall { - id: "x".into(), - name: "read".into(), - arguments: serde_json::json!({}), - }; - let json = serde_json::to_string(&call).unwrap(); - let back: ToolCall = serde_json::from_str(&json).unwrap(); - assert_eq!(call, back); - } -} diff --git a/turn-orchestrator/src/agent_call.rs b/turn-orchestrator/src/agent_call.rs index 0d56ff30f..959500063 100644 --- a/turn-orchestrator/src/agent_call.rs +++ b/turn-orchestrator/src/agent_call.rs @@ -3,12 +3,12 @@ //! The model emits exactly one tool name, `agent_call`, with `{function, //! payload}` arguments. This module owns the LLM-facing tool descriptor //! (`agent_call_tool`), the shared dispatch helper (`dispatch`) that -//! `states::tools::handle_execute` calls into, and the iii function +//! `states::functions::handle_execute` calls into, and the iii function //! registration (`register`) that surfaces the dispatcher on the bus. use std::sync::Arc; -use harness_types::{ContentBlock, TextContent, ToolResult}; +use harness_types::{ContentBlock, FunctionResult, TextContent}; use iii_sdk::{IIIError, RegisterFunctionMessage, TriggerRequest, Value, III}; use serde_json::json; @@ -50,11 +50,11 @@ pub fn agent_call_tool() -> Value { }) } -/// Build a `ToolResult` carrying a structured error envelope. The agent +/// Build a `FunctionResult` carrying a structured error envelope. The agent /// loop must continue regardless of failure class — never throw from the /// dispatcher. -fn error_result(envelope: Value) -> ToolResult { - ToolResult { +fn error_result(envelope: Value) -> FunctionResult { + FunctionResult { content: vec![ContentBlock::Text(TextContent { text: envelope.to_string(), })], @@ -63,10 +63,10 @@ fn error_result(envelope: Value) -> ToolResult { } } -/// Validate the `function` field. Returns `Err(ToolResult)` when the field +/// Validate the `function` field. Returns `Err(FunctionResult)` when the field /// is missing, empty, or not a string. The caller short-circuits without /// touching `iii.trigger`. -fn validate_function_field(function: &Value) -> Result { +fn validate_function_field(function: &Value) -> Result { match function.as_str() { Some(s) if !s.is_empty() => Ok(s.to_string()), _ => Err(error_result(json!({ @@ -94,14 +94,14 @@ pub(crate) fn is_timeout(err: &IIIError) -> bool { matches!(err, IIIError::Timeout) } -/// If the inner function returned a `ToolResult`-shaped value, deserialize +/// If the inner function returned a `FunctionResult`-shaped value, deserialize /// it. Otherwise wrap the value as the tool's `details` so function-level /// envelopes (`{ok: false, error}`) pass through verbatim per the spec. -fn decode_or_passthrough(value: Value) -> ToolResult { - if let Ok(tr) = serde_json::from_value::(value.clone()) { +fn decode_or_passthrough(value: Value) -> FunctionResult { + if let Ok(tr) = serde_json::from_value::(value.clone()) { return tr; } - ToolResult { + FunctionResult { content: vec![ContentBlock::Text(TextContent { text: value.to_string(), })], @@ -111,7 +111,7 @@ fn decode_or_passthrough(value: Value) -> ToolResult { } /// Shared dispatch helper. Both `agent::call`'s registered iii handler and -/// `states::tools::handle_execute` call this so policy → trigger → error +/// `states::functions::handle_execute` call this so policy → trigger → error /// mapping has one source of truth. /// /// Tier 2: no schema lookup, no payload validation, no sandbox automation. @@ -127,7 +127,7 @@ pub async fn dispatch( _session_id: &str, function: &Value, payload: Value, -) -> ToolResult { +) -> FunctionResult { let function_id = match validate_function_field(function) { Ok(id) => id, Err(result) => return result, @@ -170,7 +170,7 @@ pub fn register(iii: &Arc) { let iii_clone = iii.clone(); iii.register_function(( RegisterFunctionMessage::with_id(FUNCTION_ID.to_string()).with_description( - "LLM-facing dispatcher: dispatches an iii function and returns a ToolResult." + "LLM-facing dispatcher: dispatches an iii function and returns a FunctionResult." .to_string(), ), move |payload: Value| { @@ -339,7 +339,7 @@ mod dispatch_tests { #[test] fn decode_or_passthrough_handles_partial_tool_result() { // `terminate` has #[serde(default)]; partial input still - // deserializes as a ToolResult. If a future change drops the + // deserializes as a FunctionResult. If a future change drops the // default, this test fails and forces an explicit decision. let inner = json!({"content": [], "details": {"k": "v"}}); let tr = decode_or_passthrough(inner.clone()); diff --git a/turn-orchestrator/src/lib.rs b/turn-orchestrator/src/lib.rs index e95209df1..1e5f9da38 100644 --- a/turn-orchestrator/src/lib.rs +++ b/turn-orchestrator/src/lib.rs @@ -11,12 +11,11 @@ pub mod state; pub mod states; pub mod subscriber; pub mod system_prompt; -pub mod tools_catalog; pub mod transitions; pub use config::TurnOrchestratorConfig; pub use register::register_with_iii; pub use state::{ - cwd_index_key, cwd_key, messages_key, run_request_key, sandbox_id_key, tool_schemas_key, - turn_state_key, TurnState, TurnStateRecord, + cwd_index_key, cwd_key, function_schemas_key, messages_key, run_request_key, sandbox_id_key, + tool_schemas_key, turn_state_key, TurnState, TurnStateRecord, }; diff --git a/turn-orchestrator/src/persistence.rs b/turn-orchestrator/src/persistence.rs index 7998b079d..0e9dc34e0 100644 --- a/turn-orchestrator/src/persistence.rs +++ b/turn-orchestrator/src/persistence.rs @@ -2,13 +2,13 @@ //! and never panics; missing keys deserialise to defaults so callers can //! treat first-time and retry paths the same way. -use harness_types::{AgentMessage, ToolCall, ToolResult}; +use harness_types::{AgentMessage, FunctionCall, FunctionResult}; use iii_sdk::{TriggerRequest, Value, III}; use serde_json::{json, Value as JsonValue}; use crate::state::{ - cwd_index_key, cwd_key, messages_key, run_request_key, sandbox_id_key, tool_schemas_key, - turn_state_key, TurnStateRecord, + cwd_index_key, cwd_key, function_schemas_key, messages_key, run_request_key, sandbox_id_key, + tool_schemas_key, turn_state_key, TurnStateRecord, }; const STATE_SCOPE: &str = "agent"; @@ -189,14 +189,31 @@ pub async fn load_sandbox_id(iii: &III, session_id: &str) -> Option { .and_then(|v| v.as_str().map(str::to_string)) } +pub async fn save_function_schemas(iii: &III, session_id: &str, schemas: JsonValue) { + state_set(iii, &function_schemas_key(session_id), schemas).await; +} + +/// Load function catalog JSON; falls back to legacy `tool_schemas` key when the new key is absent. +pub async fn load_function_schemas(iii: &III, session_id: &str) -> JsonValue { + let new_key = function_schemas_key(session_id); + match state_get(iii, &new_key).await { + Some(v) => v, + None => state_get(iii, &tool_schemas_key(session_id)) + .await + .unwrap_or_else(|| json!([])), + } +} + +/// Back-compat name for callers being migrated — prefer [`save_function_schemas`]. +#[inline] pub async fn save_tool_schemas(iii: &III, session_id: &str, schemas: JsonValue) { - state_set(iii, &tool_schemas_key(session_id), schemas).await; + save_function_schemas(iii, session_id, schemas).await; } +/// Back-compat — prefer [`load_function_schemas`]. +#[inline] pub async fn load_tool_schemas(iii: &III, session_id: &str) -> JsonValue { - state_get(iii, &tool_schemas_key(session_id)) - .await - .unwrap_or_else(|| json!([])) + load_function_schemas(iii, session_id).await } async fn state_get(iii: &III, key: &str) -> Option { @@ -232,22 +249,39 @@ async fn state_set(iii: &III, key: &str, value: Value) { } } -const PREPARED_KEY: &str = "tool_prepared"; -const EXECUTED_KEY: &str = "tool_executed"; +const PREPARED_KEY: &str = "function_prepared"; +const EXECUTED_KEY: &str = "function_executed"; +const LEGACY_PREPARED_KEY: &str = "tool_prepared"; +const LEGACY_EXECUTED_KEY: &str = "tool_executed"; fn staging_key(session_id: &str, suffix: &str) -> String { format!("session/{session_id}/{suffix}") } +async fn staging_get_with_legacy( + iii: &III, + session_id: &str, + new_suffix: &str, + legacy_suffix: &str, +) -> JsonValue { + let new_k = staging_key(session_id, new_suffix); + match state_get(iii, &new_k).await { + Some(v) => v, + None => state_get(iii, &staging_key(session_id, legacy_suffix)) + .await + .unwrap_or_else(|| json!([])), + } +} + pub async fn save_prepared_calls( iii: &III, session_id: &str, - prepared: &[(ToolCall, Option)], + prepared: &[(FunctionCall, Option)], ) { let payload = serde_json::to_value( prepared .iter() - .map(|(tc, pre)| json!({ "tool_call": tc, "blocked": pre })) + .map(|(tc, pre)| json!({ "function_call": tc, "blocked": pre })) .collect::>(), ) .unwrap_or_else(|_| json!([])); @@ -257,21 +291,22 @@ pub async fn save_prepared_calls( pub async fn load_prepared_calls( iii: &III, session_id: &str, -) -> Vec<(ToolCall, Option)> { - let value = state_get(iii, &staging_key(session_id, PREPARED_KEY)) - .await - .unwrap_or_else(|| json!([])); +) -> Vec<(FunctionCall, Option)> { + let value = staging_get_with_legacy(iii, session_id, PREPARED_KEY, LEGACY_PREPARED_KEY).await; let Some(arr) = value.as_array() else { return Vec::new(); }; arr.iter() .filter_map(|entry| { - let tc = serde_json::from_value::(entry.get("tool_call")?.clone()).ok()?; + let fc = entry + .get("function_call") + .or_else(|| entry.get("tool_call")) + .and_then(|v| serde_json::from_value::(v.clone()).ok())?; let pre = entry .get("blocked") - .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) .unwrap_or(None); - Some((tc, pre)) + Some((fc, pre)) }) .collect() } @@ -279,50 +314,54 @@ pub async fn load_prepared_calls( pub async fn save_executed_calls( iii: &III, session_id: &str, - executed: &[(ToolCall, ToolResult, bool)], + executed: &[(FunctionCall, FunctionResult, bool)], ) { let payload = serde_json::to_value( executed .iter() - .map(|(tc, r, e)| json!({ "tool_call": tc, "result": r, "is_error": e })) + .map(|(tc, r, e)| json!({ "function_call": tc, "result": r, "is_error": e })) .collect::>(), ) .unwrap_or_else(|_| json!([])); state_set(iii, &staging_key(session_id, EXECUTED_KEY), payload).await; } -pub async fn load_executed_calls(iii: &III, session_id: &str) -> Vec<(ToolCall, ToolResult, bool)> { - let value = state_get(iii, &staging_key(session_id, EXECUTED_KEY)) - .await - .unwrap_or_else(|| json!([])); +pub async fn load_executed_calls( + iii: &III, + session_id: &str, +) -> Vec<(FunctionCall, FunctionResult, bool)> { + let value = staging_get_with_legacy(iii, session_id, EXECUTED_KEY, LEGACY_EXECUTED_KEY).await; let Some(arr) = value.as_array() else { return Vec::new(); }; arr.iter() .filter_map(|entry| { - let tc = serde_json::from_value::(entry.get("tool_call")?.clone()).ok()?; - let r = serde_json::from_value::(entry.get("result")?.clone()).ok()?; + let fc = entry + .get("function_call") + .or_else(|| entry.get("tool_call")) + .and_then(|v| serde_json::from_value::(v.clone()).ok())?; + let r = serde_json::from_value::(entry.get("result")?.clone()).ok()?; let e = entry .get("is_error") .and_then(Value::as_bool) .unwrap_or(false); - Some((tc, r, e)) + Some((fc, r, e)) }) .collect() } pub fn find_executed_call<'a>( - executed: &'a [(ToolCall, ToolResult, bool)], - tool_call_id: &str, -) -> Option<&'a (ToolCall, ToolResult, bool)> { - executed.iter().find(|(tc, _, _)| tc.id == tool_call_id) + executed: &'a [(FunctionCall, FunctionResult, bool)], + function_call_id: &str, +) -> Option<&'a (FunctionCall, FunctionResult, bool)> { + executed.iter().find(|(fc, _, _)| fc.id == function_call_id) } pub fn upsert_executed_call( - executed: &mut Vec<(ToolCall, ToolResult, bool)>, - entry: (ToolCall, ToolResult, bool), + executed: &mut Vec<(FunctionCall, FunctionResult, bool)>, + entry: (FunctionCall, FunctionResult, bool), ) { - if let Some(existing) = executed.iter_mut().find(|(tc, _, _)| tc.id == entry.0.id) { + if let Some(existing) = executed.iter_mut().find(|(fc, _, _)| fc.id == entry.0.id) { *existing = entry; } else { executed.push(entry); @@ -335,16 +374,16 @@ mod tests { use crate::state::TurnState; use harness_types::{ContentBlock, TextContent}; - fn tool_call(id: &str, name: &str) -> ToolCall { - ToolCall { + fn fc(id: &str, function_id: &str) -> FunctionCall { + FunctionCall { id: id.into(), - name: name.into(), + function_id: function_id.into(), arguments: json!({ "id": id }), } } - fn tool_result(text: &str) -> ToolResult { - ToolResult { + fn func_result(text: &str) -> FunctionResult { + FunctionResult { content: vec![ContentBlock::Text(TextContent { text: text.into() })], details: json!({ "text": text }), terminate: false, @@ -363,16 +402,16 @@ mod tests { } #[test] - fn find_executed_call_matches_tool_call_id() { + fn find_executed_call_matches_function_call_id() { let executed = vec![ - (tool_call("tc-1", "read"), tool_result("one"), false), - (tool_call("tc-2", "write"), tool_result("two"), true), + (fc("tc-1", "read"), func_result("one"), false), + (fc("tc-2", "write"), func_result("two"), true), ]; let found = find_executed_call(&executed, "tc-2").expect("expected tc-2"); assert_eq!(found.0.id, "tc-2"); - assert_eq!(found.0.name, "write"); + assert_eq!(found.0.function_id, "write"); assert!(found.2); assert!(find_executed_call(&executed, "missing").is_none()); } @@ -380,21 +419,17 @@ mod tests { #[test] fn upsert_executed_call_preserves_order_and_replaces_existing() { let mut executed = vec![ - (tool_call("tc-1", "read"), tool_result("one"), false), - (tool_call("tc-2", "write"), tool_result("two"), true), + (fc("tc-1", "read"), func_result("one"), false), + (fc("tc-2", "write"), func_result("two"), true), ]; upsert_executed_call( &mut executed, - ( - tool_call("tc-2", "write"), - tool_result("replacement"), - false, - ), + (fc("tc-2", "write"), func_result("replacement"), false), ); upsert_executed_call( &mut executed, - (tool_call("tc-3", "list"), tool_result("three"), false), + (fc("tc-3", "list"), func_result("three"), false), ); assert_eq!(executed.len(), 3); diff --git a/turn-orchestrator/src/state.rs b/turn-orchestrator/src/state.rs index 0c3d49e48..4b830a779 100644 --- a/turn-orchestrator/src/state.rs +++ b/turn-orchestrator/src/state.rs @@ -1,7 +1,7 @@ //! Persisted turn state. Loaded and saved on every `turn::step_requested` //! transition. See `docs/plans/2026-04-30-durable-harness-p2.md` § "TurnStateRecord". -use harness_types::{AgentMessage, AssistantMessage, ToolCall, ToolResultMessage}; +use harness_types::{AgentMessage, AssistantMessage, FunctionCall, FunctionResultMessage}; use serde::{Deserialize, Serialize}; /// Each state corresponds to a node in the durable state machine. @@ -12,9 +12,12 @@ pub enum TurnState { AwaitingAssistant, AssistantStreaming, AssistantFinished, - ToolPrepare, - ToolExecute, - ToolFinalize, + #[serde(rename = "function_prepare", alias = "tool_prepare")] + FunctionPrepare, + #[serde(rename = "function_execute", alias = "tool_execute")] + FunctionExecute, + #[serde(rename = "function_finalize", alias = "tool_finalize")] + FunctionFinalize, SteeringCheck, TearingDown, Stopped, @@ -27,9 +30,9 @@ impl TurnState { Self::AwaitingAssistant => "awaiting_assistant", Self::AssistantStreaming => "assistant_streaming", Self::AssistantFinished => "assistant_finished", - Self::ToolPrepare => "tool_prepare", - Self::ToolExecute => "tool_execute", - Self::ToolFinalize => "tool_finalize", + Self::FunctionPrepare => "function_prepare", + Self::FunctionExecute => "function_execute", + Self::FunctionFinalize => "function_finalize", Self::SteeringCheck => "steering_check", Self::TearingDown => "tearing_down", Self::Stopped => "stopped", @@ -46,8 +49,10 @@ pub struct TurnStateRecord { pub turn_count: u32, pub max_turns: Option, pub last_assistant: Option, - pub pending_tool_calls: Vec, - pub tool_results: Vec, + #[serde(alias = "pending_tool_calls")] + pub pending_function_calls: Vec, + #[serde(alias = "tool_results")] + pub function_results: Vec, /// Set true at any point a `TurnEnd` is emitted; reset false at the /// next `TurnStart`. Coordinates emission across handlers so the /// stream mirrors legacy `run_loop` (one TurnEnd per turn). See @@ -67,8 +72,8 @@ impl TurnStateRecord { turn_count: 0, max_turns, last_assistant: None, - pending_tool_calls: Vec::new(), - tool_results: Vec::new(), + pending_function_calls: Vec::new(), + function_results: Vec::new(), turn_end_emitted: false, started_at_ms: now, updated_at_ms: now, @@ -110,6 +115,11 @@ pub fn sandbox_id_key(session_id: &str) -> String { format!("session/{session_id}/sandbox_id") } +pub fn function_schemas_key(session_id: &str) -> String { + format!("session/{session_id}/function_schemas") +} + +/// Legacy persistence path; readers fall back when `function_schemas` is absent. pub fn tool_schemas_key(session_id: &str) -> String { format!("session/{session_id}/tool_schemas") } @@ -150,12 +160,19 @@ mod tests { assert_eq!(s, serde_json::json!("awaiting_assistant")); } + #[test] + fn turn_state_legacy_tool_prepare_alias() { + let v = serde_json::json!("tool_prepare"); + let t: TurnState = serde_json::from_value(v).unwrap(); + assert_eq!(t, TurnState::FunctionPrepare); + } + #[test] fn keys_use_session_namespace() { assert_eq!(turn_state_key("abc"), "session/abc/turn_state"); assert_eq!(messages_key("abc"), "session/abc/messages"); assert_eq!(sandbox_id_key("abc"), "session/abc/sandbox_id"); - assert_eq!(tool_schemas_key("abc"), "session/abc/tool_schemas"); + assert_eq!(function_schemas_key("abc"), "session/abc/function_schemas"); assert_eq!(run_request_key("abc"), "session/abc/run_request"); assert_eq!(cwd_key("abc"), "session/abc/cwd"); } @@ -167,4 +184,12 @@ mod tests { "harness/cwd/abc123/last_session_id" ); } + + #[test] + fn turn_state_record_legacy_field_names_deserialize() { + let json = r#"{"session_id":"s","state":"assistant_finished","turn_count":0,"max_turns":null,"last_assistant":null,"pending_tool_calls":[],"tool_results":[],"turn_end_emitted":false,"started_at_ms":0,"updated_at_ms":0}"#; + let r: TurnStateRecord = serde_json::from_str(json).unwrap(); + assert!(r.pending_function_calls.is_empty()); + assert!(r.function_results.is_empty()); + } } diff --git a/turn-orchestrator/src/states/assistant.rs b/turn-orchestrator/src/states/assistant.rs index 3a71f5018..71cc74f01 100644 --- a/turn-orchestrator/src/states/assistant.rs +++ b/turn-orchestrator/src/states/assistant.rs @@ -1,7 +1,7 @@ //! `awaiting_assistant`, `assistant_streaming`, `assistant_finished` handlers. use harness_types::{ - AgentEvent, AgentMessage, AssistantMessage, ContentBlock, StopReason, ToolCall, + AgentEvent, AgentMessage, AssistantMessage, ContentBlock, FunctionCall, StopReason, }; use iii_sdk::{TriggerRequest, III}; use serde_json::json; @@ -55,7 +55,7 @@ pub async fn handle_awaiting(iii: &III, record: &mut TurnStateRecord) -> anyhow: &record.session_id, &AgentEvent::TurnEnd { message: exhausted_msg, - tool_results: Vec::new(), + function_results: Vec::new(), }, ) .await; @@ -77,7 +77,7 @@ pub async fn handle_awaiting(iii: &III, record: &mut TurnStateRecord) -> anyhow: pub async fn handle_streaming(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<()> { let request = persistence::load_run_request(iii, &record.session_id).await; let messages = persistence::load_messages(iii, &record.session_id).await; - let tools = persistence::load_tool_schemas(iii, &record.session_id).await; + let schemas = persistence::load_function_schemas(iii, &record.session_id).await; let payload = json!({ "session_id": record.session_id, @@ -85,7 +85,7 @@ pub async fn handle_streaming(iii: &III, record: &mut TurnStateRecord) -> anyhow "model": request.get("model").cloned().unwrap_or_else(|| json!("")), "system_prompt": request.get("system_prompt").cloned().unwrap_or_else(|| json!("")), "messages": messages, - "tools": tools, + "tools": schemas, }); let response = iii .trigger(TriggerRequest { @@ -128,7 +128,7 @@ pub async fn handle_finished(iii: &III, record: &mut TurnStateRecord) -> anyhow: &record.session_id, &AgentEvent::TurnEnd { message: AgentMessage::Assistant(assistant), - tool_results: Vec::new(), + function_results: Vec::new(), }, ) .await; @@ -137,12 +137,12 @@ pub async fn handle_finished(iii: &III, record: &mut TurnStateRecord) -> anyhow: return Ok(()); } - let tool_calls = extract_tool_calls(&assistant); - if tool_calls.is_empty() { + let calls = extract_function_calls(&assistant); + if calls.is_empty() { record.transition_to(TurnState::SteeringCheck); } else { - record.pending_tool_calls = tool_calls; - record.transition_to(TurnState::ToolPrepare); + record.pending_function_calls = calls; + record.transition_to(TurnState::FunctionPrepare); } Ok(()) } @@ -159,18 +159,18 @@ pub(crate) fn assistant_lifecycle_events(assistant: &AssistantMessage) -> Vec Vec { +fn extract_function_calls(assistant: &AssistantMessage) -> Vec { assistant .content .iter() .filter_map(|c| match c { - ContentBlock::ToolCall { + ContentBlock::FunctionCall { id, - name, + function_id, arguments, - } => Some(ToolCall { + } => Some(FunctionCall { id: id.clone(), - name: name.clone(), + function_id: function_id.clone(), arguments: arguments.clone(), }), _ => None, @@ -198,12 +198,12 @@ mod tests { fn assistant_tool() -> AssistantMessage { AssistantMessage { - content: vec![ContentBlock::ToolCall { + content: vec![ContentBlock::FunctionCall { id: "x".into(), - name: "read".into(), + function_id: "read".into(), arguments: json!({}), }], - stop_reason: StopReason::Tool, + stop_reason: StopReason::FunctionCall, error_message: None, error_kind: None, usage: None, @@ -214,11 +214,11 @@ mod tests { } #[test] - fn extract_tool_calls_collects_tool_blocks_only() { - assert!(extract_tool_calls(&assistant_text()).is_empty()); - let calls = extract_tool_calls(&assistant_tool()); + fn extract_function_calls_collects_function_blocks_only() { + assert!(extract_function_calls(&assistant_text()).is_empty()); + let calls = extract_function_calls(&assistant_tool()); assert_eq!(calls.len(), 1); - assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].function_id, "read"); } #[test] diff --git a/turn-orchestrator/src/states/tools.rs b/turn-orchestrator/src/states/functions.rs similarity index 52% rename from turn-orchestrator/src/states/tools.rs rename to turn-orchestrator/src/states/functions.rs index 79510c402..2adb40fd7 100644 --- a/turn-orchestrator/src/states/tools.rs +++ b/turn-orchestrator/src/states/functions.rs @@ -1,8 +1,8 @@ -//! `tool_prepare`, `tool_execute`, `tool_finalize` handlers. +//! `function_prepare`, `function_execute`, `function_finalize` handlers. use harness_types::{ - AgentEvent, AgentMessage, AssistantMessage, ContentBlock, TextContent, ToolCall, ToolResult, - ToolResultMessage, + AgentEvent, AgentMessage, AssistantMessage, ContentBlock, FunctionCall, FunctionResult, + FunctionResultMessage, TextContent, }; use iii_sdk::{TriggerRequest, Value, III}; use serde_json::json; @@ -12,41 +12,41 @@ use crate::events; use crate::persistence; use crate::state::{TurnState, TurnStateRecord}; -const TOPIC_BEFORE: &str = "agent::before_tool_call"; -const TOPIC_AFTER: &str = "agent::after_tool_call"; +const TOPIC_BEFORE: &str = "agent::before_function_call"; +const TOPIC_AFTER: &str = "agent::after_function_call"; const HOOK_TIMEOUT_MS: u64 = 10_000; /// Map `tool_use {name: "agent_call", input: {function, payload}}` back to -/// a normal [`ToolCall`] carrying the inner function id. Non-`agent_call` -/// tool calls pass through unchanged so legacy/test fixtures keep working. -fn unwrap_agent_call(tc: ToolCall) -> ToolCall { - if tc.name != AGENT_CALL_TOOL_NAME { - return tc; +/// a normal [`FunctionCall`] carrying the inner function id. Non-`agent_call` +/// calls pass through unchanged so legacy/test fixtures keep working. +fn unwrap_agent_call(fc: FunctionCall) -> FunctionCall { + if fc.function_id != AGENT_CALL_TOOL_NAME { + return fc; } - let function = tc + let function = fc .arguments .get("function") .and_then(Value::as_str) .unwrap_or("") .to_string(); - let payload = tc + let payload = fc .arguments .get("payload") .cloned() .unwrap_or_else(|| json!({})); - ToolCall { - id: tc.id, - name: function, + FunctionCall { + id: fc.id, + function_id: function, arguments: payload, } } pub async fn handle_prepare(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<()> { - record.tool_results.clear(); - let raw = std::mem::take(&mut record.pending_tool_calls); - record.pending_tool_calls = raw.into_iter().map(unwrap_agent_call).collect(); + record.function_results.clear(); + let raw = std::mem::take(&mut record.pending_function_calls); + record.pending_function_calls = raw.into_iter().map(unwrap_agent_call).collect(); // run_request is immutable for a session; loading it here on every retry of - // ToolPrepare is wasteful but correct. Cache on TurnStateRecord if hot. + // FunctionPrepare is wasteful but correct. Cache on TurnStateRecord if hot. let run_request = persistence::load_run_request(iii, &record.session_id).await; let approval_required: Vec = run_request .get("approval_required") @@ -63,13 +63,13 @@ pub async fn handle_prepare(iii: &III, record: &mut TurnStateRecord) -> anyhow:: }) .unwrap_or_default(); - let mut prepared: Vec<(ToolCall, Option)> = - Vec::with_capacity(record.pending_tool_calls.len()); - for tc in record.pending_tool_calls.iter().cloned() { + let mut prepared: Vec<(FunctionCall, Option)> = + Vec::with_capacity(record.pending_function_calls.len()); + for fc in record.pending_function_calls.iter().cloned() { let merged = publish_collect( iii, TOPIC_BEFORE, - build_before_tool_call_payload(&tc, &approval_required), + build_before_function_call_payload(&fc, &approval_required), "first_block_wins", HOOK_TIMEOUT_MS, ) @@ -84,7 +84,7 @@ pub async fn handle_prepare(iii: &III, record: &mut TurnStateRecord) -> anyhow:: .and_then(Value::as_str) .unwrap_or("blocked") .to_string(); - Some(ToolResult { + Some(FunctionResult { content: vec![ContentBlock::Text(TextContent { text: reason })], details: json!({ "blocked": true }), terminate: false, @@ -92,7 +92,7 @@ pub async fn handle_prepare(iii: &III, record: &mut TurnStateRecord) -> anyhow:: } else { None }; - prepared.push((tc, prefilled)); + prepared.push((fc, prefilled)); } persistence::save_record(iii, record).await; @@ -100,52 +100,52 @@ pub async fn handle_prepare(iii: &III, record: &mut TurnStateRecord) -> anyhow:: persistence::save_executed_calls(iii, &record.session_id, &executed).await; persistence::save_prepared_calls(iii, &record.session_id, &prepared).await; - record.transition_to(TurnState::ToolExecute); + record.transition_to(TurnState::FunctionExecute); Ok(()) } pub async fn handle_execute(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<()> { let prepared = persistence::load_prepared_calls(iii, &record.session_id).await; let mut results = persistence::load_executed_calls(iii, &record.session_id).await; - for (tc, prefilled) in prepared { + for (fc, prefilled) in prepared { events::emit( iii, &record.session_id, - &AgentEvent::ToolExecutionStart { - tool_call_id: tc.id.clone(), - tool_name: tc.name.clone(), - args: tc.arguments.clone(), + &AgentEvent::FunctionExecutionStart { + function_call_id: fc.id.clone(), + function_id: fc.function_id.clone(), + args: fc.arguments.clone(), }, ) .await; if let Some(blocked) = prefilled { - persistence::upsert_executed_call(&mut results, (tc.clone(), blocked.clone(), true)); + persistence::upsert_executed_call(&mut results, (fc.clone(), blocked.clone(), true)); persistence::save_executed_calls(iii, &record.session_id, &results).await; - let evt = build_tool_execution_event(&tc, &blocked, true); + let evt = build_function_execution_event(&fc, &blocked, true); events::emit(iii, &record.session_id, &evt).await; continue; } if let Some((_, recorded, recorded_is_error)) = - persistence::find_executed_call(&results, &tc.id).cloned() + persistence::find_executed_call(&results, &fc.id).cloned() { - let evt = build_tool_execution_event(&tc, &recorded, recorded_is_error); + let evt = build_function_execution_event(&fc, &recorded, recorded_is_error); events::emit(iii, &record.session_id, &evt).await; continue; } - let mut augmented = match tc.arguments.clone() { + let mut augmented = match fc.arguments.clone() { Value::Object(o) => Value::Object(o), other => json!({ "arguments": other }), }; if let Some(obj) = augmented.as_object_mut() { obj.insert("session_id".into(), json!(record.session_id)); - obj.insert("tool_call_id".into(), json!(tc.id)); - obj.insert("tool_name".into(), json!(tc.name)); + obj.insert("function_call_id".into(), json!(fc.id)); + obj.insert("function_id".into(), json!(fc.function_id)); obj.insert( - "tool_call".into(), + "function_call".into(), json!({ - "id": tc.id.clone(), - "name": tc.name.clone(), - "arguments": tc.arguments.clone(), + "id": fc.id.clone(), + "function_id": fc.function_id.clone(), + "arguments": fc.arguments.clone(), }), ); } @@ -153,7 +153,7 @@ pub async fn handle_execute(iii: &III, record: &mut TurnStateRecord) -> anyhow:: let result = crate::agent_call::dispatch( iii, &record.session_id, - &json!(tc.name.clone()), + &json!(fc.function_id.clone()), augmented, ) .await; @@ -163,37 +163,37 @@ pub async fn handle_execute(iii: &III, record: &mut TurnStateRecord) -> anyhow:: .and_then(Value::as_str) .is_some(); - persistence::upsert_executed_call(&mut results, (tc.clone(), result.clone(), is_error)); + persistence::upsert_executed_call(&mut results, (fc.clone(), result.clone(), is_error)); persistence::save_executed_calls(iii, &record.session_id, &results).await; - let evt = build_tool_execution_event(&tc, &result, is_error); + let evt = build_function_execution_event(&fc, &result, is_error); events::emit(iii, &record.session_id, &evt).await; } - record.transition_to(TurnState::ToolFinalize); + record.transition_to(TurnState::FunctionFinalize); Ok(()) } pub async fn handle_finalize(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<()> { let executed = persistence::load_executed_calls(iii, &record.session_id).await; - let mut tool_results: Vec = Vec::with_capacity(executed.len()); + let mut function_results: Vec = Vec::with_capacity(executed.len()); let mut all_terminate = !executed.is_empty(); - for (tc, mut result, is_error) in executed { + for (fc, mut result, is_error) in executed { let merged = publish_collect( iii, TOPIC_AFTER, - json!({ "tool_call": tc, "result": result }), + json!({ "function_call": &fc, "result": &result }), "field_merge", HOOK_TIMEOUT_MS, ) .await; - if let Ok(after) = serde_json::from_value::(merged.clone()) { + if let Ok(after) = serde_json::from_value::(merged.clone()) { result = after; } if !result.terminate { all_terminate = false; } - tool_results.push(ToolResultMessage { - tool_call_id: tc.id, - tool_name: tc.name, + function_results.push(FunctionResultMessage { + function_call_id: fc.id, + function_id: fc.function_id, content: result.content, details: result.details, is_error, @@ -202,34 +202,28 @@ pub async fn handle_finalize(iii: &III, record: &mut TurnStateRecord) -> anyhow: } let mut messages = persistence::load_messages(iii, &record.session_id).await; - for r in &tool_results { - messages.push(AgentMessage::ToolResult(r.clone())); + for r in &function_results { + messages.push(AgentMessage::FunctionResult(r.clone())); } persistence::save_messages(iii, &record.session_id, &messages).await; let Some(last_assistant) = record.last_assistant.clone() else { - // The state machine should only transition to ToolFinalize from - // AssistantFinished, which always populates last_assistant. If we - // ever land here (resume after crash mid-turn, persistence - // corruption, or a bug elsewhere), end the turn cleanly instead of - // panicking. The lifecycle events tied to last_assistant are - // skipped; the tool_results are still persisted above. tracing::warn!( session_id = %record.session_id, - "ToolFinalize reached without last_assistant; tearing down without lifecycle emit" + "FunctionFinalize reached without last_assistant; tearing down without lifecycle emit" ); - record.tool_results = tool_results; - record.pending_tool_calls.clear(); + record.function_results = function_results; + record.pending_function_calls.clear(); record.transition_to(TurnState::TearingDown); return Ok(()); }; - for evt in build_finalize_lifecycle(&last_assistant, &tool_results) { + for evt in build_finalize_lifecycle(&last_assistant, &function_results) { events::emit(iii, &record.session_id, &evt).await; } record.turn_end_emitted = true; - record.tool_results = tool_results; - record.pending_tool_calls.clear(); + record.function_results = function_results; + record.pending_function_calls.clear(); if all_terminate { record.transition_to(TurnState::TearingDown); } else { @@ -239,52 +233,52 @@ pub async fn handle_finalize(iii: &III, record: &mut TurnStateRecord) -> anyhow: } pub(crate) fn executed_staging_for_new_prepare_batch( - _stale: &[(ToolCall, ToolResult, bool)], -) -> Vec<(ToolCall, ToolResult, bool)> { + _stale: &[(FunctionCall, FunctionResult, bool)], +) -> Vec<(FunctionCall, FunctionResult, bool)> { Vec::new() } -/// Pure helper: build the inner payload for the `agent::before_tool_call` -/// topic. Subscribers (policy-denylist, approval-gate) read this shape. -pub(crate) fn build_before_tool_call_payload(tc: &ToolCall, approval_required: &[String]) -> Value { +/// Pure helper: inner payload for the `agent::before_function_call` topic. +pub(crate) fn build_before_function_call_payload( + fc: &FunctionCall, + approval_required: &[String], +) -> Value { json!({ - "tool_call": tc, + "function_call": fc, "approval_required": approval_required, }) } -/// Pure helper: build the [`AgentEvent::ToolExecutionEnd`] for one tool. -pub(crate) fn build_tool_execution_event( - tc: &ToolCall, - result: &ToolResult, +/// Pure helper: build [`AgentEvent::FunctionExecutionEnd`] for one call. +pub(crate) fn build_function_execution_event( + fc: &FunctionCall, + result: &FunctionResult, is_error: bool, ) -> AgentEvent { - AgentEvent::ToolExecutionEnd { - tool_call_id: tc.id.clone(), - tool_name: tc.name.clone(), + AgentEvent::FunctionExecutionEnd { + function_call_id: fc.id.clone(), + function_id: fc.function_id.clone(), is_error, result: result.clone(), } } -/// Pure helper: build the lifecycle events emitted at the end of a -/// tool-bearing turn: `MessageStart`/`MessageEnd` per tool result, then -/// one `TurnEnd` carrying the assistant message and all tool results. +/// Lifecycle events at the end of a function-bearing turn. pub(crate) fn build_finalize_lifecycle( assistant: &AssistantMessage, - tool_results: &[ToolResultMessage], + function_results: &[FunctionResultMessage], ) -> Vec { - let mut events = Vec::with_capacity(tool_results.len() * 2 + 1); - for r in tool_results { - let m = AgentMessage::ToolResult(r.clone()); - events.push(AgentEvent::MessageStart { message: m.clone() }); - events.push(AgentEvent::MessageEnd { message: m }); + let mut out = Vec::with_capacity(function_results.len() * 2 + 1); + for r in function_results { + let m = AgentMessage::FunctionResult(r.clone()); + out.push(AgentEvent::MessageStart { message: m.clone() }); + out.push(AgentEvent::MessageEnd { message: m }); } - events.push(AgentEvent::TurnEnd { + out.push(AgentEvent::TurnEnd { message: AgentMessage::Assistant(assistant.clone()), - tool_results: tool_results.to_vec(), + function_results: function_results.to_vec(), }); - events + out } async fn publish_collect( @@ -315,100 +309,94 @@ async fn publish_collect( #[cfg(test)] mod tests { use super::*; - use harness_types::{AgentEvent, AssistantMessage, ContentBlock, TextContent, ToolCall}; + use harness_types::{AgentEvent, AssistantMessage, ContentBlock, FunctionCall, TextContent}; - fn tc(id: &str, name: &str, args: serde_json::Value) -> ToolCall { - ToolCall { + fn fc(id: &str, function_id: &str, args: serde_json::Value) -> FunctionCall { + FunctionCall { id: id.into(), - name: name.into(), + function_id: function_id.into(), arguments: args, } } #[test] fn standard_agent_call_unwraps_to_inner() { - let input = tc( + let input = fc( "call_1", "agent_call", json!({ "function": "shell::filesystem::ls", "payload": { "path": "/tmp" } }), ); let out = unwrap_agent_call(input); assert_eq!(out.id, "call_1"); - assert_eq!(out.name, "shell::filesystem::ls"); + assert_eq!(out.function_id, "shell::filesystem::ls"); assert_eq!(out.arguments, json!({ "path": "/tmp" })); } #[test] fn missing_payload_defaults_to_empty_object() { - let input = tc( + let input = fc( "call_2", "agent_call", json!({ "function": "skills::list" }), ); let out = unwrap_agent_call(input); - assert_eq!(out.name, "skills::list"); + assert_eq!(out.function_id, "skills::list"); assert_eq!(out.arguments, json!({})); } #[test] fn non_agent_call_returns_unchanged() { - let input = tc("call_3", "shell::filesystem::ls", json!({ "path": "/tmp" })); + let input = fc("call_3", "shell::filesystem::ls", json!({ "path": "/tmp" })); let out = unwrap_agent_call(input.clone()); assert_eq!(out, input); } #[test] - fn missing_function_field_unwraps_to_empty_name() { - let input = tc("call_4", "agent_call", json!({ "payload": { "x": 1 } })); + fn missing_function_field_unwraps_to_empty_function_id() { + let input = fc("call_4", "agent_call", json!({ "payload": { "x": 1 } })); let out = unwrap_agent_call(input); - assert_eq!(out.name, ""); + assert_eq!(out.function_id, ""); assert_eq!(out.arguments, json!({ "x": 1 })); } #[test] - fn unwrapped_tool_calls_replace_agent_call_in_place() { + fn unwrapped_calls_replace_agent_call_in_place() { let calls = vec![ - tc( + fc( "a", "agent_call", json!({"function":"shell::filesystem::ls","payload":{"path":"/tmp"}}), ), - tc("b", "skills::list", json!({})), + fc("b", "skills::list", json!({})), ]; let unwrapped: Vec<_> = calls.into_iter().map(unwrap_agent_call).collect(); - assert_eq!(unwrapped[0].name, "shell::filesystem::ls"); + assert_eq!(unwrapped[0].function_id, "shell::filesystem::ls"); assert_eq!(unwrapped[0].arguments, json!({"path":"/tmp"})); - assert_eq!(unwrapped[1].name, "skills::list"); + assert_eq!(unwrapped[1].function_id, "skills::list"); } - /// REGRESSION (plan-eng-review §3): `handle_execute` must route through - /// `agent_call::dispatch`, never `iii.trigger(tc.name, ...)`, directly. + /// REGRESSION: `handle_execute` must route through `agent_call::dispatch`. #[test] - fn handle_execute_does_not_call_iii_trigger_with_tc_name_directly() { - let src = include_str!("tools.rs"); + fn handle_execute_does_not_call_iii_trigger_with_fc_name_directly() { + let src = include_str!("functions.rs"); let start = src .find("pub async fn handle_execute") .expect("handle_execute exists"); let window = &src[start..start + src[start..].len().min(5000)]; - assert!( - !window.contains("function_id: tc.name"), - "handle_execute must dispatch via agent_call::dispatch, \ - not iii.trigger(tc.name, ...) directly." - ); assert!( window.contains("agent_call::dispatch"), "handle_execute must call agent_call::dispatch" ); } - fn assistant_with_tool_call(name: &str) -> AssistantMessage { + fn assistant_with_function_call(function_id: &str) -> AssistantMessage { AssistantMessage { - content: vec![ContentBlock::ToolCall { + content: vec![ContentBlock::FunctionCall { id: "tc-1".into(), - name: name.into(), + function_id: function_id.into(), arguments: json!({}), }], - stop_reason: harness_types::StopReason::Tool, + stop_reason: harness_types::StopReason::FunctionCall, error_message: None, error_kind: None, usage: None, @@ -418,10 +406,10 @@ mod tests { } } - fn tool_result_msg(name: &str, is_error: bool) -> ToolResultMessage { - ToolResultMessage { - tool_call_id: "tc-1".into(), - tool_name: name.into(), + fn function_result_msg(function_id: &str, is_error: bool) -> FunctionResultMessage { + FunctionResultMessage { + function_call_id: "tc-1".into(), + function_id: function_id.into(), content: vec![ContentBlock::Text(TextContent { text: "done".into(), })], @@ -434,12 +422,12 @@ mod tests { #[test] fn new_prepare_batch_clears_stale_executed_call_ids() { let stale = vec![( - ToolCall { + FunctionCall { id: "tc-1".into(), - name: "read".into(), + function_id: "read".into(), arguments: json!({}), }, - ToolResult { + FunctionResult { content: vec![], details: json!({}), terminate: false, @@ -455,74 +443,74 @@ mod tests { } #[test] - fn build_tool_execution_event_carries_tool_name_and_error_flag() { - let tc = ToolCall { + fn build_function_execution_event_carries_function_id_and_error_flag() { + let fc = FunctionCall { id: "tc-1".into(), - name: "read".into(), + function_id: "read".into(), arguments: json!({"path": "/tmp/x"}), }; - let result = ToolResult { + let result = FunctionResult { content: vec![ContentBlock::Text(TextContent { text: "ok".into() })], details: json!({}), terminate: false, }; - let evt = build_tool_execution_event(&tc, &result, false); + let evt = build_function_execution_event(&fc, &result, false); match evt { - AgentEvent::ToolExecutionEnd { - tool_name, + AgentEvent::FunctionExecutionEnd { + function_id, is_error, .. } => { - assert_eq!(tool_name, "read"); + assert_eq!(function_id, "read"); assert!(!is_error); } - other => panic!("expected ToolExecutionEnd, got {other:?}"), + other => panic!("expected FunctionExecutionEnd, got {other:?}"), } } #[test] - fn build_tool_execution_event_marks_blocked_tool_as_error() { - let tc = ToolCall { + fn build_function_execution_event_marks_blocked_as_error() { + let fc = FunctionCall { id: "tc-2".into(), - name: "bash".into(), + function_id: "bash".into(), arguments: json!({"command": "rm -rf /"}), }; - let blocked = ToolResult { + let blocked = FunctionResult { content: vec![ContentBlock::Text(TextContent { text: "blocked by policy".into(), })], details: json!({"blocked": true}), terminate: false, }; - let evt = build_tool_execution_event(&tc, &blocked, true); + let evt = build_function_execution_event(&fc, &blocked, true); match evt { - AgentEvent::ToolExecutionEnd { - tool_name, + AgentEvent::FunctionExecutionEnd { + function_id, is_error, result, .. } => { - assert_eq!(tool_name, "bash"); + assert_eq!(function_id, "bash"); assert!(is_error); assert!(matches!( result.content.first(), Some(ContentBlock::Text(t)) if t.text == "blocked by policy" )); } - other => panic!("expected ToolExecutionEnd, got {other:?}"), + other => panic!("expected FunctionExecutionEnd, got {other:?}"), } } #[test] - fn before_tool_call_payload_carries_approval_required() { - let tc = ToolCall { + fn before_function_call_payload_carries_approval_required() { + let fc = FunctionCall { id: "tc-1".into(), - name: "shell::filesystem::write".into(), + function_id: "shell::filesystem::write".into(), arguments: json!({"path": "/tmp/x"}), }; let approval_required = vec!["shell::filesystem::write".to_string()]; - let inner = build_before_tool_call_payload(&tc, &approval_required); - assert_eq!(inner["tool_call"]["id"], "tc-1"); + let inner = build_before_function_call_payload(&fc, &approval_required); + assert_eq!(inner["function_call"]["id"], "tc-1"); assert_eq!( inner["approval_required"], json!(["shell::filesystem::write"]), @@ -530,84 +518,65 @@ mod tests { } #[test] - fn before_tool_call_payload_has_empty_approval_required_when_none_configured() { - let tc = ToolCall { + fn before_function_call_payload_has_empty_approval_required_when_none_configured() { + let fc = FunctionCall { id: "tc-1".into(), - name: "shell::filesystem::ls".into(), + function_id: "shell::filesystem::ls".into(), arguments: json!({}), }; - let inner = build_before_tool_call_payload(&tc, &[]); + let inner = build_before_function_call_payload(&fc, &[]); assert_eq!(inner["approval_required"], json!([])); } #[test] - fn build_finalize_lifecycle_emits_pair_per_tool_then_turn_end() { - let asst = assistant_with_tool_call("read"); + fn build_finalize_lifecycle_emits_pair_per_result_then_turn_end() { + let asst = assistant_with_function_call("read"); let results = vec![ - tool_result_msg("read", false), - tool_result_msg("write", false), + function_result_msg("read", false), + function_result_msg("write", false), ]; - let events = build_finalize_lifecycle(&asst, &results); - assert_eq!(events.len(), 5); - assert!(matches!(&events[0], AgentEvent::MessageStart { .. })); - assert!(matches!(events.last(), Some(AgentEvent::TurnEnd { .. }))); + let evs = build_finalize_lifecycle(&asst, &results); + assert_eq!(evs.len(), 5); + assert!(matches!(&evs[0], AgentEvent::MessageStart { .. })); + assert!(matches!(evs.last(), Some(AgentEvent::TurnEnd { .. }))); } - // ── Adversarial unit tests added per plan - // /Users/ytallolayon/.claude/plans/let-s-implement-more-tests-refactored-flask.md - - /// Wire-contract regression guard. policy-denylist subscribes to - /// `agent::before_tool_call` by exact name; renaming the constant - /// here silently breaks the policy gate. Same risk for the after- - /// hook. Keep these strings stable or coordinate the rename. + /// policy-denylist subscribes to this topic by exact name. #[test] fn topic_constants_are_stable() { - assert_eq!(TOPIC_BEFORE, "agent::before_tool_call"); - assert_eq!(TOPIC_AFTER, "agent::after_tool_call"); + assert_eq!(TOPIC_BEFORE, "agent::before_function_call"); + assert_eq!(TOPIC_AFTER, "agent::after_function_call"); } - /// Pin the shape of the payload the policy hook subscribers consume. - /// `tool_call.name` is what `policy-denylist` matches against - /// `POLICY_DENIED_TOOLS`; if this field is renamed or moved, the - /// gate fails open silently. + /// `function_call.function_id` is what `policy-denylist` matches against + /// `POLICY_DENIED_FUNCTIONS`. #[test] - fn build_before_tool_call_payload_preserves_tool_call_shape() { - let tc = ToolCall { + fn build_before_function_call_payload_preserves_function_call_shape() { + let fc = FunctionCall { id: "tc-1".into(), - name: "shell::filesystem::ls".into(), + function_id: "shell::filesystem::ls".into(), arguments: json!({"path": "/tmp"}), }; - let inner = build_before_tool_call_payload(&tc, &[]); - assert_eq!(inner["tool_call"]["id"], "tc-1"); - assert_eq!(inner["tool_call"]["name"], "shell::filesystem::ls"); - assert_eq!(inner["tool_call"]["arguments"], json!({"path": "/tmp"})); + let inner = build_before_function_call_payload(&fc, &[]); + assert_eq!(inner["function_call"]["id"], "tc-1"); + assert_eq!( + inner["function_call"]["function_id"], + "shell::filesystem::ls" + ); + assert_eq!(inner["function_call"]["arguments"], json!({"path": "/tmp"})); assert!(inner.get("approval_required").is_some()); } - // TODO(test-harden): tools.rs's `handle_finalize` calls .expect() on - // record.last_assistant. If the state machine ever transitions to - // ToolFinalize without an assistant message (resume after crash mid- - // AwaitingAssistant, concurrency bug, manual record forgery), the - // orchestrator panics and crashes the session. - // - // Real fix: replace .expect() with a graceful transition to - // TearingDown + AgentError event. - // - /// Source-grep regression guard: the panic path - /// `.expect("tools state requires last_assistant…")` in - /// `handle_finalize` must stay removed. A full functional test would - /// need a stub `iii::III`; until that lands, this prevents reverts. #[test] fn handle_finalize_does_not_expect_last_assistant() { - let src = include_str!("tools.rs"); + let src = include_str!("functions.rs"); let start = src .find("pub async fn handle_finalize") .expect("handle_finalize exists"); let window = &src[start..start + src[start..].len().min(3000)]; assert!( !window.contains(".expect(\"tools state requires last_assistant"), - "handle_finalize must not .expect() last_assistant; \ - use a let-else that gracefully transitions to TearingDown." + "handle_finalize must not .expect() last_assistant" ); } } diff --git a/turn-orchestrator/src/states/mod.rs b/turn-orchestrator/src/states/mod.rs index 530e191e1..0729e8302 100644 --- a/turn-orchestrator/src/states/mod.rs +++ b/turn-orchestrator/src/states/mod.rs @@ -1,11 +1,11 @@ pub mod assistant; +pub mod functions; pub mod provisioning; pub mod steering; pub mod tearing_down; -pub mod tools; pub use assistant::{handle_awaiting, handle_finished, handle_streaming}; +pub use functions::{handle_execute, handle_finalize, handle_prepare}; pub use provisioning::handle as handle_provisioning; pub use steering::handle as handle_steering; pub use tearing_down::handle as handle_tearing_down; -pub use tools::{handle_execute, handle_finalize, handle_prepare}; diff --git a/turn-orchestrator/src/states/provisioning.rs b/turn-orchestrator/src/states/provisioning.rs index 2d2f0c782..2d2828117 100644 --- a/turn-orchestrator/src/states/provisioning.rs +++ b/turn-orchestrator/src/states/provisioning.rs @@ -3,23 +3,23 @@ use iii_sdk::{TriggerRequest, Value, III}; use serde_json::json; +use crate::agent_call; use crate::persistence; use crate::state::{TurnState, TurnStateRecord}; use crate::system_prompt; -use crate::tools_catalog; pub async fn handle(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<()> { let request = persistence::load_run_request(iii, &record.session_id).await; - let tools = json!([tools_catalog::agent_call_tool()]); - persistence::save_tool_schemas(iii, &record.session_id, tools.clone()).await; + let tools = json!([agent_call::agent_call_tool()]); + persistence::save_function_schemas(iii, &record.session_id, tools.clone()).await; let override_prompt = request .get("system_prompt") .and_then(Value::as_str) .filter(|s| !s.is_empty()); let cwd = request.get("cwd").and_then(Value::as_str); - let skills_index = fetch_skills_index(iii).await; + let skills_index = fetch_skills_bootstrap(iii).await; let prompt = system_prompt::build(skills_index.as_deref(), cwd, override_prompt); let mut updated = request.clone(); if let Some(obj) = updated.as_object_mut() { @@ -36,21 +36,122 @@ pub async fn handle(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<( Ok(()) } -/// Best-effort fetch of the `iii://skills` index. Returns `None` on any -/// failure — provisioning must not block on a skills worker that isn't -/// up yet. The fallback section in `system_prompt::build` covers this. -async fn fetch_skills_index(iii: &III) -> Option { +/// Best-effort bootstrap of the skills surface for the system prompt. +/// +/// Concatenates: +/// 1. the auto-rendered `iii://skills` index (links to every registered skill), and +/// 2. the bodies of every **root-depth** registered skill (no `/` in the id), +/// batched in a single `skill::fetch` call. +/// +/// The agent therefore boots with both the table of contents AND the +/// router-style bodies the agent normally reaches for first — eliminating +/// the round-trip to `skill::fetch iii://skills` on the first turn. +/// +/// Any sub-step that fails returns `None` for that piece; the caller +/// degrades gracefully (the fallback section in `system_prompt::build` +/// covers a fully missing skills surface). +async fn fetch_skills_bootstrap(iii: &III) -> Option { + let index = fetch_uri(iii, "iii://skills").await; + let root_uris = list_root_skill_uris(iii).await; + let bodies = if root_uris.is_empty() { + None + } else { + fetch_uris_batched(iii, &root_uris).await + }; + + match (index, bodies) { + (Some(idx), Some(bod)) => Some(format!("{idx}\n\n---\n\n# Root skill bodies\n\n{bod}")), + (Some(idx), None) => Some(idx), + (None, Some(bod)) => Some(bod), + (None, None) => None, + } +} + +/// Fetch a single `iii://` URI via `skill::fetch`. Tolerates either a raw +/// string response or `{ body: "..." }` envelope. +async fn fetch_uri(iii: &III, uri: &str) -> Option { let resp = iii .trigger(TriggerRequest { function_id: "skill::fetch".into(), - payload: json!({ "uri": "iii://skills" }), + payload: json!({ "uri": uri }), action: None, timeout_ms: Some(5_000), }) .await .ok()?; + response_to_string(&resp) +} + +/// Batch-fetch many URIs in one round trip. `skill::fetch` joins them with +/// `\n\n---\n\n` already, so the return is a single concatenated body. +async fn fetch_uris_batched(iii: &III, uris: &[String]) -> Option { + let resp = iii + .trigger(TriggerRequest { + function_id: "skill::fetch".into(), + payload: json!({ "uris": uris }), + action: None, + timeout_ms: Some(10_000), + }) + .await + .ok()?; + response_to_string(&resp) +} + +/// List every registered skill id and keep only **root-depth** ones (no +/// `/` in the id). Empty list on any failure. +async fn list_root_skill_uris(iii: &III) -> Vec { + let Ok(resp) = iii + .trigger(TriggerRequest { + function_id: "skills::list".into(), + payload: json!({}), + action: None, + timeout_ms: Some(5_000), + }) + .await + else { + return Vec::new(); + }; + let Some(arr) = resp.get("skills").and_then(Value::as_array) else { + return Vec::new(); + }; + arr.iter() + .filter_map(|entry| entry.get("id").and_then(Value::as_str)) + .filter(|id| is_root_skill_id(id)) + .map(|id| format!("iii://{id}")) + .collect() +} + +/// `iii` and `harness` are root; `resend/email`, `shell/bash` are not. +fn is_root_skill_id(id: &str) -> bool { + !id.is_empty() && !id.contains('/') +} + +fn response_to_string(resp: &Value) -> Option { if let Some(s) = resp.as_str() { return Some(s.to_string()); } resp.get("body").and_then(Value::as_str).map(str::to_string) } + +#[cfg(test)] +mod tests { + use super::is_root_skill_id; + + #[test] + fn root_ids_have_no_slash() { + assert!(is_root_skill_id("iii")); + assert!(is_root_skill_id("harness")); + assert!(is_root_skill_id("shell-bash")); + } + + #[test] + fn nested_ids_are_not_root() { + assert!(!is_root_skill_id("resend/email")); + assert!(!is_root_skill_id("shell/bash/exec")); + } + + #[test] + fn empty_id_is_not_root() { + assert!(!is_root_skill_id("")); + } +} diff --git a/turn-orchestrator/src/states/steering.rs b/turn-orchestrator/src/states/steering.rs index 09d546111..3eded594e 100644 --- a/turn-orchestrator/src/states/steering.rs +++ b/turn-orchestrator/src/states/steering.rs @@ -8,60 +8,129 @@ use crate::events; use crate::persistence; use crate::state::{TurnState, TurnStateRecord}; -pub async fn handle(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<()> { - if abort_set(iii, &record.session_id).await { - // Abort: build a legacy-shaped aborted message, persist it onto - // the transcript, then emit TurnEnd carrying it. Mirror of - // `provider-router/src/loop_state.rs:139-148` and - // `loop_state.rs:321-332`. - let aborted = aborted_message(); - let mut messages = persistence::load_messages(iii, &record.session_id).await; - messages.push(AgentMessage::Assistant(aborted.clone())); - persistence::save_messages(iii, &record.session_id, &messages).await; - record.last_assistant = Some(aborted.clone()); - if !record.turn_end_emitted { - events::emit( - iii, - &record.session_id, - &AgentEvent::TurnEnd { - message: AgentMessage::Assistant(aborted), - tool_results: Vec::new(), - }, - ) - .await; - record.turn_end_emitted = true; - } - record.transition_to(TurnState::TearingDown); - return Ok(()); - } +/// Pure routing decision for `steering_check`. Lifted out of `handle` so +/// every branch is unit-testable without an `III` instance. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum SteeringRoute { + /// Abort flag was set: synthesize an aborted assistant message and tear down. + Abort, + /// External `steering` inbox had messages: append them and run another assistant turn. + Steering, + /// External `followup` inbox had messages: append them and run another assistant turn. + Followup, + /// No external messages but a function-call batch just finished — feed + /// the function results back to the assistant for a follow-up turn. + /// **This is the branch that was missing pre-fix.** + ContinueAfterFunction, + /// Nothing more to do — emit `TurnEnd` and tear down. + EndTurn, +} - let steering = drain_queue(iii, "steering", &record.session_id).await; - if !steering.is_empty() { - emit_turn_end_once(iii, record).await; - let mut messages = persistence::load_messages(iii, &record.session_id).await; - messages.extend(steering); - persistence::save_messages(iii, &record.session_id, &messages).await; - record.transition_to(TurnState::AwaitingAssistant); - return Ok(()); +// Four bools is one over the clippy default. Each represents an +// independent precondition checked in priority order; collapsing them +// into a struct or bitflag obscures the call site, where naming each +// argument at the boundary is the readability win. +#[allow(clippy::fn_params_excessive_bools)] +pub(crate) fn route( + abort: bool, + has_steering: bool, + has_followup: bool, + has_function_results: bool, +) -> SteeringRoute { + if abort { + SteeringRoute::Abort + } else if has_steering { + SteeringRoute::Steering + } else if has_followup { + SteeringRoute::Followup + } else if has_function_results { + SteeringRoute::ContinueAfterFunction + } else { + SteeringRoute::EndTurn } +} - let followup = drain_queue(iii, "followup", &record.session_id).await; - if !followup.is_empty() { - emit_turn_end_once(iii, record).await; - let mut messages = persistence::load_messages(iii, &record.session_id).await; - messages.extend(followup); - persistence::save_messages(iii, &record.session_id, &messages).await; - record.transition_to(TurnState::AwaitingAssistant); - return Ok(()); - } +pub async fn handle(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<()> { + let abort = abort_set(iii, &record.session_id).await; + let steering = if abort { + Vec::new() + } else { + drain_queue(iii, "steering", &record.session_id).await + }; + let followup = if abort || !steering.is_empty() { + Vec::new() + } else { + drain_queue(iii, "followup", &record.session_id).await + }; - emit_turn_end_once(iii, record).await; - record.transition_to(TurnState::TearingDown); + match route( + abort, + !steering.is_empty(), + !followup.is_empty(), + !record.function_results.is_empty(), + ) { + SteeringRoute::Abort => { + // Abort: build a legacy-shaped aborted message, persist it onto + // the transcript, then emit TurnEnd carrying it. Mirror of + // `provider-router/src/loop_state.rs:139-148` and + // `loop_state.rs:321-332`. + let aborted = aborted_message(); + let mut messages = persistence::load_messages(iii, &record.session_id).await; + messages.push(AgentMessage::Assistant(aborted.clone())); + persistence::save_messages(iii, &record.session_id, &messages).await; + record.last_assistant = Some(aborted.clone()); + if !record.turn_end_emitted { + events::emit( + iii, + &record.session_id, + &AgentEvent::TurnEnd { + message: AgentMessage::Assistant(aborted), + function_results: Vec::new(), + }, + ) + .await; + record.turn_end_emitted = true; + } + record.transition_to(TurnState::TearingDown); + } + SteeringRoute::Steering => { + emit_turn_end_once(iii, record).await; + let mut messages = persistence::load_messages(iii, &record.session_id).await; + messages.extend(steering); + persistence::save_messages(iii, &record.session_id, &messages).await; + // Function results (if any) are already in `messages`; clear the + // transient signal so the next SteeringCheck doesn't re-loop. + record.function_results.clear(); + record.transition_to(TurnState::AwaitingAssistant); + } + SteeringRoute::Followup => { + emit_turn_end_once(iii, record).await; + let mut messages = persistence::load_messages(iii, &record.session_id).await; + messages.extend(followup); + persistence::save_messages(iii, &record.session_id, &messages).await; + record.function_results.clear(); + record.transition_to(TurnState::AwaitingAssistant); + } + SteeringRoute::ContinueAfterFunction => { + // The previous turn already emitted TurnEnd in `function_finalize` + // (see `states/functions.rs::handle_finalize` — `turn_end_emitted = true`). + // The next turn will emit its own TurnStart in `handle_awaiting`. + // Function results are already persisted into `messages` by + // finalize, so the next assistant turn picks them up via + // `persistence::load_messages`. Just clear the transient signal. + record.function_results.clear(); + record.transition_to(TurnState::AwaitingAssistant); + } + SteeringRoute::EndTurn => { + emit_turn_end_once(iii, record).await; + record.transition_to(TurnState::TearingDown); + } + } Ok(()) } /// Emit `TurnEnd` only when the current turn hasn't already emitted one -/// (`tools::handle_finalize` and `assistant::handle_awaiting`/`handle_finished`'s +/// (`functions::handle_finalize` and `assistant::handle_awaiting`/`handle_finished`'s /// terminating branches set `turn_end_emitted = true`). For no-tool turns /// reaching `steering_check` from `handle_finished` directly, this is the /// single emission point. Mirrors legacy `loop_state.rs:194-200`. @@ -86,7 +155,7 @@ async fn emit_turn_end_once(iii: &III, record: &mut TurnStateRecord) { &record.session_id, &AgentEvent::TurnEnd { message, - tool_results: Vec::new(), + function_results: Vec::new(), }, ) .await; @@ -166,4 +235,57 @@ mod tests { assert_eq!(m.error_message.as_deref(), Some("aborted")); assert!(m.content.is_empty()); } + + // ── route() coverage ────────────────────────────────────────────── + // Pins every branch of the steering-check routing decision so a + // future refactor can't silently drop a transition. Writing these as + // pure-fn assertions instead of integration tests keeps them fast + // and removes the III dependency. + + #[test] + fn route_abort_wins_over_everything() { + // Abort flag dominates even when queues and function_results are present. + assert_eq!(route(true, true, true, true), SteeringRoute::Abort); + assert_eq!(route(true, false, false, false), SteeringRoute::Abort); + } + + #[test] + fn route_steering_takes_precedence_over_followup_and_function_results() { + assert_eq!( + route(false, true, true, true), + SteeringRoute::Steering, + "steering inbox messages must be drained before followup or function-results continuation" + ); + } + + #[test] + fn route_followup_takes_precedence_over_function_results() { + assert_eq!( + route(false, false, true, true), + SteeringRoute::Followup, + "followup inbox messages must be drained before function-results continuation" + ); + } + + /// **Regression pin: the bug that caused the agent to stop after a function call.** + /// + /// Before the fix, `SteeringCheck` reached from `FunctionFinalize` with + /// no external messages fell through to `EndTurn`, so the model never + /// saw the function result. This test asserts the new branch routes + /// the loop back to `AwaitingAssistant` instead. + #[test] + fn route_continues_to_assistant_when_function_results_present() { + assert_eq!( + route(false, false, false, true), + SteeringRoute::ContinueAfterFunction, + "post-function SteeringCheck must continue to AwaitingAssistant so the model sees the result" + ); + } + + #[test] + fn route_ends_turn_when_nothing_pending() { + // No function call, no inbox messages, no abort — this is a clean + // text-only assistant response: the turn legitimately ends here. + assert_eq!(route(false, false, false, false), SteeringRoute::EndTurn); + } } diff --git a/turn-orchestrator/src/system_prompt.rs b/turn-orchestrator/src/system_prompt.rs index 520273bec..2561dd726 100644 --- a/turn-orchestrator/src/system_prompt.rs +++ b/turn-orchestrator/src/system_prompt.rs @@ -34,9 +34,9 @@ pub fn build( let skills_section = match skills_index { Some(s) if !s.is_empty() => format!( - "## Available skills\n\n{s}\n\nCall `skill::fetch` via `agent_call` to load any `iii://` URI you see above when you need its full content." + "## Available skills\n\n{s}\n\nThe section above already contains the skills index AND the bodies of every root-level skill — do NOT call `skill::fetch` for any `iii://` URI listed above; you already have its content. Use `skill::fetch` ONLY to load deeper sub-skill URIs (e.g. `iii://resend/email/send`) that are referenced from a root body but not inlined here. If a function id isn't covered by what's loaded, call `engine::functions::list` via `agent_call` to confirm it exists and read its `request_format`. Never invent function ids." ), - _ => "## Available skills\n\n(Skills index not loaded — call `skill::fetch` via `agent_call` with `uri: \"iii://skills\"` to discover what's registered.)".to_string(), + _ => "## Available skills\n\n(Skills index not loaded — call `skill::fetch` via `agent_call` with `uri: \"iii://skills\"` to discover what's registered. For the live function set + schemas, use `engine::functions::list`.)".to_string(), }; format!("{BASE_BODY}\n\n{cwd_section}{skills_section}") @@ -69,7 +69,31 @@ mod tests { assert!(out.contains("/work/proj")); assert!(out.contains("## Available skills")); assert!(out.contains("iii://skills/echo")); - assert!(out.contains("`skill::fetch` via `agent_call`")); + assert!( + out.contains("do NOT call `skill::fetch`"), + "must instruct against re-fetching root URIs already inlined" + ); + assert!( + out.contains("`skill::fetch`"), + "must still mention `skill::fetch` for deeper sub-skill loads" + ); + } + + /// Pins the reconciliation with `harness/docs/iii-skill.md`: skills are + /// curated docs *over* the live function set; `engine::functions::list` + /// is the source of truth for existence + schemas. A revert to + /// "skills index = source of truth" would drop these substrings. + #[test] + fn skills_section_points_at_engine_functions_list_for_uncovered_ids() { + let out = build(Some("- iii://skills/echo"), None, None); + assert!( + out.contains("engine::functions::list"), + "skills section must direct the agent to the live function set" + ); + assert!( + out.contains("Never invent function ids"), + "skills section must keep the no-invention rule" + ); } #[test] @@ -77,6 +101,10 @@ mod tests { let out = build(None, Some("/tmp"), None); assert!(out.contains("Skills index not loaded")); assert!(out.contains("iii://skills")); + assert!( + out.contains("engine::functions::list"), + "fallback must still point at the live function set" + ); } #[test] diff --git a/turn-orchestrator/src/tools_catalog.rs b/turn-orchestrator/src/tools_catalog.rs deleted file mode 100644 index b5ba02f1b..000000000 --- a/turn-orchestrator/src/tools_catalog.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! LLM tool catalog. After the iii-native refactor (spec -//! `docs/superpowers/specs/2026-05-07-iii-native-harness-design.md`) the -//! catalog has exactly one entry, `agent_call`. The schema lives in -//! `agent_call.rs`; this module re-exports it so existing callers keep -//! compiling. Delete this file once those callers are migrated to import -//! `crate::agent_call::agent_call_tool` directly. - -pub use crate::agent_call::agent_call_tool; diff --git a/turn-orchestrator/src/transitions.rs b/turn-orchestrator/src/transitions.rs index 6641f52b3..6fe039db5 100644 --- a/turn-orchestrator/src/transitions.rs +++ b/turn-orchestrator/src/transitions.rs @@ -13,9 +13,9 @@ pub async fn step(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<()> TurnState::AwaitingAssistant => states::handle_awaiting(iii, record).await?, TurnState::AssistantStreaming => states::handle_streaming(iii, record).await?, TurnState::AssistantFinished => states::handle_finished(iii, record).await?, - TurnState::ToolPrepare => states::handle_prepare(iii, record).await?, - TurnState::ToolExecute => states::handle_execute(iii, record).await?, - TurnState::ToolFinalize => states::handle_finalize(iii, record).await?, + TurnState::FunctionPrepare => states::handle_prepare(iii, record).await?, + TurnState::FunctionExecute => states::handle_execute(iii, record).await?, + TurnState::FunctionFinalize => states::handle_finalize(iii, record).await?, TurnState::SteeringCheck => states::handle_steering(iii, record).await?, TurnState::TearingDown => states::handle_tearing_down(iii, record).await?, TurnState::Stopped => { diff --git a/turn-orchestrator/tests/integration.rs b/turn-orchestrator/tests/integration.rs index 354bab17f..ecf154555 100644 --- a/turn-orchestrator/tests/integration.rs +++ b/turn-orchestrator/tests/integration.rs @@ -17,7 +17,7 @@ fn state_keys_distinct_per_facet() { turn_orchestrator::run_request_key(s), turn_orchestrator::cwd_key(s), turn_orchestrator::sandbox_id_key(s), - turn_orchestrator::tool_schemas_key(s), + turn_orchestrator::function_schemas_key(s), ]; let unique: std::collections::HashSet<_> = keys.iter().collect(); assert_eq!(unique.len(), keys.len(), "every facet has a distinct key");