Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
73
crates/common/xai-computer-hub-core/src/inner.rs
Normal file
73
crates/common/xai-computer-hub-core/src/inner.rs
Normal 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
|
||||
}
|
||||
}
|
||||
29
crates/common/xai-computer-hub-core/src/lib.rs
Normal file
29
crates/common/xai-computer-hub-core/src/lib.rs
Normal 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};
|
||||
77
crates/common/xai-computer-hub-core/src/local.rs
Normal file
77
crates/common/xai-computer-hub-core/src/local.rs
Normal 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
|
||||
}
|
||||
}
|
||||
305
crates/common/xai-computer-hub-core/src/registry.rs
Normal file
305
crates/common/xai-computer-hub-core/src/registry.rs
Normal 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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
541
crates/common/xai-computer-hub-core/src/remote.rs
Normal file
541
crates/common/xai-computer-hub-core/src/remote.rs
Normal 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(¶ms) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
280
crates/common/xai-computer-hub-core/src/resolver.rs
Normal file
280
crates/common/xai-computer-hub-core/src/resolver.rs
Normal 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}"),
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
116
crates/common/xai-computer-hub-core/src/transport.rs
Normal file
116
crates/common/xai-computer-hub-core/src/transport.rs
Normal 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>;
|
||||
}
|
||||
Loading…
Reference in a new issue