From 1fe4f14b5bdc9a9f71fc26e7c2d4bfb3717dfcd0 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 8 May 2026 09:31:52 -0300 Subject: [PATCH 1/7] refactor(harness-types): rename tool-related types to function-related types Updated the ContentBlock and AgentMessage enums to replace ToolCall and ToolResult with FunctionCall and FunctionResult, respectively. Adjusted serialization/deserialization to maintain backward compatibility with legacy tool names. Refactored related tests and state management to reflect these changes, ensuring the system correctly handles function calls and results. This enhances clarity and aligns with the new function-based architecture. --- README.md | 2 +- approval-gate/README.md | 4 +- approval-gate/iii.worker.yaml | 4 +- approval-gate/src/lib.rs | 145 +++-- approval-gate/tests/integration.rs | 16 +- harness/ARCHITECTURE.md | 18 +- harness/scripts/demo.sh | 11 +- harness/src/fanout.rs | 12 +- harness/web/src/App.tsx | 7 +- harness/web/src/components/ApprovalRow.tsx | 28 +- .../web/src/components/FunctionCallBlock.tsx | 27 + .../web/src/components/FunctionPalette.tsx | 6 +- .../src/components/FunctionResultBlock.tsx | 33 + harness/web/src/components/SessionView.tsx | 34 +- harness/web/src/components/StatusTab.tsx | 12 +- harness/web/src/export.test.ts | 2 +- harness/web/src/export.ts | 18 +- harness/web/src/reducer.test.ts | 7 +- harness/web/src/reducer.ts | 27 +- harness/web/src/types.ts | 57 +- harness/web/src/useStatus.test.ts | 37 +- harness/web/src/useStatus.ts | 25 +- harness/web/tests/e2e/approval.spec.ts | 2 +- hook-fanout/README.md | 4 +- hook-fanout/src/lib.rs | 8 +- policy-denylist/README.md | 24 +- policy-denylist/iii.worker.yaml | 6 +- policy-denylist/src/config.rs | 37 +- policy-denylist/src/lib.rs | 65 +- policy-denylist/src/main.rs | 45 +- policy-denylist/src/manifest.rs | 2 +- provider-anthropic/src/lib.rs | 79 +-- provider-anthropic/tests/integration.rs | 8 +- .../crates/provider-base/src/iii_register.rs | 16 +- .../crates/provider-base/src/openai_compat.rs | 44 +- provider-openai/src/lib.rs | 10 +- provider-router/src/register.rs | 10 +- registry/index.json | 2 +- session-tree/src/lib.rs | 20 +- .../crates/harness-types/src/agent_event.rs | 109 +++- .../crates/harness-types/src/agent_message.rs | 28 +- .../crates/harness-types/src/content.rs | 34 +- .../crates/harness-types/src/function.rs | 121 ++++ .../crates/harness-types/src/lib.rs | 10 +- .../crates/harness-types/src/stream_event.rs | 12 +- turn-orchestrator/src/agent_call.rs | 30 +- turn-orchestrator/src/lib.rs | 5 +- turn-orchestrator/src/persistence.rs | 140 +++-- turn-orchestrator/src/state.rs | 49 +- turn-orchestrator/src/states/assistant.rs | 42 +- turn-orchestrator/src/states/functions.rs | 580 ++++++++++++++++++ turn-orchestrator/src/states/mod.rs | 4 +- turn-orchestrator/src/states/provisioning.rs | 6 +- turn-orchestrator/src/states/steering.rs | 6 +- turn-orchestrator/src/transitions.rs | 6 +- turn-orchestrator/tests/integration.rs | 2 +- 56 files changed, 1607 insertions(+), 491 deletions(-) create mode 100644 harness/web/src/components/FunctionCallBlock.tsx create mode 100644 harness/web/src/components/FunctionResultBlock.tsx create mode 100644 turn-orchestrator/crates/harness-types/src/function.rs create mode 100644 turn-orchestrator/src/states/functions.rs 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..df65ca96e 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,7 @@ 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 +81,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 +103,17 @@ 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 +124,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 +168,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 +183,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 +224,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 +383,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 +402,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 +411,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 +419,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 +430,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 +442,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 +455,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 +497,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 +546,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 +557,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 +577,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 +661,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 +676,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 +711,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 +744,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 +825,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/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..263e1fcb3 100644 --- a/harness/src/fanout.rs +++ b/harness/src/fanout.rs @@ -566,7 +566,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 +609,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, ); } @@ -969,7 +973,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 +988,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/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..134daed8a --- /dev/null +++ b/harness/web/src/components/FunctionCallBlock.tsx @@ -0,0 +1,27 @@ +import { useState } from "react"; + +interface Props { + functionId: string; + args: unknown; +} + +export function FunctionCallBlock({ functionId, args }: Props) { + const [open, setOpen] = useState(false); + 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..57fc989ef --- /dev/null +++ b/harness/web/src/components/FunctionResultBlock.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; + +interface Props { + functionId: string; + isError: boolean; + output: string; +} + +const COLLAPSED_LIMIT = 600; + +export function FunctionResultBlock({ functionId, 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"} + {functionId} +
+
{visible}
+ {truncated ? ( + + ) : null} +
+ ); +} diff --git a/harness/web/src/components/SessionView.tsx b/harness/web/src/components/SessionView.tsx index c714adeff..ff59a286c 100644 --- a/harness/web/src/components/SessionView.tsx +++ b/harness/web/src/components/SessionView.tsx @@ -1,7 +1,7 @@ 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"; interface Props { sessionId: string; @@ -19,26 +19,44 @@ interface Props { 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 === "tool_result" || m.role === "function_result") return "function"; + return "message"; } function renderBlocks(m: AgentMessage) { 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 === "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") { + if (b.type === "tool_result" || b.type === "function_result" || b.type === "functionResult") { 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); + const fid = + typeof b.function_id === "string" + ? b.function_id + : typeof b.tool_name === "string" + ? b.tool_name + : "function"; return ( - 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/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/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..cb8a735d0 100644 --- a/policy-denylist/src/config.rs +++ b/policy-denylist/src/config.rs @@ -5,28 +5,31 @@ 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 +48,7 @@ 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..924d0b11c 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,17 @@ 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 +310,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 +318,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 +329,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 +351,7 @@ 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 +364,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 +376,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 +384,19 @@ 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/src/lib.rs b/provider-anthropic/src/lib.rs index 23094bf79..bc2ed4c4a 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,7 @@ 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 +522,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 +556,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 +608,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,9 +653,9 @@ 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(), + 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, @@ -671,7 +671,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 +747,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 +768,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 +785,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 +797,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/provider-base/src/iii_register.rs b/provider-openai/crates/provider-base/src/iii_register.rs index d3414e9e0..2ace466ac 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, + AgentMessage, AgentFunction, 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,7 @@ 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 +115,7 @@ 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 +147,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 +237,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..304b447eb 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/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/src/lib.rs b/session-tree/src/lib.rs index e8b587ba1..dc4c50d62 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,16 @@ 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/crates/harness-types/src/agent_event.rs b/turn-orchestrator/crates/harness-types/src/agent_event.rs index 8040e33ee..2f50270d6 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::stream_event::AssistantMessageEvent; -use crate::tool::ToolResult; +use crate::function::FunctionResult; /// 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..4eea86d23 100644 --- a/turn-orchestrator/crates/harness-types/src/agent_message.rs +++ b/turn-orchestrator/crates/harness-types/src/agent_message.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; use crate::stream_event::{ErrorKind, StopReason, Usage}; use crate::thinking::ThinkingLevel; -use crate::tool::AgentTool; +use crate::function::AgentFunction; /// 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..9d7848e89 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,28 @@ 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..0430a080c 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 stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; -pub use tool::{ - AgentTool, CacheRetention, ExecutionMode, FinalizedToolCall, PreparedToolCall, ToolCall, - ToolResult, Transport, +pub use function::{ + AgentFunction, CacheRetention, ExecutionMode, FinalizedFunctionCall, FunctionCall, + FunctionResult, PreparedFunctionCall, 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/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..8ea5869df 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,24 @@ 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 +316,57 @@ 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 +379,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 +407,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 +424,21 @@ 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"), + 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/functions.rs b/turn-orchestrator/src/states/functions.rs new file mode 100644 index 000000000..89c5adfd3 --- /dev/null +++ b/turn-orchestrator/src/states/functions.rs @@ -0,0 +1,580 @@ +//! `function_prepare`, `function_execute`, `function_finalize` handlers. + +use harness_types::{ + AgentEvent, AgentMessage, AssistantMessage, ContentBlock, FunctionCall, FunctionResult, + FunctionResultMessage, TextContent, +}; +use iii_sdk::{TriggerRequest, Value, III}; +use serde_json::json; + +use crate::agent_call::TOOL_NAME as AGENT_CALL_TOOL_NAME; +use crate::events; +use crate::persistence; +use crate::state::{TurnState, TurnStateRecord}; + +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 [`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 = fc + .arguments + .get("function") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let payload = fc + .arguments + .get("payload") + .cloned() + .unwrap_or_else(|| json!({})); + FunctionCall { + id: fc.id, + function_id: function, + arguments: payload, + } +} +pub async fn handle_prepare(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<()> { + 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 + // 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") + .and_then(|v| match serde_json::from_value::>(v.clone()) { + Ok(list) => Some(list), + Err(err) => { + tracing::warn!( + %err, + session_id = %record.session_id, + "approval_required malformed in run_request; treating as empty" + ); + None + } + }) + .unwrap_or_default(); + + 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_function_call_payload(&fc, &approval_required), + "first_block_wins", + HOOK_TIMEOUT_MS, + ) + .await; + let blocked = merged + .get("block") + .and_then(Value::as_bool) + .unwrap_or(false); + let prefilled = if blocked { + let reason = merged + .get("reason") + .and_then(Value::as_str) + .unwrap_or("blocked") + .to_string(); + Some(FunctionResult { + content: vec![ContentBlock::Text(TextContent { text: reason })], + details: json!({ "blocked": true }), + terminate: false, + }) + } else { + None + }; + prepared.push((fc, prefilled)); + } + + persistence::save_record(iii, record).await; + let executed = executed_staging_for_new_prepare_batch(&[]); + persistence::save_executed_calls(iii, &record.session_id, &executed).await; + persistence::save_prepared_calls(iii, &record.session_id, &prepared).await; + + 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 (fc, prefilled) in prepared { + events::emit( + iii, + &record.session_id, + &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, (fc.clone(), blocked.clone(), true)); + persistence::save_executed_calls(iii, &record.session_id, &results).await; + 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, &fc.id).cloned() + { + let evt = build_function_execution_event(&fc, &recorded, recorded_is_error); + events::emit(iii, &record.session_id, &evt).await; + continue; + } + 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("function_call_id".into(), json!(fc.id)); + obj.insert("function_id".into(), json!(fc.function_id)); + obj.insert( + "function_call".into(), + json!({ + "id": fc.id.clone(), + "function_id": fc.function_id.clone(), + "arguments": fc.arguments.clone(), + }), + ); + } + + let result = crate::agent_call::dispatch( + iii, + &record.session_id, + &json!(fc.function_id.clone()), + augmented, + ) + .await; + let is_error = result + .details + .get("error") + .and_then(Value::as_str) + .is_some(); + + 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_function_execution_event(&fc, &result, is_error); + events::emit(iii, &record.session_id, &evt).await; + } + 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 function_results: Vec = + Vec::with_capacity(executed.len()); + let mut all_terminate = !executed.is_empty(); + for (fc, mut result, is_error) in executed { + let merged = publish_collect( + iii, + TOPIC_AFTER, + json!({ "function_call": &fc, "result": &result }), + "field_merge", + HOOK_TIMEOUT_MS, + ) + .await; + if let Ok(after) = serde_json::from_value::(merged.clone()) { + result = after; + } + if !result.terminate { + all_terminate = false; + } + function_results.push(FunctionResultMessage { + function_call_id: fc.id, + function_id: fc.function_id, + content: result.content, + details: result.details, + is_error, + timestamp: chrono::Utc::now().timestamp_millis(), + }); + } + + let mut messages = persistence::load_messages(iii, &record.session_id).await; + 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 { + tracing::warn!( + session_id = %record.session_id, + "FunctionFinalize reached without last_assistant; tearing down without lifecycle emit" + ); + 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, &function_results) { + events::emit(iii, &record.session_id, &evt).await; + } + record.turn_end_emitted = true; + + record.function_results = function_results; + record.pending_function_calls.clear(); + if all_terminate { + record.transition_to(TurnState::TearingDown); + } else { + record.transition_to(TurnState::SteeringCheck); + } + Ok(()) +} + +pub(crate) fn executed_staging_for_new_prepare_batch( + _stale: &[(FunctionCall, FunctionResult, bool)], +) -> Vec<(FunctionCall, FunctionResult, bool)> { + Vec::new() +} + +/// 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!({ + "function_call": fc, + "approval_required": approval_required, + }) +} + +/// Pure helper: build [`AgentEvent::FunctionExecutionEnd`] for one call. +pub(crate) fn build_function_execution_event( + fc: &FunctionCall, + result: &FunctionResult, + is_error: bool, +) -> AgentEvent { + AgentEvent::FunctionExecutionEnd { + function_call_id: fc.id.clone(), + function_id: fc.function_id.clone(), + is_error, + result: result.clone(), + } +} + +/// Lifecycle events at the end of a function-bearing turn. +pub(crate) fn build_finalize_lifecycle( + assistant: &AssistantMessage, + function_results: &[FunctionResultMessage], +) -> Vec { + 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 }); + } + out.push(AgentEvent::TurnEnd { + message: AgentMessage::Assistant(assistant.clone()), + function_results: function_results.to_vec(), + }); + out +} + +async fn publish_collect( + iii: &III, + topic: &str, + inner: Value, + merge_rule: &str, + timeout_ms: u64, +) -> Value { + let payload = json!({ + "topic": topic, + "payload": inner, + "merge_rule": merge_rule, + "timeout_ms": timeout_ms, + }); + iii.trigger(TriggerRequest { + function_id: "hook-fanout::publish_collect".into(), + payload, + action: None, + timeout_ms: None, + }) + .await + .ok() + .and_then(|v| v.get("merged").cloned()) + .unwrap_or_else(|| json!({})) +} + +#[cfg(test)] +mod tests { + use super::*; + use harness_types::{AgentEvent, AssistantMessage, ContentBlock, FunctionCall, TextContent}; + + fn fc(id: &str, function_id: &str, args: serde_json::Value) -> FunctionCall { + FunctionCall { + id: id.into(), + function_id: function_id.into(), + arguments: args, + } + } + + #[test] + fn standard_agent_call_unwraps_to_inner() { + 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.function_id, "shell::filesystem::ls"); + assert_eq!(out.arguments, json!({ "path": "/tmp" })); + } + + #[test] + fn missing_payload_defaults_to_empty_object() { + let input = fc( + "call_2", + "agent_call", + json!({ "function": "skills::list" }), + ); + let out = unwrap_agent_call(input); + assert_eq!(out.function_id, "skills::list"); + assert_eq!(out.arguments, json!({})); + } + + #[test] + fn non_agent_call_returns_unchanged() { + 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_function_id() { + let input = fc("call_4", "agent_call", json!({ "payload": { "x": 1 } })); + let out = unwrap_agent_call(input); + assert_eq!(out.function_id, ""); + assert_eq!(out.arguments, json!({ "x": 1 })); + } + + #[test] + fn unwrapped_calls_replace_agent_call_in_place() { + let calls = vec![ + fc( + "a", + "agent_call", + json!({"function":"shell::filesystem::ls","payload":{"path":"/tmp"}}), + ), + fc("b", "skills::list", json!({})), + ]; + let unwrapped: Vec<_> = calls.into_iter().map(unwrap_agent_call).collect(); + assert_eq!(unwrapped[0].function_id, "shell::filesystem::ls"); + assert_eq!(unwrapped[0].arguments, json!({"path":"/tmp"})); + assert_eq!(unwrapped[1].function_id, "skills::list"); + } + + /// REGRESSION: `handle_execute` must route through `agent_call::dispatch`. + #[test] + 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("agent_call::dispatch"), + "handle_execute must call agent_call::dispatch" + ); + } + + fn assistant_with_function_call(function_id: &str) -> AssistantMessage { + AssistantMessage { + content: vec![ContentBlock::FunctionCall { + id: "tc-1".into(), + function_id: function_id.into(), + arguments: json!({}), + }], + stop_reason: harness_types::StopReason::FunctionCall, + error_message: None, + error_kind: None, + usage: None, + model: "m".into(), + provider: "p".into(), + timestamp: 0, + } + } + + 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(), + })], + details: json!({}), + is_error, + timestamp: 0, + } + } + + #[test] + fn new_prepare_batch_clears_stale_executed_call_ids() { + let stale = vec![( + FunctionCall { + id: "tc-1".into(), + function_id: "read".into(), + arguments: json!({}), + }, + FunctionResult { + content: vec![], + details: json!({}), + terminate: false, + }, + false, + )]; + + let reset = executed_staging_for_new_prepare_batch(&stale); + + assert!(persistence::find_executed_call(&stale, "tc-1").is_some()); + assert!(persistence::find_executed_call(&reset, "tc-1").is_none()); + assert!(reset.is_empty()); + } + + #[test] + fn build_function_execution_event_carries_function_id_and_error_flag() { + let fc = FunctionCall { + id: "tc-1".into(), + function_id: "read".into(), + arguments: json!({"path": "/tmp/x"}), + }; + let result = FunctionResult { + content: vec![ContentBlock::Text(TextContent { text: "ok".into() })], + details: json!({}), + terminate: false, + }; + let evt = build_function_execution_event(&fc, &result, false); + match evt { + AgentEvent::FunctionExecutionEnd { + function_id, + is_error, + .. + } => { + assert_eq!(function_id, "read"); + assert!(!is_error); + } + other => panic!("expected FunctionExecutionEnd, got {other:?}"), + } + } + + #[test] + fn build_function_execution_event_marks_blocked_as_error() { + let fc = FunctionCall { + id: "tc-2".into(), + function_id: "bash".into(), + arguments: json!({"command": "rm -rf /"}), + }; + let blocked = FunctionResult { + content: vec![ContentBlock::Text(TextContent { + text: "blocked by policy".into(), + })], + details: json!({"blocked": true}), + terminate: false, + }; + let evt = build_function_execution_event(&fc, &blocked, true); + match evt { + AgentEvent::FunctionExecutionEnd { + function_id, + is_error, + result, + .. + } => { + 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 FunctionExecutionEnd, got {other:?}"), + } + } + + #[test] + fn before_function_call_payload_carries_approval_required() { + let fc = FunctionCall { + id: "tc-1".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_function_call_payload(&fc, &approval_required); + assert_eq!(inner["function_call"]["id"], "tc-1"); + assert_eq!( + inner["approval_required"], + json!(["shell::filesystem::write"]), + ); + } + + #[test] + fn before_function_call_payload_has_empty_approval_required_when_none_configured() { + let fc = FunctionCall { + id: "tc-1".into(), + function_id: "shell::filesystem::ls".into(), + arguments: json!({}), + }; + let inner = build_before_function_call_payload(&fc, &[]); + assert_eq!(inner["approval_required"], json!([])); + } + + #[test] + fn build_finalize_lifecycle_emits_pair_per_result_then_turn_end() { + let asst = assistant_with_function_call("read"); + let results = vec![ + function_result_msg("read", false), + function_result_msg("write", false), + ]; + 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 { .. }))); + } + + /// policy-denylist subscribes to this topic by exact name. + #[test] + fn topic_constants_are_stable() { + assert_eq!(TOPIC_BEFORE, "agent::before_function_call"); + assert_eq!(TOPIC_AFTER, "agent::after_function_call"); + } + + /// `function_call.function_id` is what `policy-denylist` matches against + /// `POLICY_DENIED_FUNCTIONS`. + #[test] + fn build_before_function_call_payload_preserves_function_call_shape() { + let fc = FunctionCall { + id: "tc-1".into(), + function_id: "shell::filesystem::ls".into(), + 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()); + } + + #[test] + fn handle_finalize_does_not_expect_last_assistant() { + 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" + ); + } +} 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..f9e4ef982 100644 --- a/turn-orchestrator/src/states/provisioning.rs +++ b/turn-orchestrator/src/states/provisioning.rs @@ -3,16 +3,16 @@ 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") diff --git a/turn-orchestrator/src/states/steering.rs b/turn-orchestrator/src/states/steering.rs index 09d546111..2cd4ce0da 100644 --- a/turn-orchestrator/src/states/steering.rs +++ b/turn-orchestrator/src/states/steering.rs @@ -25,7 +25,7 @@ pub async fn handle(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<( &record.session_id, &AgentEvent::TurnEnd { message: AgentMessage::Assistant(aborted), - tool_results: Vec::new(), + function_results: Vec::new(), }, ) .await; @@ -61,7 +61,7 @@ pub async fn handle(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<( } /// 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 +86,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; 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"); From 91ea65e2432ad7ff6f13e40aaa1ad45cb9f5fd16 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 8 May 2026 09:35:43 -0300 Subject: [PATCH 2/7] refactor(harness): update Makefile and remove obsolete components Modified the Makefile to ensure the engine process writes its PID to the correct location. Removed the ToolResultBlock and ToolUseBlock components from the web interface, as they are no longer needed. This streamlines the codebase in preparation for a function-based architecture, enhancing maintainability and clarity. --- harness/Makefile | 2 +- .../web/src/components/ToolResultBlock.tsx | 33 - harness/web/src/components/ToolUseBlock.tsx | 27 - .../crates/harness-types/src/agent_event.rs | 109 +++- .../crates/harness-types/src/agent_message.rs | 28 +- .../crates/harness-types/src/content.rs | 34 +- .../crates/harness-types/src/function.rs | 60 +- .../crates/harness-types/src/lib.rs | 10 +- .../crates/harness-types/src/stream_event.rs | 12 +- .../crates/provider-base/src/iii_register.rs | 16 +- .../crates/provider-base/src/openai_compat.rs | 44 +- .../crates/harness-types/src/agent_event.rs | 109 +++- .../crates/harness-types/src/agent_message.rs | 28 +- .../crates/harness-types/src/content.rs | 34 +- .../crates/harness-types/src/function.rs | 60 +- .../crates/harness-types/src/lib.rs | 10 +- .../crates/harness-types/src/stream_event.rs | 12 +- .../crates/harness-types/src/agent_event.rs | 109 +++- .../crates/harness-types/src/agent_message.rs | 28 +- .../crates/harness-types/src/content.rs | 34 +- .../crates/harness-types/src/function.rs | 60 +- .../crates/harness-types/src/lib.rs | 10 +- .../crates/harness-types/src/stream_event.rs | 12 +- .../crates/harness-types/src/agent_event.rs | 109 +++- .../crates/harness-types/src/agent_message.rs | 28 +- .../crates/harness-types/src/content.rs | 34 +- .../crates/harness-types/src/function.rs | 60 +- session-tree/crates/harness-types/src/lib.rs | 10 +- .../crates/harness-types/src/stream_event.rs | 12 +- turn-orchestrator/README.md | 2 +- .../crates/harness-types/src/tool.rs | 111 ---- turn-orchestrator/src/states/tools.rs | 613 ------------------ turn-orchestrator/src/tools_catalog.rs | 8 - 33 files changed, 712 insertions(+), 1156 deletions(-) delete mode 100644 harness/web/src/components/ToolResultBlock.tsx delete mode 100644 harness/web/src/components/ToolUseBlock.tsx rename provider-router/crates/harness-types/src/tool.rs => provider-anthropic/crates/harness-types/src/function.rs (56%) rename session-tree/crates/harness-types/src/tool.rs => provider-openai/crates/harness-types/src/function.rs (56%) rename provider-anthropic/crates/harness-types/src/tool.rs => provider-router/crates/harness-types/src/function.rs (56%) rename provider-openai/crates/harness-types/src/tool.rs => session-tree/crates/harness-types/src/function.rs (56%) delete mode 100644 turn-orchestrator/crates/harness-types/src/tool.rs delete mode 100644 turn-orchestrator/src/states/tools.rs delete mode 100644 turn-orchestrator/src/tools_catalog.rs 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/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/provider-anthropic/crates/harness-types/src/agent_event.rs b/provider-anthropic/crates/harness-types/src/agent_event.rs index 8040e33ee..2f50270d6 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::stream_event::AssistantMessageEvent; -use crate::tool::ToolResult; +use crate::function::FunctionResult; /// 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..4eea86d23 100644 --- a/provider-anthropic/crates/harness-types/src/agent_message.rs +++ b/provider-anthropic/crates/harness-types/src/agent_message.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; use crate::stream_event::{ErrorKind, StopReason, Usage}; use crate::thinking::ThinkingLevel; -use crate::tool::AgentTool; +use crate::function::AgentFunction; /// 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..9d7848e89 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,28 @@ 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..0430a080c 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 stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; -pub use tool::{ - AgentTool, CacheRetention, ExecutionMode, FinalizedToolCall, PreparedToolCall, ToolCall, - ToolResult, Transport, +pub use function::{ + AgentFunction, CacheRetention, ExecutionMode, FinalizedFunctionCall, FunctionCall, + FunctionResult, PreparedFunctionCall, 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..2ace466ac 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, + AgentMessage, AgentFunction, 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,7 @@ 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 +115,7 @@ 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 +147,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 +237,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..ada5e9d62 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-openai/crates/harness-types/src/agent_event.rs b/provider-openai/crates/harness-types/src/agent_event.rs index 8040e33ee..2f50270d6 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::stream_event::AssistantMessageEvent; -use crate::tool::ToolResult; +use crate::function::FunctionResult; /// 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..4eea86d23 100644 --- a/provider-openai/crates/harness-types/src/agent_message.rs +++ b/provider-openai/crates/harness-types/src/agent_message.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; use crate::stream_event::{ErrorKind, StopReason, Usage}; use crate::thinking::ThinkingLevel; -use crate::tool::AgentTool; +use crate::function::AgentFunction; /// 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..9d7848e89 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,28 @@ 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..0430a080c 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 stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; -pub use tool::{ - AgentTool, CacheRetention, ExecutionMode, FinalizedToolCall, PreparedToolCall, ToolCall, - ToolResult, Transport, +pub use function::{ + AgentFunction, CacheRetention, ExecutionMode, FinalizedFunctionCall, FunctionCall, + FunctionResult, PreparedFunctionCall, 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-router/crates/harness-types/src/agent_event.rs b/provider-router/crates/harness-types/src/agent_event.rs index 8040e33ee..2f50270d6 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::stream_event::AssistantMessageEvent; -use crate::tool::ToolResult; +use crate::function::FunctionResult; /// 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..4eea86d23 100644 --- a/provider-router/crates/harness-types/src/agent_message.rs +++ b/provider-router/crates/harness-types/src/agent_message.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; use crate::stream_event::{ErrorKind, StopReason, Usage}; use crate::thinking::ThinkingLevel; -use crate::tool::AgentTool; +use crate::function::AgentFunction; /// 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..9d7848e89 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,28 @@ 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..0430a080c 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 stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; -pub use tool::{ - AgentTool, CacheRetention, ExecutionMode, FinalizedToolCall, PreparedToolCall, ToolCall, - ToolResult, Transport, +pub use function::{ + AgentFunction, CacheRetention, ExecutionMode, FinalizedFunctionCall, FunctionCall, + FunctionResult, PreparedFunctionCall, 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/session-tree/crates/harness-types/src/agent_event.rs b/session-tree/crates/harness-types/src/agent_event.rs index 8040e33ee..2f50270d6 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::stream_event::AssistantMessageEvent; -use crate::tool::ToolResult; +use crate::function::FunctionResult; /// 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..4eea86d23 100644 --- a/session-tree/crates/harness-types/src/agent_message.rs +++ b/session-tree/crates/harness-types/src/agent_message.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::content::ContentBlock; use crate::stream_event::{ErrorKind, StopReason, Usage}; use crate::thinking::ThinkingLevel; -use crate::tool::AgentTool; +use crate::function::AgentFunction; /// 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..9d7848e89 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,28 @@ 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..0430a080c 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 stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; -pub use tool::{ - AgentTool, CacheRetention, ExecutionMode, FinalizedToolCall, PreparedToolCall, ToolCall, - ToolResult, Transport, +pub use function::{ + AgentFunction, CacheRetention, ExecutionMode, FinalizedFunctionCall, FunctionCall, + FunctionResult, PreparedFunctionCall, 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/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/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/states/tools.rs b/turn-orchestrator/src/states/tools.rs deleted file mode 100644 index 79510c402..000000000 --- a/turn-orchestrator/src/states/tools.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! `tool_prepare`, `tool_execute`, `tool_finalize` handlers. - -use harness_types::{ - AgentEvent, AgentMessage, AssistantMessage, ContentBlock, TextContent, ToolCall, ToolResult, - ToolResultMessage, -}; -use iii_sdk::{TriggerRequest, Value, III}; -use serde_json::json; - -use crate::agent_call::TOOL_NAME as AGENT_CALL_TOOL_NAME; -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 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; - } - let function = tc - .arguments - .get("function") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - let payload = tc - .arguments - .get("payload") - .cloned() - .unwrap_or_else(|| json!({})); - ToolCall { - id: tc.id, - name: 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(); - - // run_request is immutable for a session; loading it here on every retry of - // ToolPrepare 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") - .and_then(|v| match serde_json::from_value::>(v.clone()) { - Ok(list) => Some(list), - Err(err) => { - tracing::warn!( - %err, - session_id = %record.session_id, - "approval_required malformed in run_request; treating as empty" - ); - None - } - }) - .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 merged = publish_collect( - iii, - TOPIC_BEFORE, - build_before_tool_call_payload(&tc, &approval_required), - "first_block_wins", - HOOK_TIMEOUT_MS, - ) - .await; - let blocked = merged - .get("block") - .and_then(Value::as_bool) - .unwrap_or(false); - let prefilled = if blocked { - let reason = merged - .get("reason") - .and_then(Value::as_str) - .unwrap_or("blocked") - .to_string(); - Some(ToolResult { - content: vec![ContentBlock::Text(TextContent { text: reason })], - details: json!({ "blocked": true }), - terminate: false, - }) - } else { - None - }; - prepared.push((tc, prefilled)); - } - - persistence::save_record(iii, record).await; - let executed = executed_staging_for_new_prepare_batch(&[]); - 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); - 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 { - events::emit( - iii, - &record.session_id, - &AgentEvent::ToolExecutionStart { - tool_call_id: tc.id.clone(), - tool_name: tc.name.clone(), - args: tc.arguments.clone(), - }, - ) - .await; - if let Some(blocked) = prefilled { - persistence::upsert_executed_call(&mut results, (tc.clone(), blocked.clone(), true)); - persistence::save_executed_calls(iii, &record.session_id, &results).await; - let evt = build_tool_execution_event(&tc, &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() - { - let evt = build_tool_execution_event(&tc, &recorded, recorded_is_error); - events::emit(iii, &record.session_id, &evt).await; - continue; - } - let mut augmented = match tc.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( - "tool_call".into(), - json!({ - "id": tc.id.clone(), - "name": tc.name.clone(), - "arguments": tc.arguments.clone(), - }), - ); - } - - let result = crate::agent_call::dispatch( - iii, - &record.session_id, - &json!(tc.name.clone()), - augmented, - ) - .await; - let is_error = result - .details - .get("error") - .and_then(Value::as_str) - .is_some(); - - persistence::upsert_executed_call(&mut results, (tc.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); - events::emit(iii, &record.session_id, &evt).await; - } - record.transition_to(TurnState::ToolFinalize); - 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 all_terminate = !executed.is_empty(); - for (tc, mut result, is_error) in executed { - let merged = publish_collect( - iii, - TOPIC_AFTER, - json!({ "tool_call": tc, "result": result }), - "field_merge", - HOOK_TIMEOUT_MS, - ) - .await; - 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, - content: result.content, - details: result.details, - is_error, - timestamp: chrono::Utc::now().timestamp_millis(), - }); - } - - let mut messages = persistence::load_messages(iii, &record.session_id).await; - for r in &tool_results { - messages.push(AgentMessage::ToolResult(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" - ); - record.tool_results = tool_results; - record.pending_tool_calls.clear(); - record.transition_to(TurnState::TearingDown); - return Ok(()); - }; - for evt in build_finalize_lifecycle(&last_assistant, &tool_results) { - events::emit(iii, &record.session_id, &evt).await; - } - record.turn_end_emitted = true; - - record.tool_results = tool_results; - record.pending_tool_calls.clear(); - if all_terminate { - record.transition_to(TurnState::TearingDown); - } else { - record.transition_to(TurnState::SteeringCheck); - } - Ok(()) -} - -pub(crate) fn executed_staging_for_new_prepare_batch( - _stale: &[(ToolCall, ToolResult, bool)], -) -> Vec<(ToolCall, ToolResult, 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 { - json!({ - "tool_call": tc, - "approval_required": approval_required, - }) -} - -/// Pure helper: build the [`AgentEvent::ToolExecutionEnd`] for one tool. -pub(crate) fn build_tool_execution_event( - tc: &ToolCall, - result: &ToolResult, - is_error: bool, -) -> AgentEvent { - AgentEvent::ToolExecutionEnd { - tool_call_id: tc.id.clone(), - tool_name: tc.name.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. -pub(crate) fn build_finalize_lifecycle( - assistant: &AssistantMessage, - tool_results: &[ToolResultMessage], -) -> 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 }); - } - events.push(AgentEvent::TurnEnd { - message: AgentMessage::Assistant(assistant.clone()), - tool_results: tool_results.to_vec(), - }); - events -} - -async fn publish_collect( - iii: &III, - topic: &str, - inner: Value, - merge_rule: &str, - timeout_ms: u64, -) -> Value { - let payload = json!({ - "topic": topic, - "payload": inner, - "merge_rule": merge_rule, - "timeout_ms": timeout_ms, - }); - iii.trigger(TriggerRequest { - function_id: "hook-fanout::publish_collect".into(), - payload, - action: None, - timeout_ms: None, - }) - .await - .ok() - .and_then(|v| v.get("merged").cloned()) - .unwrap_or_else(|| json!({})) -} - -#[cfg(test)] -mod tests { - use super::*; - use harness_types::{AgentEvent, AssistantMessage, ContentBlock, TextContent, ToolCall}; - - fn tc(id: &str, name: &str, args: serde_json::Value) -> ToolCall { - ToolCall { - id: id.into(), - name: name.into(), - arguments: args, - } - } - - #[test] - fn standard_agent_call_unwraps_to_inner() { - let input = tc( - "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.arguments, json!({ "path": "/tmp" })); - } - - #[test] - fn missing_payload_defaults_to_empty_object() { - let input = tc( - "call_2", - "agent_call", - json!({ "function": "skills::list" }), - ); - let out = unwrap_agent_call(input); - assert_eq!(out.name, "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 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 } })); - let out = unwrap_agent_call(input); - assert_eq!(out.name, ""); - assert_eq!(out.arguments, json!({ "x": 1 })); - } - - #[test] - fn unwrapped_tool_calls_replace_agent_call_in_place() { - let calls = vec![ - tc( - "a", - "agent_call", - json!({"function":"shell::filesystem::ls","payload":{"path":"/tmp"}}), - ), - tc("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].arguments, json!({"path":"/tmp"})); - assert_eq!(unwrapped[1].name, "skills::list"); - } - - /// REGRESSION (plan-eng-review §3): `handle_execute` must route through - /// `agent_call::dispatch`, never `iii.trigger(tc.name, ...)`, directly. - #[test] - fn handle_execute_does_not_call_iii_trigger_with_tc_name_directly() { - let src = include_str!("tools.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 { - AssistantMessage { - content: vec![ContentBlock::ToolCall { - id: "tc-1".into(), - name: name.into(), - arguments: json!({}), - }], - stop_reason: harness_types::StopReason::Tool, - error_message: None, - error_kind: None, - usage: None, - model: "m".into(), - provider: "p".into(), - timestamp: 0, - } - } - - fn tool_result_msg(name: &str, is_error: bool) -> ToolResultMessage { - ToolResultMessage { - tool_call_id: "tc-1".into(), - tool_name: name.into(), - content: vec![ContentBlock::Text(TextContent { - text: "done".into(), - })], - details: json!({}), - is_error, - timestamp: 0, - } - } - - #[test] - fn new_prepare_batch_clears_stale_executed_call_ids() { - let stale = vec![( - ToolCall { - id: "tc-1".into(), - name: "read".into(), - arguments: json!({}), - }, - ToolResult { - content: vec![], - details: json!({}), - terminate: false, - }, - false, - )]; - - let reset = executed_staging_for_new_prepare_batch(&stale); - - assert!(persistence::find_executed_call(&stale, "tc-1").is_some()); - assert!(persistence::find_executed_call(&reset, "tc-1").is_none()); - assert!(reset.is_empty()); - } - - #[test] - fn build_tool_execution_event_carries_tool_name_and_error_flag() { - let tc = ToolCall { - id: "tc-1".into(), - name: "read".into(), - arguments: json!({"path": "/tmp/x"}), - }; - let result = ToolResult { - content: vec![ContentBlock::Text(TextContent { text: "ok".into() })], - details: json!({}), - terminate: false, - }; - let evt = build_tool_execution_event(&tc, &result, false); - match evt { - AgentEvent::ToolExecutionEnd { - tool_name, - is_error, - .. - } => { - assert_eq!(tool_name, "read"); - assert!(!is_error); - } - other => panic!("expected ToolExecutionEnd, got {other:?}"), - } - } - - #[test] - fn build_tool_execution_event_marks_blocked_tool_as_error() { - let tc = ToolCall { - id: "tc-2".into(), - name: "bash".into(), - arguments: json!({"command": "rm -rf /"}), - }; - let blocked = ToolResult { - 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); - match evt { - AgentEvent::ToolExecutionEnd { - tool_name, - is_error, - result, - .. - } => { - assert_eq!(tool_name, "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:?}"), - } - } - - #[test] - fn before_tool_call_payload_carries_approval_required() { - let tc = ToolCall { - id: "tc-1".into(), - name: "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"); - assert_eq!( - inner["approval_required"], - json!(["shell::filesystem::write"]), - ); - } - - #[test] - fn before_tool_call_payload_has_empty_approval_required_when_none_configured() { - let tc = ToolCall { - id: "tc-1".into(), - name: "shell::filesystem::ls".into(), - arguments: json!({}), - }; - let inner = build_before_tool_call_payload(&tc, &[]); - 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"); - let results = vec![ - tool_result_msg("read", false), - tool_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 { .. }))); - } - - // ── 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. - #[test] - fn topic_constants_are_stable() { - assert_eq!(TOPIC_BEFORE, "agent::before_tool_call"); - assert_eq!(TOPIC_AFTER, "agent::after_tool_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. - #[test] - fn build_before_tool_call_payload_preserves_tool_call_shape() { - let tc = ToolCall { - id: "tc-1".into(), - name: "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"})); - 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 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." - ); - } -} 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; From 930ee0f8ea42831d6228dfaddde254340f0a24e3 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 8 May 2026 11:21:42 -0300 Subject: [PATCH 3/7] feat(harness): implement browser eviction and error handling improvements Added a new function `is_function_not_found` to check for specific error types related to unregistered functions. Implemented `evict_browser` to remove all sessions associated with a browser when its handler is no longer available. Updated the event handling logic to trigger browser eviction on encountering a "function not found" error. Enhanced tests to cover the new eviction functionality and error matching, ensuring robust handling of browser states and error scenarios. --- harness/src/fanout.rs | 98 ++++++++- harness/src/lib.rs | 55 +++++ harness/tests/skills_register.rs | 27 +++ turn-orchestrator/src/states/provisioning.rs | 113 +++++++++- turn-orchestrator/src/states/steering.rs | 210 +++++++++++++++---- turn-orchestrator/src/system_prompt.rs | 34 ++- 6 files changed, 481 insertions(+), 56 deletions(-) diff --git a/harness/src/fanout.rs b/harness/src/fanout.rs index 263e1fcb3..b4e26b78c 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)"); + } } }); } @@ -833,6 +873,60 @@ 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(); 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..d1d3272a4 100644 --- a/harness/tests/skills_register.rs +++ b/harness/tests/skills_register.rs @@ -11,3 +11,30 @@ 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/turn-orchestrator/src/states/provisioning.rs b/turn-orchestrator/src/states/provisioning.rs index f9e4ef982..2d2828117 100644 --- a/turn-orchestrator/src/states/provisioning.rs +++ b/turn-orchestrator/src/states/provisioning.rs @@ -19,7 +19,7 @@ pub async fn handle(iii: &III, record: &mut TurnStateRecord) -> anyhow::Result<( .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 2cd4ce0da..c72deb615 100644 --- a/turn-orchestrator/src/states/steering.rs +++ b/turn-orchestrator/src/states/steering.rs @@ -8,55 +8,119 @@ 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), - function_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(()); +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(()) } @@ -166,4 +230,60 @@ 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] From 728ede69e8d39ced1468c6441ed0c8260a8072cd Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 8 May 2026 11:44:44 -0300 Subject: [PATCH 4/7] feat(harness/web): enhance function call and result display with markdown support - Added a new `Markdown` component to render markdown content in the UI, utilizing `react-markdown` and `remark-gfm` for enhanced formatting. - Updated `FunctionCallBlock` to display function names dynamically based on arguments. - Enhanced `FunctionResultBlock` to detect output format (JSON, markdown, or text) and render accordingly, with support for expandable views. - Modified `SessionView` to integrate markdown rendering for assistant messages, improving readability and presentation. - Adjusted CSS styles for better layout and spacing in message components. --- harness/web/package.json | 4 +- .../web/src/components/FunctionCallBlock.tsx | 27 +- .../src/components/FunctionResultBlock.tsx | 105 ++++- harness/web/src/components/Markdown.tsx | 25 ++ harness/web/src/components/SessionView.tsx | 130 ++++-- harness/web/src/styles.css | 392 +++++++++++++++++- 6 files changed, 626 insertions(+), 57 deletions(-) create mode 100644 harness/web/src/components/Markdown.tsx 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/components/FunctionCallBlock.tsx b/harness/web/src/components/FunctionCallBlock.tsx index 134daed8a..f4093fa6c 100644 --- a/harness/web/src/components/FunctionCallBlock.tsx +++ b/harness/web/src/components/FunctionCallBlock.tsx @@ -5,8 +5,27 @@ interface Props { 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)}
diff --git a/harness/web/src/components/FunctionResultBlock.tsx b/harness/web/src/components/FunctionResultBlock.tsx index 57fc989ef..1603027a2 100644 --- a/harness/web/src/components/FunctionResultBlock.tsx +++ b/harness/web/src/components/FunctionResultBlock.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { Markdown } from "./Markdown"; interface Props { functionId: string; @@ -6,27 +7,99 @@ interface Props { output: string; } -const COLLAPSED_LIMIT = 600; +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 truncated = output.length > COLLAPSED_LIMIT; - const visible = expanded || !truncated ? output : output.slice(0, COLLAPSED_LIMIT) + "…"; + 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 ( -
-
+
+
-
{visible}
- {truncated ? ( - + {!open && preview ? ( + {preview} + ) : null} + + + {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 ff59a286c..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 { FunctionCallBlock } from "./FunctionCallBlock"; import { FunctionResultBlock } from "./FunctionResultBlock"; +import { Markdown } from "./Markdown"; + +const STICK_THRESHOLD_PX = 64; interface Props { sessionId: string; @@ -16,16 +21,64 @@ 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"; + 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 === "text") { + if (m.role === "assistant") { + return ( +
+ +
+ ); + } + return

{b.text}

; + } if ( b.type === "tool_use" || b.type === "tool_call" || @@ -42,11 +95,7 @@ function renderBlocks(m: AgentMessage) { return ; } if (b.type === "tool_result" || b.type === "function_result" || b.type === "functionResult") { - 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); + const text = blockText(b); const fid = typeof b.function_id === "string" ? b.function_id @@ -73,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 (
@@ -88,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/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 { From b67fce67e4ab00652c8586328d426187accbe25d Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Fri, 8 May 2026 11:48:02 -0300 Subject: [PATCH 5/7] =?UTF-8?q?style:=20apply=20cargo=20fmt=20across=20cra?= =?UTF-8?q?tes=20touched=20by=20tool=E2=86=92function=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rename touched many lines across vendored harness-types copies and the orchestrator state machine, leaving formatting that drifted from rustfmt's preference. Pure formatting; no behavior change. --- approval-gate/src/lib.rs | 8 +++-- harness/src/fanout.rs | 5 ++- harness/tests/skills_register.rs | 5 ++- policy-denylist/src/config.rs | 10 +++--- policy-denylist/src/lib.rs | 17 ++++++---- .../crates/harness-types/src/agent_event.rs | 2 +- .../crates/harness-types/src/agent_message.rs | 2 +- .../crates/harness-types/src/content.rs | 3 +- .../crates/harness-types/src/lib.rs | 4 +-- .../crates/provider-base/src/iii_register.rs | 14 ++++++-- .../crates/provider-base/src/openai_compat.rs | 4 +-- provider-anthropic/src/lib.rs | 21 +++++++----- .../crates/harness-types/src/agent_event.rs | 2 +- .../crates/harness-types/src/agent_message.rs | 2 +- .../crates/harness-types/src/content.rs | 3 +- .../crates/harness-types/src/lib.rs | 4 +-- .../crates/provider-base/src/iii_register.rs | 14 ++++++-- .../crates/provider-base/src/openai_compat.rs | 4 +-- .../crates/harness-types/src/agent_event.rs | 2 +- .../crates/harness-types/src/agent_message.rs | 2 +- .../crates/harness-types/src/content.rs | 3 +- .../crates/harness-types/src/lib.rs | 4 +-- .../crates/harness-types/src/agent_event.rs | 2 +- .../crates/harness-types/src/agent_message.rs | 2 +- .../crates/harness-types/src/content.rs | 3 +- session-tree/crates/harness-types/src/lib.rs | 4 +-- session-tree/src/lib.rs | 4 ++- .../crates/harness-types/src/agent_event.rs | 2 +- .../crates/harness-types/src/agent_message.rs | 2 +- .../crates/harness-types/src/content.rs | 3 +- .../crates/harness-types/src/lib.rs | 4 +-- turn-orchestrator/src/persistence.rs | 33 +++++++------------ turn-orchestrator/src/states/functions.rs | 8 +++-- turn-orchestrator/src/states/steering.rs | 5 +-- 34 files changed, 118 insertions(+), 89 deletions(-) diff --git a/approval-gate/src/lib.rs b/approval-gate/src/lib.rs index df65ca96e..5540a768a 100644 --- a/approval-gate/src/lib.rs +++ b/approval-gate/src/lib.rs @@ -58,7 +58,9 @@ pub struct IncomingCall { impl IncomingCall { pub fn requires_approval(&self) -> bool { - self.approval_required.iter().any(|n| n == &self.function_id) + self.approval_required + .iter() + .any(|n| n == &self.function_id) } } @@ -103,7 +105,9 @@ 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 fc = inner.get("function_call").or_else(|| 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")) diff --git a/harness/src/fanout.rs b/harness/src/fanout.rs index b4e26b78c..c723f2402 100644 --- a/harness/src/fanout.rs +++ b/harness/src/fanout.rs @@ -884,7 +884,10 @@ mod tests { 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!( + removed, + "evict must report success when the browser was present" + ); assert_eq!(s.browser_count(), 0); assert!( s.subscribers_for("sess-1").is_empty(), diff --git a/harness/tests/skills_register.rs b/harness/tests/skills_register.rs index d1d3272a4..bf1001c5f 100644 --- a/harness/tests/skills_register.rs +++ b/harness/tests/skills_register.rs @@ -19,7 +19,10 @@ fn build_iii_skill_payload_matches_skills_register_contract() { 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"); + assert!( + skill.starts_with("# iii"), + "skill must lead with an H1 title" + ); } // TEMP(sandbox-skill): delete this test when iii-sandbox publishes its own. diff --git a/policy-denylist/src/config.rs b/policy-denylist/src/config.rs index cb8a735d0..c48c6fe7e 100644 --- a/policy-denylist/src/config.rs +++ b/policy-denylist/src/config.rs @@ -5,10 +5,7 @@ use serde::{Deserialize, Serialize}; pub struct WorkerConfig { #[serde(default = "default_topic")] pub topic: String, - #[serde( - default = "default_denied_functions_vec", - alias = "denied_tools" - )] + #[serde(default = "default_denied_functions_vec", alias = "denied_tools")] pub denied_functions: Vec, } @@ -48,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_functions, policy_denylist::default_denied_functions()); + assert_eq!( + cfg.denied_functions, + policy_denylist::default_denied_functions() + ); } #[test] diff --git a/policy-denylist/src/lib.rs b/policy-denylist/src/lib.rs index 924d0b11c..f8b2da8b8 100644 --- a/policy-denylist/src/lib.rs +++ b/policy-denylist/src/lib.rs @@ -150,7 +150,10 @@ pub(crate) async fn handle_event(bus: &dyn ReplyBus, denied: &[String], payload: reply } -pub fn subscribe_denylist(iii: &III, denied_functions: Vec) -> Result { +pub fn subscribe_denylist( + iii: &III, + denied_functions: Vec, +) -> Result { subscribe_denylist_with_config(iii, denied_functions, PolicyDenylistConfig::default()) } @@ -351,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!({ "function_call": { "function_id": "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 })); @@ -388,11 +395,7 @@ mod tests { 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 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))); } diff --git a/provider-anthropic/crates/harness-types/src/agent_event.rs b/provider-anthropic/crates/harness-types/src/agent_event.rs index 2f50270d6..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, FunctionResultMessage}; -use crate::stream_event::AssistantMessageEvent; use crate::function::FunctionResult; +use crate::stream_event::AssistantMessageEvent; /// Outcome of an approval gate. Wire format is the lowercase string /// `"allow"` or `"deny"`; the typed enum prevents constructing illegal values. diff --git a/provider-anthropic/crates/harness-types/src/agent_message.rs b/provider-anthropic/crates/harness-types/src/agent_message.rs index 4eea86d23..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::function::AgentFunction; /// Transcript message. Superset of LLM message types plus app-defined custom entries. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/provider-anthropic/crates/harness-types/src/content.rs b/provider-anthropic/crates/harness-types/src/content.rs index 9d7848e89..52e678751 100644 --- a/provider-anthropic/crates/harness-types/src/content.rs +++ b/provider-anthropic/crates/harness-types/src/content.rs @@ -70,7 +70,8 @@ mod tests { #[test] fn function_call_block_legacy_tool_call_type() { - let json = r#"{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"/tmp/x"}}"#; + 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, diff --git a/provider-anthropic/crates/harness-types/src/lib.rs b/provider-anthropic/crates/harness-types/src/lib.rs index 0430a080c..904fccbba 100644 --- a/provider-anthropic/crates/harness-types/src/lib.rs +++ b/provider-anthropic/crates/harness-types/src/lib.rs @@ -16,9 +16,9 @@ pub use agent_message::{ FunctionResultMessage, UserMessage, }; pub use content::{ContentBlock, ImageContent, TextContent}; -pub use stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; -pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; 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}; diff --git a/provider-anthropic/crates/provider-base/src/iii_register.rs b/provider-anthropic/crates/provider-base/src/iii_register.rs index 2ace466ac..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, AgentFunction, AssistantMessage, AssistantMessageEvent, ContentBlock, ErrorKind, + AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, ErrorKind, StopReason, TextContent, }; use iii_sdk::{FunctionRef, IIIError, RegisterFunctionMessage, III}; @@ -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!( diff --git a/provider-anthropic/crates/provider-base/src/openai_compat.rs b/provider-anthropic/crates/provider-base/src/openai_compat.rs index ada5e9d62..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::{ - AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, - StopReason, TextContent, Usage, + AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, StopReason, + TextContent, Usage, }; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; diff --git a/provider-anthropic/src/lib.rs b/provider-anthropic/src/lib.rs index bc2ed4c4a..b95f8f38b 100644 --- a/provider-anthropic/src/lib.rs +++ b/provider-anthropic/src/lib.rs @@ -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.function_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) } @@ -653,14 +654,16 @@ mod tests { #[test] fn tool_result_converts_to_user_with_tool_result_block() { - 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 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"); diff --git a/provider-openai/crates/harness-types/src/agent_event.rs b/provider-openai/crates/harness-types/src/agent_event.rs index 2f50270d6..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, FunctionResultMessage}; -use crate::stream_event::AssistantMessageEvent; use crate::function::FunctionResult; +use crate::stream_event::AssistantMessageEvent; /// Outcome of an approval gate. Wire format is the lowercase string /// `"allow"` or `"deny"`; the typed enum prevents constructing illegal values. diff --git a/provider-openai/crates/harness-types/src/agent_message.rs b/provider-openai/crates/harness-types/src/agent_message.rs index 4eea86d23..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::function::AgentFunction; /// Transcript message. Superset of LLM message types plus app-defined custom entries. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/provider-openai/crates/harness-types/src/content.rs b/provider-openai/crates/harness-types/src/content.rs index 9d7848e89..52e678751 100644 --- a/provider-openai/crates/harness-types/src/content.rs +++ b/provider-openai/crates/harness-types/src/content.rs @@ -70,7 +70,8 @@ mod tests { #[test] fn function_call_block_legacy_tool_call_type() { - let json = r#"{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"/tmp/x"}}"#; + 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, diff --git a/provider-openai/crates/harness-types/src/lib.rs b/provider-openai/crates/harness-types/src/lib.rs index 0430a080c..904fccbba 100644 --- a/provider-openai/crates/harness-types/src/lib.rs +++ b/provider-openai/crates/harness-types/src/lib.rs @@ -16,9 +16,9 @@ pub use agent_message::{ FunctionResultMessage, UserMessage, }; pub use content::{ContentBlock, ImageContent, TextContent}; -pub use stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; -pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; 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}; diff --git a/provider-openai/crates/provider-base/src/iii_register.rs b/provider-openai/crates/provider-base/src/iii_register.rs index 2ace466ac..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, AgentFunction, AssistantMessage, AssistantMessageEvent, ContentBlock, ErrorKind, + AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, ErrorKind, StopReason, TextContent, }; use iii_sdk::{FunctionRef, IIIError, RegisterFunctionMessage, III}; @@ -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!( diff --git a/provider-openai/crates/provider-base/src/openai_compat.rs b/provider-openai/crates/provider-base/src/openai_compat.rs index 304b447eb..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::{ - AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, - StopReason, TextContent, Usage, + AgentFunction, AgentMessage, AssistantMessage, AssistantMessageEvent, ContentBlock, StopReason, + TextContent, Usage, }; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; diff --git a/provider-router/crates/harness-types/src/agent_event.rs b/provider-router/crates/harness-types/src/agent_event.rs index 2f50270d6..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, FunctionResultMessage}; -use crate::stream_event::AssistantMessageEvent; use crate::function::FunctionResult; +use crate::stream_event::AssistantMessageEvent; /// Outcome of an approval gate. Wire format is the lowercase string /// `"allow"` or `"deny"`; the typed enum prevents constructing illegal values. diff --git a/provider-router/crates/harness-types/src/agent_message.rs b/provider-router/crates/harness-types/src/agent_message.rs index 4eea86d23..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::function::AgentFunction; /// Transcript message. Superset of LLM message types plus app-defined custom entries. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/provider-router/crates/harness-types/src/content.rs b/provider-router/crates/harness-types/src/content.rs index 9d7848e89..52e678751 100644 --- a/provider-router/crates/harness-types/src/content.rs +++ b/provider-router/crates/harness-types/src/content.rs @@ -70,7 +70,8 @@ mod tests { #[test] fn function_call_block_legacy_tool_call_type() { - let json = r#"{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"/tmp/x"}}"#; + 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, diff --git a/provider-router/crates/harness-types/src/lib.rs b/provider-router/crates/harness-types/src/lib.rs index 0430a080c..904fccbba 100644 --- a/provider-router/crates/harness-types/src/lib.rs +++ b/provider-router/crates/harness-types/src/lib.rs @@ -16,9 +16,9 @@ pub use agent_message::{ FunctionResultMessage, UserMessage, }; pub use content::{ContentBlock, ImageContent, TextContent}; -pub use stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; -pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; 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}; diff --git a/session-tree/crates/harness-types/src/agent_event.rs b/session-tree/crates/harness-types/src/agent_event.rs index 2f50270d6..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, FunctionResultMessage}; -use crate::stream_event::AssistantMessageEvent; use crate::function::FunctionResult; +use crate::stream_event::AssistantMessageEvent; /// Outcome of an approval gate. Wire format is the lowercase string /// `"allow"` or `"deny"`; the typed enum prevents constructing illegal values. diff --git a/session-tree/crates/harness-types/src/agent_message.rs b/session-tree/crates/harness-types/src/agent_message.rs index 4eea86d23..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::function::AgentFunction; /// Transcript message. Superset of LLM message types plus app-defined custom entries. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/session-tree/crates/harness-types/src/content.rs b/session-tree/crates/harness-types/src/content.rs index 9d7848e89..52e678751 100644 --- a/session-tree/crates/harness-types/src/content.rs +++ b/session-tree/crates/harness-types/src/content.rs @@ -70,7 +70,8 @@ mod tests { #[test] fn function_call_block_legacy_tool_call_type() { - let json = r#"{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"/tmp/x"}}"#; + 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, diff --git a/session-tree/crates/harness-types/src/lib.rs b/session-tree/crates/harness-types/src/lib.rs index 0430a080c..904fccbba 100644 --- a/session-tree/crates/harness-types/src/lib.rs +++ b/session-tree/crates/harness-types/src/lib.rs @@ -16,9 +16,9 @@ pub use agent_message::{ FunctionResultMessage, UserMessage, }; pub use content::{ContentBlock, ImageContent, TextContent}; -pub use stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage}; -pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel}; 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}; diff --git a/session-tree/src/lib.rs b/session-tree/src/lib.rs index dc4c50d62..efe1ac8c6 100644 --- a/session-tree/src/lib.rs +++ b/session-tree/src/lib.rs @@ -856,7 +856,9 @@ fn render_blocks_html(blocks: &[ContentBlock]) -> String { out.push_str("]
"); } ContentBlock::FunctionCall { - function_id, arguments, .. + function_id, + arguments, + .. } => { out.push_str("
function call: ");
                 out.push_str(&html_escape(function_id));
diff --git a/turn-orchestrator/crates/harness-types/src/agent_event.rs b/turn-orchestrator/crates/harness-types/src/agent_event.rs
index 2f50270d6..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, FunctionResultMessage};
-use crate::stream_event::AssistantMessageEvent;
 use crate::function::FunctionResult;
+use crate::stream_event::AssistantMessageEvent;
 
 /// Outcome of an approval gate. Wire format is the lowercase string
 /// `"allow"` or `"deny"`; the typed enum prevents constructing illegal values.
diff --git a/turn-orchestrator/crates/harness-types/src/agent_message.rs b/turn-orchestrator/crates/harness-types/src/agent_message.rs
index 4eea86d23..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::function::AgentFunction;
 
 /// Transcript message. Superset of LLM message types plus app-defined custom entries.
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
diff --git a/turn-orchestrator/crates/harness-types/src/content.rs b/turn-orchestrator/crates/harness-types/src/content.rs
index 9d7848e89..52e678751 100644
--- a/turn-orchestrator/crates/harness-types/src/content.rs
+++ b/turn-orchestrator/crates/harness-types/src/content.rs
@@ -70,7 +70,8 @@ mod tests {
 
     #[test]
     fn function_call_block_legacy_tool_call_type() {
-        let json = r#"{"type":"toolCall","id":"call_1","name":"read","arguments":{"path":"/tmp/x"}}"#;
+        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,
diff --git a/turn-orchestrator/crates/harness-types/src/lib.rs b/turn-orchestrator/crates/harness-types/src/lib.rs
index 0430a080c..904fccbba 100644
--- a/turn-orchestrator/crates/harness-types/src/lib.rs
+++ b/turn-orchestrator/crates/harness-types/src/lib.rs
@@ -16,9 +16,9 @@ pub use agent_message::{
     FunctionResultMessage, UserMessage,
 };
 pub use content::{ContentBlock, ImageContent, TextContent};
-pub use stream_event::{AssistantMessageEvent, ErrorKind, StopReason, Usage};
-pub use thinking::{TextPhase, TextSignature, ThinkingBudgets, ThinkingLevel};
 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};
diff --git a/turn-orchestrator/src/persistence.rs b/turn-orchestrator/src/persistence.rs
index 8ea5869df..0e9dc34e0 100644
--- a/turn-orchestrator/src/persistence.rs
+++ b/turn-orchestrator/src/persistence.rs
@@ -292,18 +292,16 @@ pub async fn load_prepared_calls(
     iii: &III,
     session_id: &str,
 ) -> Vec<(FunctionCall, Option)> {
-    let value =
-        staging_get_with_legacy(iii, session_id, PREPARED_KEY, LEGACY_PREPARED_KEY).await;
+    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 fc =
-                entry
-                    .get("function_call")
-                    .or_else(|| entry.get("tool_call"))
-                    .and_then(|v| serde_json::from_value::(v.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())
@@ -332,20 +330,17 @@ 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 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 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 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)
@@ -430,11 +425,7 @@ mod tests {
 
         upsert_executed_call(
             &mut executed,
-            (
-                fc("tc-2", "write"),
-                func_result("replacement"),
-                false,
-            ),
+            (fc("tc-2", "write"), func_result("replacement"), false),
         );
         upsert_executed_call(
             &mut executed,
diff --git a/turn-orchestrator/src/states/functions.rs b/turn-orchestrator/src/states/functions.rs
index 89c5adfd3..2adb40fd7 100644
--- a/turn-orchestrator/src/states/functions.rs
+++ b/turn-orchestrator/src/states/functions.rs
@@ -174,8 +174,7 @@ pub async fn handle_execute(iii: &III, record: &mut TurnStateRecord) -> anyhow::
 
 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 function_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 (fc, mut result, is_error) in executed {
         let merged = publish_collect(
@@ -560,7 +559,10 @@ mod tests {
         };
         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"]["function_id"],
+            "shell::filesystem::ls"
+        );
         assert_eq!(inner["function_call"]["arguments"], json!({"path": "/tmp"}));
         assert!(inner.get("approval_required").is_some());
     }
diff --git a/turn-orchestrator/src/states/steering.rs b/turn-orchestrator/src/states/steering.rs
index c72deb615..d8ecd5131 100644
--- a/turn-orchestrator/src/states/steering.rs
+++ b/turn-orchestrator/src/states/steering.rs
@@ -281,9 +281,6 @@ mod tests {
     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
-        );
+        assert_eq!(route(false, false, false, false), SteeringRoute::EndTurn);
     }
 }

From 6624e159c7c34cf0b2f60d2d5b76b4aab40f48af Mon Sep 17 00:00:00 2001
From: Ytallo Layon 
Date: Fri, 8 May 2026 11:52:03 -0300
Subject: [PATCH 6/7] fix(ci): track harness/docs and silence excessive-bools
 on route()

- harness/src/lib.rs include_str!s docs/iii-skill.md and
  docs/sandbox-skill.md, but the root .gitignore globally ignored
  `docs`, so the files were never tracked and CI failed compilation.
  Add a narrow `!harness/docs/` exception and check the two markdown
  files in.
- steering::route takes four bools (abort + three queue checks). Each
  is an independent precondition checked in priority order; collapsing
  into a struct or bitflag obscures the call site without buying
  anything. Allow clippy::fn_params_excessive_bools with a comment.
---
 .gitignore                               |   1 +
 harness/docs/iii-skill.md                | 239 +++++++++++++++++++++++
 harness/docs/sandbox-skill.md            | 106 ++++++++++
 turn-orchestrator/src/states/steering.rs |   5 +
 4 files changed, 351 insertions(+)
 create mode 100644 harness/docs/iii-skill.md
 create mode 100644 harness/docs/sandbox-skill.md

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/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/turn-orchestrator/src/states/steering.rs b/turn-orchestrator/src/states/steering.rs
index d8ecd5131..3eded594e 100644
--- a/turn-orchestrator/src/states/steering.rs
+++ b/turn-orchestrator/src/states/steering.rs
@@ -26,6 +26,11 @@ pub(crate) enum SteeringRoute {
     EndTurn,
 }
 
+// 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,

From 5fd5b1ed3b7f07cf41d65f1000d849987ae4500a Mon Sep 17 00:00:00 2001
From: Ytallo Layon 
Date: Fri, 8 May 2026 12:02:29 -0300
Subject: [PATCH 7/7] fix(harness): add iii-sandbox to iii.worker.yaml
 dependencies

Commit 1717655 added "iii-sandbox" to EXPECTED_WORKERS in lib.rs but
left iii.worker.yaml untouched, so integration test
expected_workers_matches_yaml_dependency_count failed (16 vs 17). Add
the missing dependency line.
---
 harness/iii.worker.yaml | 1 +
 1 file changed, 1 insertion(+)

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"