Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,73 @@
//! `InnerDispatchForResolver` — an object-safe `ToolDispatch` that routes
//! through a `Weak<CompoundResolver>` bound to a single session.
//!
//! Tools that need to call other tools (the inner-dispatch pattern) ask
//! the runtime for an `Arc<dyn ToolDispatch>`. This adapter answers that
//! question with a resolver-backed implementation. Holding the resolver
//! by [`Weak`] lets the router own the resolver while inner-dispatch
//! handles created from the same resolver release naturally when the
//! router is torn down.
use std::sync::Weak;
use async_trait::async_trait;
use serde_json::Value;
use xai_tool_protocol::{SessionId, ToolId};
use xai_tool_runtime::{
ToolCallContext, ToolDispatch, ToolError, ToolStream, TypedToolOutput, terminal_only,
};
use crate::resolver::CompoundResolver;
/// Resolver-backed `ToolDispatch` implementation.
///
/// The resolver is held by [`Weak`] so the inner-dispatch handle never
/// keeps the router alive past its natural lifetime — when the owning
/// router drops the resolver, in-flight inner calls fail cleanly with
/// [`ToolError::Custom`] keyed `computer_hub_dropped`.
///
/// Bound to a single [`SessionId`] at construction (rather than reading a
/// session from [`ToolCallContext`]) so the inner-dispatch path mirrors
/// the per-session lifetime of the outer router.
#[derive(Debug)]
pub struct InnerDispatchForResolver {
resolver: Weak<CompoundResolver>,
session_id: SessionId,
}
impl InnerDispatchForResolver {
/// Build an inner-dispatch handle bound to `session_id`, resolving
/// through `resolver`.
pub fn new(resolver: Weak<CompoundResolver>, session_id: SessionId) -> Self {
Self {
resolver,
session_id,
}
}
/// Borrow the bound session identifier.
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
}
#[async_trait]
impl ToolDispatch for InnerDispatchForResolver {
async fn call(
&self,
tool_id: ToolId,
args: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
let Some(resolver) = self.resolver.upgrade() else {
return terminal_only(Err(ToolError::custom(
"computer_hub_dropped",
"computer hub dropped before inner call could execute",
)));
};
resolver
.resolve_and_dispatch(&self.session_id, tool_id, args, ctx)
.await
}
}

View file

@ -0,0 +1,29 @@
//! xAI Computer Hub — transport + registry + resolver core.
//!
//! Object-safe abstractions used by every router build: a [`Transport`]
//! that authorises and dispatches calls, a [`ToolRegistry`] trait shared
//! by both storage planes, a [`CompoundResolver`] that applies the
//! local-shadows-remote rule, and the local + remote transports plus
//! inner-dispatch glue that sit on top.
#![forbid(unsafe_code)]
pub mod inner;
pub mod local;
pub mod registry;
pub mod remote;
pub mod resolver;
pub mod transport;
pub use inner::InnerDispatchForResolver;
pub use local::{LOCAL_INVOKE_SCOPE, LocalTransport};
pub use registry::{
ConnectionCleanupReport, SessionCleanupReport, ToolRegistry, ToolSessionBindOutcome,
ToolSessionUnbindOutcome,
};
pub use remote::{
ConnectionClient, RemoteToolProxy, RemoteTransport, decode_call_result, error_from_envelope,
is_workspace_unavailable, output_to_value, progress_from_frame, tool_error_from_wire,
};
pub use resolver::{CompoundResolver, ErasedTool, ResolvedTool, ToolHandle};
pub use transport::{Principal, Transport, TransportKind};

View file

@ -0,0 +1,77 @@
//! In-process transport that resolves through a [`CompoundResolver`].
//!
//! `LocalTransport` is bound to a single `(user_id, session_id)` at
//! construction. Authorisation returns a principal pre-populated with the
//! bound session and the `tool.invoke` scope; per-call dispatch resolves
//! against the bound session's view of the resolver.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use xai_tool_protocol::{SessionId, ToolId, UserId};
use xai_tool_runtime::{ToolCallContext, ToolError, ToolStream, TypedToolOutput};
use crate::resolver::CompoundResolver;
use crate::transport::{Principal, Transport, TransportKind};
/// The scope `LocalTransport::authorize` grants to its principal.
///
/// Hoisted so adapters that authorise principals through other paths
/// can match the local convention without restating the literal.
pub const LOCAL_INVOKE_SCOPE: &str = "tool.invoke";
/// Transport that dispatches against an in-process resolver.
#[derive(Debug)]
pub struct LocalTransport {
resolver: Arc<CompoundResolver>,
user_id: UserId,
session_id: SessionId,
}
impl LocalTransport {
/// Build a transport bound to `(user_id, session_id)` and resolving
/// through `resolver`.
pub fn new(resolver: Arc<CompoundResolver>, user_id: UserId, session_id: SessionId) -> Self {
Self {
resolver,
user_id,
session_id,
}
}
/// Bound user identity for this transport.
pub fn user_id(&self) -> &UserId {
&self.user_id
}
/// Bound session for this transport.
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
}
#[async_trait]
impl Transport for LocalTransport {
fn kind(&self) -> TransportKind {
TransportKind::Local
}
async fn authorize(&self) -> Result<Principal, ToolError> {
Ok(Principal::new(self.user_id.clone())
.with_session(self.session_id.clone())
.with_scope(LOCAL_INVOKE_SCOPE))
}
async fn call(
&self,
tool_id: ToolId,
args: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
self.resolver
.resolve_and_dispatch(&self.session_id, tool_id, args, ctx)
.await
}
}

View file

@ -0,0 +1,305 @@
//! Object-safe `ToolRegistry` trait shared by every storage plane.
//!
//! Two registry implementations are expected: one in-memory plane for
//! statically-registered local tools, and one connection-keyed plane fed by
//! incoming remote registrations. Both expose the same trait so the
//! router can compose them through [`crate::CompoundResolver`] without
//! caring which is which.
//!
//! Mutations are connection-scoped: each registered tool belongs to the
//! [`ConnectionId`] that introduced it. Per-tool session bindings live
//! alongside the tool's record and are mutated independently via
//! [`ToolRegistry::bind_tool_session`] / [`ToolRegistry::unbind_tool_session`].
//! Reads (`find_tool`, `list_tools`, `search`) remain session-scoped — the
//! router resolves a tool by `(session_id, tool_id)`, never by
//! connection id.
//!
//! The concrete in-memory implementation is intentionally **out of scope**
//! for this crate — it requires a concurrency story (sharded maps, an
//! actor, etc.) that belongs alongside the registry's collision matrix and
//! generation handling. Tests exercise the trait via per-test mock impls.
use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, Ordering};
use async_trait::async_trait;
use xai_tool_protocol::{
ConnectionId, RegistrationOutcome, ServerId, SessionId, ToolDefinitionMode, ToolId,
ToolRegistration, ToolServerRegistration, UserId,
};
use xai_tool_runtime::{SearchSnapshot, ServerSummary};
use xai_tool_types::ToolDescription;
use crate::resolver::ResolvedTool;
/// Outcome of a single [`ToolRegistry::bind_tool_session`] call.
///
/// This enum is the source of truth for storage outcomes; the wire enum
/// [`xai_tool_protocol::ToolSessionBindOutcome`] is a strict subset with
/// one extra wire-only variant. The two layers diverge deliberately:
///
/// - `Conflict` (cross-connection race on the `(session_id, tool_id)`
/// reverse-index slot) is registry-internal: the router lifts it to a
/// top-level `ServerError::ToolBindingConflict` (-32600) instead of
/// mirroring it to the wire ack, so the contended caller gets a
/// dedicated error code rather than overloading `UnknownTool`.
/// - The wire enum's `SessionNotBound` is router-injected by the
/// per-frame envelope pre-check (the connection's bound-session set
/// lives in router state, not the registry) and is never produced by
/// any registry call — so it has no counterpart here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSessionBindOutcome {
/// Added to the tool's session set.
Bound,
/// Session id was already in the tool's session set; no-op.
AlreadyBound,
/// No tool with the given id is registered against this connection.
UnknownTool,
/// Cross-connection conflict: another connection already holds the
/// `(session_id, tool_id)` reverse-index slot. The router lifts this
/// into a top-level `ToolBindingConflict` server error so the wire
/// reply uses the dedicated -32600 code instead of the structurally
/// dishonest `UnknownTool`. No registry state was mutated.
Conflict,
}
/// Outcome of a single [`ToolRegistry::unbind_tool_session`] call.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolSessionUnbindOutcome {
/// Removed from the tool's session set.
Unbound,
/// Session id was not in the tool's session set; no-op.
NotBound,
/// No tool with the given id is registered against this connection.
UnknownTool,
}
/// Aggregated summary of a connection-scoped cleanup pass.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ConnectionCleanupReport {
/// Number of distinct `(connection, tool_id)` records dropped.
pub tools_dropped: usize,
/// Number of reverse-index `(session_id, tool_id)` rows cleaned up
/// across every session the dropped tools were bound to.
pub session_bindings_cleared: usize,
}
/// Aggregated summary of a session-scoped cleanup pass.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SessionCleanupReport {
/// Number of tools whose session set lost the unregistered session id.
pub tools_touched: usize,
/// Number of tools whose session set became empty after the
/// unregistration. The tool record itself is NOT removed — the owning
/// connection still owns it and may rebind via
/// [`ToolRegistry::bind_tool_session`] later.
pub tools_left_orphaned: usize,
}
/// Backend-agnostic registry of tools available within a router.
///
/// Methods are split into mutating (`async fn` — registration changes may
/// touch shared state and require coordination) and read-only views
/// (synchronous — implementations should answer from a consistent snapshot
/// without awaiting). The split mirrors how callers use the registry: the
/// hot path is the `find_tool` / `list_tools` view; mutations happen on the
/// rarer registration boundary.
#[async_trait]
pub trait ToolRegistry: Send + Sync + std::fmt::Debug {
/// Register a single tool against `connection_id`.
///
/// The outcome reports whether the registration created a new entry,
/// updated an existing one, was shadowed by a higher-priority
/// registration, or was rejected. `reg.sessions` may be empty — the
/// tool is registered but unreachable until
/// [`Self::bind_tool_session`] adds at least one session binding.
/// Implementations must enforce per-`(connection_id, tool_id)`
/// uniqueness within their plane.
async fn register_tool(
&self,
connection_id: ConnectionId,
reg: ToolRegistration,
) -> RegistrationOutcome;
/// Register a multi-tool batch from a single tool server against
/// `connection_id`.
///
/// Returns one [`RegistrationOutcome`] per tool in input order. Batch
/// semantics are best-effort: per-tool failures do not abort the rest
/// of the batch. The whole batch shares `reg.sessions` (which may be
/// empty).
async fn register_server(
&self,
connection_id: ConnectionId,
reg: ToolServerRegistration,
) -> Vec<RegistrationOutcome>;
/// Drop the tool registered under `(connection_id, tool_id)`. Returns
/// `true` if a matching entry was removed, `false` if no such entry
/// existed. The tool is removed from every session it was bound to in
/// one shot — use [`Self::unbind_tool_session`] for per-session removal.
async fn unregister_tool(&self, connection_id: &ConnectionId, tool: &ToolId) -> bool;
/// Drop every tool registered by `connection_id` under `server_id`.
/// Returns the number of entries removed.
async fn unregister_server(&self, connection_id: &ConnectionId, server: &ServerId) -> usize;
/// Add `session_id` to the per-tool session set of
/// `(connection_id, tool_id)`. The caller (typically the WebSocket
/// router) is responsible for verifying that `session_id` is in the
/// connection's bound-session set before calling this method.
async fn bind_tool_session(
&self,
connection_id: &ConnectionId,
tool: &ToolId,
session_id: &SessionId,
) -> ToolSessionBindOutcome;
/// Remove `session_id` from the per-tool session set of
/// `(connection_id, tool_id)`. Does not unregister the tool itself.
async fn unbind_tool_session(
&self,
connection_id: &ConnectionId,
tool: &ToolId,
session_id: &SessionId,
) -> ToolSessionUnbindOutcome;
/// Drop every tool registered by `connection_id`. Used by the WebSocket
/// transport on disconnect cleanup. Returns counters describing how
/// much state was released.
async fn drop_connection(&self, connection_id: &ConnectionId) -> ConnectionCleanupReport;
/// Look up the active resolution for `(session, tool)`.
///
/// Returns `None` when no entry exists or when an entry exists but is
/// shadowed. A shadowed entry is never returned — the caller sees only
/// the active resolution.
fn find_tool(&self, session: &SessionId, tool: &ToolId) -> Option<ResolvedTool>;
/// Enumerate every active tool description for `session`, filtered by
/// the requested presentation `mode`. Implementations decide how to
/// honour the mode (e.g. omit non-meta tools when `Concise` is set).
fn list_tools(&self, session: &SessionId, mode: &ToolDefinitionMode) -> Vec<ToolDescription>;
/// Enumerate active server summaries for `session`. Useful for
/// rendering connected-integrations system reminders.
fn list_servers(&self, session: &SessionId) -> Vec<ServerSummary>;
/// Run a search query against the registry's index for `session`.
/// `limit` caps the result count; the snapshot reports how many
/// matches were hidden by the cap.
fn search(&self, session: &SessionId, query: &str, limit: usize) -> SearchSnapshot;
/// Drop the binding to `session` from every tool that has it. The
/// affected tool records are NOT removed — their owning connection
/// retains them and may rebind via [`Self::bind_tool_session`]. Called
/// by the WebSocket transport when a session ends globally (no peer
/// connection still holds the binding) and by the connection actor
/// during per-disconnect cleanup.
async fn unregister_session(&self, session: &SessionId) -> SessionCleanupReport;
/// Helper: set of session ids currently bound to `(connection_id, tool_id)`.
/// Returns an empty set when the tool is not registered. Mainly used
/// by tests to assert per-tool session set invariants without leaning
/// on the reverse index.
fn tool_sessions(&self, connection_id: &ConnectionId, tool: &ToolId) -> HashSet<SessionId>;
/// All servers registered by this user across all connections.
fn list_servers_for_user(&self, user_id: &UserId) -> Vec<ServerRecord>;
/// Look up a server by its connection ID.
fn get_server_record(&self, connection_id: &ConnectionId) -> Option<ServerRecord>;
/// Look up only a server's id by its connection ID. Lighter than
/// [`Self::get_server_record`] for callers that need nothing else:
/// implementations should override the default to avoid deep-cloning
/// the whole record (notably its `metadata` JSON).
fn get_server_id(&self, connection_id: &ConnectionId) -> Option<ServerId> {
self.get_server_record(connection_id)
.map(|record| record.server_id)
}
}
/// Server identity captured at `register_server` time.
#[derive(Debug, Clone)]
pub struct ServerRecord {
pub connection_id: ConnectionId,
pub user_id: UserId,
pub server_id: ServerId,
pub description: String,
pub metadata: serde_json::Value,
pub registered_at: chrono::DateTime<chrono::Utc>,
/// Monotonic registration stamp ([`next_registration_seq`]) — the
/// stale-vs-revived discriminator for newest-wins (`registered_at` is display-only).
pub registration_seq: u64,
}
/// Process-global hybrid logical clock: per-process strictly-increasing (no ties,
/// immune to NTP step-back) and epoch-seeded so stamps also roughly order across
/// replicas — only while inter-replica clock skew stays within the revive window
/// (`tool_route_ttl_ms`); past that, TTL eviction, not seq order, is the backstop.
/// The recency key for bind newest-wins and strictly-older eviction.
static REGISTRATION_CLOCK: AtomicU64 = AtomicU64::new(0);
/// Issue the next monotonic registration stamp. See [`REGISTRATION_CLOCK`].
pub fn next_registration_seq() -> u64 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let candidate = now_ms << 10;
let mut prev = REGISTRATION_CLOCK.load(Ordering::Relaxed);
loop {
let next = candidate.max(prev + 1);
match REGISTRATION_CLOCK.compare_exchange_weak(
prev,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return next,
Err(actual) => prev = actual,
}
}
}
#[cfg(test)]
mod seq_tests {
use super::next_registration_seq;
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[test]
fn next_registration_seq_is_monotonic_and_epoch_seeded_under_burst() {
let before_ms = now_ms();
let first = next_registration_seq();
let mut prev = first;
const N: u64 = 50_000;
for _ in 0..N {
let s = next_registration_seq();
assert!(s > prev, "must be strictly increasing: {prev} -> {s}");
prev = s;
}
let after_ms = now_ms();
assert!(
prev - first >= N,
"burst must advance by at least one per call: {first} -> {prev}",
);
let high = prev >> 10;
assert!(
high >= before_ms,
"high bits ({high}) must be epoch-seeded (>= {before_ms})",
);
assert!(
high <= after_ms + 1_000,
"high bits ({high}) must track wall clock (<= {after_ms} + slack)",
);
}
}

View file

@ -0,0 +1,541 @@
//! `ConnectionClient` abstraction, `RemoteToolProxy`, and
//! `RemoteTransport`.
//!
//! `ConnectionClient` is the thin contract a downstream WebSocket SDK (or
//! an in-test channel-backed mock) implements; this crate stays free of
//! tokio-runtime / tokio-tungstenite deps so callers can pick their own.
//!
//! `RemoteToolProxy` wraps a remote tool registration so it implements
//! [`ToolHandle`] — the router routes through the same handle
//! type for local and remote registrations. `RemoteTransport` is the
//! transport-side equivalent: it forwards arbitrary `(tool_id, args)`
//! pairs over a [`ConnectionClient`] without needing a per-tool handle.
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use async_trait::async_trait;
use futures::Stream;
use futures::future::BoxFuture;
use futures::stream::BoxStream;
use serde_json::Value;
use tracing::warn;
use xai_tool_protocol::{
JsonRpcId, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, JsonRpcVersion, Method,
ResponseOutcome, SessionId, ToolCallId, ToolCallParams, ToolCallProgressFrame, ToolCallResult,
ToolCapabilities, ToolErrorWire, ToolId, ToolOutputWire, UserId, WORKSPACE_UNAVAILABLE_SUBCODE,
};
use xai_tool_runtime::{
BehaviorVersion, ContentBlock, Cwd, ListToolsContext, ToolCallContext,
ToolChatCompletionResponse, ToolError, ToolErrorKind, ToolProgress, ToolStream, ToolStreamItem,
TypedToolOutput, terminal_only,
};
use xai_tool_types::ToolDescription;
use crate::resolver::ToolHandle;
use crate::transport::{Principal, Transport, TransportKind};
/// Object-safe contract for a connected remote endpoint.
///
/// Concrete implementations supply the wire transport — the Rust SDK uses
/// `tokio_tungstenite`; tests use channel-backed mocks. Implementations
/// are expected to:
///
/// - correlate request/response pairs by [`JsonRpcId`];
/// - deliver progress notifications matching `tool_call_id` to whichever
/// subscriber registered for them;
/// - surface transport-level disconnects as [`ToolError::NetworkError`].
#[async_trait]
pub trait ConnectionClient: Send + Sync + std::fmt::Debug {
/// Send a JSON-RPC request and await the matching response. Errors
/// signal a transport-level failure (write failed, connection closed
/// before the response arrived); a successful return carries the
/// response envelope verbatim, including method-level error outcomes.
async fn request(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, ToolError>;
/// Subscribe to progress notifications for `tool_call_id`.
///
/// The returned stream closes when the call's terminal frame arrives,
/// when the connection drops, or when the caller drops the receiver.
/// Subscribers MUST be registered before the corresponding request is
/// sent — otherwise progress frames that arrive before subscription
/// is complete are lost.
async fn subscribe_progress(
&self,
tool_call_id: ToolCallId,
) -> BoxStream<'static, ToolCallProgressFrame>;
/// Send a one-way notification (no response expected). Useful for
/// hook frames such as cancel.
async fn notify(&self, notification: JsonRpcNotification) -> Result<(), ToolError>;
}
/// Wraps a remote registration so it dispatches through a connection.
///
/// Identity, description, and capabilities come from the registration
/// snapshot held on the proxy; execution forwards a `tool_call_request`
/// over the connection and merges progress + terminal frames into a
/// single [`ToolStream`].
#[derive(Debug, Clone)]
pub struct RemoteToolProxy {
tool_id: ToolId,
session_id: SessionId,
description: ToolDescription,
capabilities: ToolCapabilities,
connection: Arc<dyn ConnectionClient>,
}
impl RemoteToolProxy {
/// Build a proxy bound to a single remote registration.
pub fn new(
tool_id: ToolId,
session_id: SessionId,
description: ToolDescription,
capabilities: ToolCapabilities,
connection: Arc<dyn ConnectionClient>,
) -> Self {
Self {
tool_id,
session_id,
description,
capabilities,
connection,
}
}
/// Bound session identifier.
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
}
#[async_trait]
impl ToolHandle for RemoteToolProxy {
fn id(&self) -> ToolId {
self.tool_id.clone()
}
fn description(&self, _ctx: &ListToolsContext) -> ToolDescription {
self.description.clone()
}
fn capabilities(&self) -> ToolCapabilities {
self.capabilities.clone()
}
async fn execute(&self, ctx: ToolCallContext, args: Value) -> ToolStream<TypedToolOutput> {
dispatch_via_connection(
Arc::clone(&self.connection),
self.tool_id.clone(),
self.session_id.clone(),
args,
ctx,
)
.await
}
}
/// Transport that forwards calls over a [`ConnectionClient`].
///
/// The transport is bound to a single `(user_id, session_id)` at
/// construction. Calls do not require a pre-built proxy — the transport
/// builds the request frame from the `tool_id` it is asked to dispatch.
#[derive(Debug)]
pub struct RemoteTransport {
connection: Arc<dyn ConnectionClient>,
session_id: SessionId,
user_id: UserId,
}
impl RemoteTransport {
/// Build a transport over `connection`, bound to `(user_id,
/// session_id)`.
pub fn new(
connection: Arc<dyn ConnectionClient>,
session_id: SessionId,
user_id: UserId,
) -> Self {
Self {
connection,
session_id,
user_id,
}
}
/// Bound session identifier.
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
/// Bound user identifier.
pub fn user_id(&self) -> &UserId {
&self.user_id
}
}
#[async_trait]
impl Transport for RemoteTransport {
fn kind(&self) -> TransportKind {
TransportKind::Remote
}
async fn authorize(&self) -> Result<Principal, ToolError> {
Ok(Principal::new(self.user_id.clone()).with_session(self.session_id.clone()))
}
async fn call(
&self,
tool_id: ToolId,
args: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
dispatch_via_connection(
Arc::clone(&self.connection),
tool_id,
self.session_id.clone(),
args,
ctx,
)
.await
}
}
/// Subscribe to progress for `ctx.call_id`, send the `tool_call_request`,
/// and return a stream interleaving progress frames with the eventual
/// terminal item.
///
/// Subscribing **before** sending is the contract that
/// [`ConnectionClient::subscribe_progress`] requires; doing so here keeps
/// individual transports / proxies from re-implementing the dance.
async fn dispatch_via_connection(
connection: Arc<dyn ConnectionClient>,
tool_id: ToolId,
session_id: SessionId,
arguments: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
let cwd = ctx
.extensions
.get::<Cwd>()
.map(|c| c.0.to_string_lossy().into_owned());
let behavior_version = ctx.extensions.get::<BehaviorVersion>().map(|v| v.0.clone());
let call_id = ctx.call_id;
// Subscribe BEFORE sending. The single remaining `call_id.clone()`
// is unavoidable: subscription needs an owned id and the same id has
// to land in the request params below.
let progress = connection.subscribe_progress(call_id.clone()).await;
let params = ToolCallParams {
tool_call_id: call_id,
tool_id,
arguments,
deadline_ms: None,
behavior_version,
cwd,
// The ctx `TraceContext` extension is receive-side state.
trace_context: None,
};
let request = JsonRpcRequest {
jsonrpc: JsonRpcVersion,
id: JsonRpcId::new_uuid_v7(),
session_id: Some(session_id),
method: Method::ToolCallRequest.as_wire_str().to_string(),
params: match serde_json::to_value(&params) {
Ok(v) => v,
Err(e) => {
return terminal_only(Err(ToolError::custom("request_encoding", e.to_string())));
}
},
};
// Build the response future without awaiting it here so progress and
// terminal can be polled concurrently from the returned stream.
let request_fut = Box::pin(async move { connection.request(request).await });
Box::pin(RequestStream {
tool_id: Some(params.tool_id),
progress,
request: Some(request_fut),
done: false,
})
}
/// Owned response future with `'static` lifetime so the stream can hold
/// it across polls.
type ResponseFuture = BoxFuture<'static, Result<JsonRpcResponse, ToolError>>;
/// Stream that interleaves wire-side progress frames with the eventual
/// JSON-RPC response, ending with exactly one terminal item.
struct RequestStream {
/// Consumed exactly once when the terminal is built.
tool_id: Option<ToolId>,
progress: BoxStream<'static, ToolCallProgressFrame>,
request: Option<ResponseFuture>,
done: bool,
}
impl Stream for RequestStream {
type Item = ToolStreamItem<TypedToolOutput>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
if self.done {
return Poll::Ready(None);
}
// Poll the response first so the terminal short-circuits the
// moment it lands. Any progress frames that arrived alongside
// the response are dropped — once `Terminal` is emitted, `done`
// is set and the next poll returns `None` immediately without
// re-polling the progress stream. The router invariant is
// "`Progress* Terminal`, exactly one terminal"; dropping any
// post-terminal progress is what makes that invariant hold here.
if let Some(req_fut) = self.request.as_mut() {
match req_fut.as_mut().poll(cx) {
Poll::Ready(result) => {
self.done = true;
self.request = None;
let Some(tool_id) = self.tool_id.take() else {
return Poll::Ready(None);
};
let terminal = match result {
Ok(resp) => terminal_from_response(tool_id, resp),
Err(err) => Err(err),
};
return Poll::Ready(Some(ToolStreamItem::Terminal(terminal)));
}
Poll::Pending => {}
}
} else {
self.done = true;
return Poll::Ready(None);
}
// Poll the progress stream while the request is pending. Closing
// the progress stream is fine — the response future is still
// registered for wake-up.
match Pin::new(&mut self.progress).poll_next(cx) {
Poll::Ready(Some(frame)) => {
Poll::Ready(Some(ToolStreamItem::Progress(progress_from_frame(frame))))
}
Poll::Ready(None) | Poll::Pending => Poll::Pending,
}
}
}
/// Map a wire-side [`ToolCallProgressFrame`] into a runtime
/// [`ToolProgress`]. `kind` becomes the `Custom` subkind so callers can
/// dispatch on the producer-defined identifier without losing the body.
pub fn progress_from_frame(frame: ToolCallProgressFrame) -> ToolProgress {
ToolProgress::Custom {
subkind: frame.kind,
payload: frame.body,
}
}
/// Decode the response envelope into the terminal
/// `Result<TypedToolOutput, _>` the runtime expects.
fn terminal_from_response(
tool_id: ToolId,
resp: JsonRpcResponse,
) -> Result<TypedToolOutput, ToolError> {
match resp.outcome {
ResponseOutcome::Result(value) => decode_call_result(tool_id, value),
ResponseOutcome::Error(err) => Err(error_from_envelope(err)),
}
}
/// Decode a `tool_call_result` success body into the terminal
/// [`TypedToolOutput`]. Shared by the core remote proxy and the SDK
/// harness so both wire decoders reconstruct `chat_completion_output`
/// identically.
///
/// A body with a `tool_call_id` is decoded strictly (`response_decoding` on
/// failure), reconstructing `chat_completion_output` (an unparseable cco
/// degrades to `None`). A bare body — e.g. a hub-local tool's raw output —
/// passes through unchanged.
pub fn decode_call_result(tool_id: ToolId, value: Value) -> Result<TypedToolOutput, ToolError> {
if value.get("tool_call_id").is_none() {
return Ok(TypedToolOutput::from_value(tool_id, value));
}
let result: ToolCallResult = serde_json::from_value(value)
.map_err(|e| ToolError::custom("response_decoding", e.to_string()))?;
let chat_completion_output = result.chat_completion_output.and_then(|cco| {
serde_json::from_value::<ToolChatCompletionResponse>(cco)
.inspect_err(|e| {
warn!(tool_id = %tool_id, error = %e, "dropping unparseable chat_completion_output");
})
.ok()
});
let value = output_to_value(result.output);
Ok(TypedToolOutput::from_value(tool_id, value)
.with_chat_completion_output(chat_completion_output))
}
/// Project a wire [`ToolOutputWire`] into a JSON [`Value`].
///
/// Three shapes collapse to one runtime type:
/// - `Text` becomes a JSON string;
/// - `Json` is forwarded verbatim;
/// - `Mcp { blocks }` is re-serialised as `{ "blocks": [ContentBlock, ...] }`
/// so the same downstream decoder used for in-process content blocks
/// works without case-by-case adaptation.
pub fn output_to_value(output: ToolOutputWire) -> Value {
match output {
ToolOutputWire::Text(s) => Value::String(s),
ToolOutputWire::Json(v) => v,
ToolOutputWire::Mcp { blocks } => {
let runtime_blocks: Vec<ContentBlock> = blocks.into_iter().map(map_block).collect();
// `ContentBlock`'s derived `Serialize` impl never fails for any
// valid in-memory variant, but `to_value` is fallible at the
// type level; collapse a hypothetical failure to `Value::Null`
// before wrapping so this function stays total without an
// `unwrap`. The outer `json!` only sees a `Value` expression
// (which `to_value` round-trips infallibly), so the macro's
// hidden `to_value` call cannot panic here.
let blocks_value = serde_json::to_value(&runtime_blocks).unwrap_or(Value::Null);
serde_json::json!({ "blocks": blocks_value })
}
}
}
fn map_block(block: xai_tool_protocol::McpBlock) -> ContentBlock {
use xai_tool_protocol::McpBlock;
match block {
McpBlock::Text { text } => ContentBlock::Text { text },
McpBlock::Image { mime_type, data } => ContentBlock::Image {
mime_type,
data,
media_id: None,
filename: None,
path: None,
metadata: Default::default(),
},
McpBlock::Resource {
uri,
mime_type,
text,
} => ContentBlock::Resource {
uri,
mime_type,
text,
},
}
}
/// Decode a JSON-RPC error envelope into a [`ToolError`]. The envelope's
/// `data` field is expected to carry a serialised [`ToolErrorWire`] when
/// available; falls back to a [`ToolError::Custom`] keyed by the numeric
/// envelope code when the data shape is unknown.
pub fn error_from_envelope(err: xai_tool_protocol::JsonRpcError) -> ToolError {
if let Some(data) = err.data.clone()
&& let Ok(wire) = serde_json::from_value::<ToolErrorWire>(data)
{
return tool_error_from_wire(wire);
}
let mut e = ToolError::custom(format!("jsonrpc_{}", err.code), err.message);
if let Some(data) = err.data {
e = e.with_details(data);
}
e
}
/// Recognize the hub's `workspace_unavailable` error on an already-decoded
/// [`ToolError`]. Keys on `details["code"]` — the field that survives
/// `ToolError::custom` + `with_details` — not the numeric code or the wire
/// `Custom.subcode`.
pub fn is_workspace_unavailable(err: &ToolError) -> bool {
err.kind == ToolErrorKind::Custom
&& err
.details
.as_ref()
.and_then(|d| d.get("code"))
.and_then(|v| v.as_str())
== Some(WORKSPACE_UNAVAILABLE_SUBCODE)
}
/// Map [`ToolErrorWire`] back into the runtime [`ToolError`]. The runtime
/// error variants are the source-of-truth taxonomy; the wire form is a
/// lossy projection onto stable codes for serialisation, so a few wire
/// variants land on [`ToolError::Custom`] keyed by their wire code
/// rather than a dedicated runtime variant.
pub fn tool_error_from_wire(wire: ToolErrorWire) -> ToolError {
match wire {
ToolErrorWire::InvalidArguments { message, details } => {
let e = ToolError::invalid_arguments(message);
match details {
Some(d) => e.with_details(d),
None => e,
}
}
ToolErrorWire::ToolNotFound { tool_id } => {
let detail = format!("tool not found: {tool_id}");
ToolError::not_found(tool_id, detail)
}
ToolErrorWire::PermissionDenied { reason } => ToolError::permission_denied(reason),
ToolErrorWire::Timeout {
tool_id,
elapsed_ms,
} => ToolError::new(
ToolErrorKind::Timeout,
format!("timed out after {elapsed_ms}ms"),
)
.with_details(serde_json::json!({"tool_id": tool_id.as_str(), "elapsed_ms": elapsed_ms})),
ToolErrorWire::Cancelled { tool_id } => ToolError::cancelled(tool_id, "cancelled"),
ToolErrorWire::Execution { tool_id, message } => ToolError::execution(tool_id, message),
ToolErrorWire::BehaviorVersionUnsupported { tool_id, requested } => ToolError::new(
ToolErrorKind::BehaviorVersionUnsupported,
format!("behavior version {requested} not supported"),
)
.with_details(serde_json::json!({"tool_id": tool_id.as_str(), "requested": requested})),
ToolErrorWire::RenderLimited {
tool_id,
card_id,
reason,
} => ToolError::new(ToolErrorKind::RenderLimited, reason)
.with_details(serde_json::json!({"tool_id": tool_id.as_str(), "card_id": card_id})),
ToolErrorWire::TerminalError { tool_id, message } => {
ToolError::terminal_error(tool_id, message)
}
ToolErrorWire::Custom {
subcode,
message,
details,
} => {
let e = ToolError::custom(subcode, message);
match details {
Some(d) => e.with_details(d),
None => e,
}
}
ToolErrorWire::SessionMismatch => ToolError::custom("session_mismatch", "session mismatch"),
ToolErrorWire::TransportClosed { tool_id } => {
ToolError::network_error(format!("transport closed for {tool_id}"))
}
ToolErrorWire::UnsupportedProtocolVersion { supported } => ToolError::custom(
"unsupported_protocol_version",
format!("supported versions: {supported:?}"),
),
ToolErrorWire::PayloadTooLarge { bytes, limit } => ToolError::custom(
"payload_too_large",
format!("payload {bytes} bytes exceeds limit {limit}"),
),
ToolErrorWire::Internal { request_id, detail } => {
let e = ToolError::custom(
"internal_error",
detail.unwrap_or_else(|| "internal router error".to_owned()),
);
match request_id {
// Keep `code` alongside `request_id`: `with_details` replaces
// the `{"code": …}` object `ToolError::custom` installed.
Some(id) => e.with_details(
serde_json::json!({ "code": "internal_error", "request_id": id.as_str() }),
),
None => e,
}
}
}
}

View file

@ -0,0 +1,280 @@
//! `CompoundResolver` plus the `ResolvedTool` and `ToolHandle`
//! types it returns.
//!
//! `Tool` carries associated `Args` / `Output` types and is therefore not
//! object-safe. [`ToolHandle`] is the dyn-compatible projection used
//! by every router build: typed tools are wrapped via
//! [`ErasedTool::new`]; remote registrations expose
//! [`crate::RemoteToolProxy`] which implements [`ToolHandle`]
//! directly without an intermediate typed `Tool` impl.
use std::sync::Arc;
use async_trait::async_trait;
use futures::StreamExt;
use serde_json::Value;
use xai_tool_protocol::{SessionId, ToolCapabilities, ToolId, ToolRegistration};
use xai_tool_runtime::{
ListToolsContext, Tool, ToolCallContext, ToolError, ToolOutput, ToolStream, ToolStreamItem,
TypedToolOutput, terminal_only,
};
use xai_tool_types::ToolDescription;
use crate::registry::ToolRegistry;
/// Active resolution returned by [`CompoundResolver::resolve`].
///
/// Variants share the same `tool` handle and `registration` shape; the
/// discriminant only tells callers whether the executing handle dispatches
/// in-process or forwards over a connection. Differentiating the variants
/// is useful for metrics, log tags, and the local-shadows-remote rule
/// applied when both planes register the same `tool_id`.
#[derive(Debug, Clone)]
pub enum ResolvedTool {
/// In-process tool resolved from the local registry.
Local {
/// Object-safe handle to the tool's `execute` entry point.
tool: Arc<dyn ToolHandle>,
/// Wire-shape registration record. Carries `tool_id`, the
/// schema-bearing description, capabilities, and ownership data.
registration: ToolRegistration,
},
/// Remote registration resolved through a connection-backed proxy.
Remote {
/// Object-safe handle whose `execute` forwards over the
/// owning connection.
proxy: Arc<dyn ToolHandle>,
/// Wire-shape registration record (same shape as the local
/// variant — both store the active registration so callers do not
/// have to round-trip the registry for description / capabilities).
registration: ToolRegistration,
},
}
impl ResolvedTool {
/// Borrow the registration record regardless of variant.
pub fn registration(&self) -> &ToolRegistration {
match self {
Self::Local { registration, .. } | Self::Remote { registration, .. } => registration,
}
}
/// Borrow the executing handle regardless of variant.
pub fn handle(&self) -> &Arc<dyn ToolHandle> {
match self {
Self::Local { tool, .. } => tool,
Self::Remote { proxy, .. } => proxy,
}
}
}
/// Object-safe projection of a registered tool.
///
/// The router only needs identity, description, capabilities, and a
/// JSON-typed `execute` entry point — exactly what this trait exposes.
/// Adapters that wrap a typed `Tool` impl get [`ErasedTool`] for free;
/// non-`Tool` handles (notably remote proxies) implement this trait
/// directly.
#[async_trait]
pub trait ToolHandle: Send + Sync + std::fmt::Debug {
/// Stable identity used by the router to route calls.
fn id(&self) -> ToolId;
/// Model-facing description of the tool's argument schema.
///
/// Receives the per-turn [`ListToolsContext`] so handles backed by a
/// typed [`Tool`] can produce context-aware descriptions at listing
/// time. Callers outside a listing turn pass
/// [`ListToolsContext::default`].
fn description(&self, ctx: &ListToolsContext) -> ToolDescription;
/// Per-tool capability flags.
fn capabilities(&self) -> ToolCapabilities;
/// Per-turn listing predicate.
fn should_list(&self, _ctx: &ListToolsContext) -> bool {
true
}
/// Streaming execution entry point.
///
/// Implementations encode the tool's typed `Output` to
/// [`serde_json::Value`] and surface argument-decoding failures as
/// [`ToolError::InvalidArguments`] within the terminal item.
async fn execute(&self, ctx: ToolCallContext, args: Value) -> ToolStream<TypedToolOutput>;
}
/// Type-erasing wrapper for any [`Tool`] implementation.
///
/// Decodes `args` into `T::Args`, drives `T::execute`, and re-encodes each
/// `T::Output` (terminal and progress items pass through unchanged
/// otherwise). The wrapper holds the inner tool by `Arc` so the same
/// underlying instance can back multiple registrations cheaply.
pub struct ErasedTool<T> {
inner: Arc<T>,
}
impl<T> ErasedTool<T> {
/// Wrap an `Arc<T>` for use as an [`ToolHandle`].
pub fn from_arc(inner: Arc<T>) -> Self {
Self { inner }
}
/// Wrap an owned tool, taking the `Arc` allocation internally.
pub fn new(inner: T) -> Self {
Self::from_arc(Arc::new(inner))
}
}
impl<T: std::fmt::Debug> std::fmt::Debug for ErasedTool<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ErasedTool")
.field("inner", &self.inner)
.finish()
}
}
impl<T> Clone for ErasedTool<T> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
#[async_trait]
impl<T> ToolHandle for ErasedTool<T>
where
T: Tool + std::fmt::Debug + 'static,
T::Output: ToolOutput,
{
fn id(&self) -> ToolId {
self.inner.id()
}
fn description(&self, ctx: &ListToolsContext) -> ToolDescription {
self.inner.description(ctx)
}
fn capabilities(&self) -> ToolCapabilities {
self.inner.capabilities()
}
fn should_list(&self, ctx: &ListToolsContext) -> bool {
self.inner.should_list(ctx)
}
async fn execute(&self, ctx: ToolCallContext, args: Value) -> ToolStream<TypedToolOutput> {
let typed_args: T::Args = match serde_json::from_value(args) {
Ok(a) => a,
Err(e) => {
return terminal_only(Err(ToolError::invalid_arguments(e.to_string())));
}
};
let tool_id = self.inner.id();
let stream = self.inner.execute(ctx, typed_args).await;
let mapped = stream.map(move |item| match item {
ToolStreamItem::Progress(p) => ToolStreamItem::Progress(p),
ToolStreamItem::Terminal(Ok(out)) => match serde_json::to_value(&out) {
Ok(value) => {
let custom = out.model_output();
let model_output = if custom.is_empty() {
xai_tool_runtime::extract_content_blocks(&value)
} else {
custom
};
let chat_completion_output = out.chat_completion_output();
ToolStreamItem::Terminal(Ok(TypedToolOutput {
tool_id: tool_id.clone(),
value,
model_output,
chat_completion_output,
}))
}
Err(e) => ToolStreamItem::Terminal(Err(ToolError::custom(
"output_encoding",
e.to_string(),
))),
},
ToolStreamItem::Terminal(Err(err)) => ToolStreamItem::Terminal(Err(err)),
});
Box::pin(mapped)
}
}
/// Compose a local-first lookup over one (`local_only`) or two
/// (`compound`) registries.
///
/// The lookup contract: `find_tool` is called on the local registry first;
/// only if it returns `None` is the remote registry consulted. Any local
/// registration shadows a same-id remote registration. Cross-session
/// lookups return `None` — the caller may surface this as a
/// [`ToolError::NotFound`] to keep ownership invisible to the requester.
#[derive(Debug)]
pub struct CompoundResolver {
local: Arc<dyn ToolRegistry>,
remote: Option<Arc<dyn ToolRegistry>>,
}
impl CompoundResolver {
/// Compose a resolver that consults a single local registry.
pub fn local_only(local: Arc<dyn ToolRegistry>) -> Self {
Self {
local,
remote: None,
}
}
/// Compose a resolver with both planes; `local` is consulted first.
pub fn compound(local: Arc<dyn ToolRegistry>, remote: Arc<dyn ToolRegistry>) -> Self {
Self {
local,
remote: Some(remote),
}
}
/// Borrow the local plane.
pub fn local(&self) -> &Arc<dyn ToolRegistry> {
&self.local
}
/// Borrow the optional remote plane.
pub fn remote(&self) -> Option<&Arc<dyn ToolRegistry>> {
self.remote.as_ref()
}
/// Resolve `(session, tool_id)` honouring the local-first rule.
pub fn resolve(&self, session: &SessionId, tool_id: &ToolId) -> Option<ResolvedTool> {
if let Some(hit) = self.local.find_tool(session, tool_id) {
return Some(hit);
}
self.remote
.as_ref()
.and_then(|r| r.find_tool(session, tool_id))
}
/// Resolve `(session, tool_id)` and dispatch through the active
/// handle, returning the tool's stream verbatim. Misses produce a
/// single-item terminal stream carrying [`ToolError::NotFound`].
///
/// Centralises the resolve-then-dispatch sequence so both the
/// transport-side `LocalTransport::call` and the inner-dispatch path
/// share one implementation: a future change to the miss-shape (or
/// to the dispatch contract) lands once.
pub async fn resolve_and_dispatch(
&self,
session: &SessionId,
tool_id: ToolId,
args: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput> {
match self.resolve(session, &tool_id) {
Some(resolved) => resolved.handle().execute(ctx, args).await,
None => terminal_only(Err(ToolError::not_found(
tool_id.clone(),
format!("tool not found: {tool_id}"),
))),
}
}
}

View file

@ -0,0 +1,116 @@
//! Object-safe `Transport` trait plus the `Principal` value carried across
//! authorize/call boundaries.
//!
//! [`TransportKind`] is re-exported from [`xai_tool_protocol`] so the wire
//! and dispatch layers share one canonical enum and there is no duplicate
//! `Local` / `Remote` definition to keep in sync.
use async_trait::async_trait;
use serde_json::Value;
use xai_tool_protocol::{SessionId, ToolId, UserId};
use xai_tool_runtime::{ToolCallContext, ToolError, ToolStream, TypedToolOutput};
pub use xai_tool_protocol::TransportKind;
/// Authenticated identity bound to a transport at handshake time.
///
/// The transport authorises **once** at connect; subsequent dispatch calls
/// carry no extra credentials. `session_ids` is plural because a JWT may
/// authorise more than one session (multi-tenant tooling sessions sharing
/// a single user identity); the router narrows by [`SessionId`] at the
/// per-call boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
/// Authenticated user identity.
pub user_id: UserId,
/// Sessions this principal is authorised to act on. Empty when the
/// transport authorises a user but has not yet bound a session
/// (e.g. a fresh harness connection that has not opened a session).
pub session_ids: Vec<SessionId>,
/// OAuth-style scopes granted to this principal, e.g. `"tool.invoke"`.
pub scopes: Vec<String>,
/// Token audiences claimed by the credential, e.g. the router's
/// expected `aud` values. Used by callers that need defence-in-depth
/// audience checks beyond what the transport already validated.
pub audiences: Vec<String>,
}
impl Principal {
/// Build a principal for `user_id` with no sessions, scopes, or
/// audiences. Use the `with_*` builders to populate the rest.
pub fn new(user_id: UserId) -> Self {
Self {
user_id,
session_ids: Vec::new(),
scopes: Vec::new(),
audiences: Vec::new(),
}
}
/// Append `session_id` to the authorised set.
pub fn with_session(mut self, session_id: SessionId) -> Self {
self.session_ids.push(session_id);
self
}
/// Append `scope` to the granted scopes.
pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
self.scopes.push(scope.into());
self
}
/// Append `aud` to the token's audience list.
pub fn with_audience(mut self, aud: impl Into<String>) -> Self {
self.audiences.push(aud.into());
self
}
/// Whether `scope` is present in the granted scopes.
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == scope)
}
/// Whether `session_id` is in the principal's authorised session set.
pub fn authorizes_session(&self, session_id: &SessionId) -> bool {
self.session_ids.iter().any(|s| s == session_id)
}
}
/// Object-safe transport for dispatching tool calls.
///
/// Implementations come in two flavours: [`TransportKind::Local`] resolves
/// against an in-process registry, while [`TransportKind::Remote`] forwards
/// a `tool_call_request` over a [`crate::ConnectionClient`].
#[async_trait]
pub trait Transport: Send + Sync + std::fmt::Debug {
/// Whether the underlying transport is local (in-process) or remote
/// (forwarded over a connection).
fn kind(&self) -> TransportKind;
/// One-time authorisation handshake.
///
/// Local transports return a principal derived from the bound OS user
/// (or whatever ambient identity the host process provides). Remote
/// transports return the principal extracted from a validated
/// credential. Subsequent [`Self::call`] invocations reuse this
/// principal — the router never re-authorises per call.
async fn authorize(&self) -> Result<Principal, ToolError>;
/// Dispatch a tool call.
///
/// The returned [`ToolStream`] follows the runtime invariant: zero or
/// more `Progress` items followed by exactly one `Terminal`. A
/// not-found result is reported as a single-item terminal stream
/// carrying [`ToolError::NotFound`]; transport-level disconnects
/// surface as [`ToolError::NetworkError`].
async fn call(
&self,
tool_id: ToolId,
args: Value,
ctx: ToolCallContext,
) -> ToolStream<TypedToolOutput>;
}