docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -85,13 +85,11 @@ impl ToolCallContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegate to `self.extensions.insert()`.
|
||||
pub fn insert<T: Send + Sync + 'static>(&mut self, value: T) -> &mut Self {
|
||||
self.extensions.insert(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Delegate to `self.extensions.get()`.
|
||||
pub fn get<T: Send + Sync + 'static>(&self) -> Option<Arc<T>> {
|
||||
self.extensions.get::<T>()
|
||||
}
|
||||
@@ -129,9 +127,8 @@ pub struct BehaviorVersion(pub String);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TraceContext(pub String);
|
||||
|
||||
/// Session ID context — identifies which hub session this call belongs to.
|
||||
/// Used by multi-session tool servers to dispatch to the correct
|
||||
/// per-session state.
|
||||
/// Session ID — which hub session this call belongs to.
|
||||
/// Multi-session tool servers dispatch to the matching per-session state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SessionContext(pub String);
|
||||
|
||||
@@ -141,11 +138,10 @@ pub struct SessionContext(pub String);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cancellation(pub tokio_util::sync::CancellationToken);
|
||||
|
||||
/// Per-user feature-flag bag attached as a [`ToolCallContext`] extension.
|
||||
/// Dispatcher resolves; tools read. Default = "off" for every field so an
|
||||
/// absent extension never accidentally opts a feature in. Extend by
|
||||
/// adding fields with safe defaults; new fields need `#[serde(default)]`
|
||||
/// so older `session.bind` payloads stay deserializable.
|
||||
/// Per-user feature-flag bag on [`ToolCallContext`]. Dispatcher resolves;
|
||||
/// tools read. Default is off for every field so an absent extension never
|
||||
/// opts a feature in. New fields need `#[serde(default)]` so older
|
||||
/// `session.bind` payloads stay deserializable.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct WorkspaceViewerContext {
|
||||
/// When `true`, `BashTool` emits `bash_output_chunk` Progress frames.
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
//! Object-safe `ToolDispatch` trait — the runtime contract for handling tool calls.
|
||||
//!
|
||||
//! `Tool` itself is not object-safe (it carries associated `Args` /
|
||||
//! `Output` types), so implementations expose a JSON-typed surface and rely on
|
||||
//! per-tool adapters to encode/decode at the boundary. The default
|
||||
//! `call_terminal` impl drains the stream so the common "I just want the
|
||||
//! result" path doesn't have to depend on `futures` internals.
|
||||
//! `Tool` itself is not object-safe (associated `Args` / `Output` types), so
|
||||
//! implementations expose a JSON-typed surface and rely on per-tool adapters
|
||||
//! to encode/decode at the boundary. The default `call_terminal` impl drains
|
||||
//! the stream so callers that only need the result avoid `futures` internals.
|
||||
//!
|
||||
//! This crate is upstream of every concrete impl. Doc-comments here describe
|
||||
//! trait semantics in terms of "the runtime" or "the implementation" —
|
||||
//! concrete dispatch routers live downstream and are intentionally not named
|
||||
//! here.
|
||||
//! This crate is upstream of every concrete impl. Docs here describe trait
|
||||
//! semantics in terms of "the runtime" or "the implementation" — concrete
|
||||
//! dispatch routers live downstream and are intentionally not named here.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
@@ -23,7 +21,7 @@ use crate::tool::{ToolStream, ToolStreamItem, TypedToolOutput};
|
||||
|
||||
/// Object-safe tool dispatch interface.
|
||||
///
|
||||
/// Implementations route the `tool_id` to the correct tool, decode `args`
|
||||
/// Implementations route `tool_id` to the correct tool, decode `args`
|
||||
/// against the tool's typed `Args`, and return the streaming result as
|
||||
/// [`TypedToolOutput`] — preserving model-facing content blocks and
|
||||
/// optional chat-completion metadata end-to-end. Raw `Value` only appears
|
||||
@@ -39,14 +37,12 @@ pub trait ToolDispatch: Send + Sync {
|
||||
ctx: ToolCallContext,
|
||||
) -> ToolStream<TypedToolOutput>;
|
||||
|
||||
/// Drain the stream and return only the terminal result. Useful for
|
||||
/// callers that don't care about progress chunks.
|
||||
/// Drain the stream and return only the terminal result.
|
||||
///
|
||||
/// Default impl pulls items off the stream and discards `Progress`
|
||||
/// items; the first `Terminal` short-circuits. A stream that ends
|
||||
/// without a `Terminal` is a protocol violation by the implementation;
|
||||
/// the default surfaces this as `ToolError::Custom { code:
|
||||
/// "stream_no_terminal", ... }`.
|
||||
/// Default impl discards `Progress` items and short-circuits on the
|
||||
/// first `Terminal`. A stream that ends without a `Terminal` is a
|
||||
/// protocol violation; the default surfaces
|
||||
/// `ToolError::Custom { code: "stream_no_terminal", ... }`.
|
||||
async fn call_terminal(
|
||||
&self,
|
||||
tool_id: ToolId,
|
||||
|
||||
@@ -14,12 +14,12 @@ use serde_json::Value;
|
||||
|
||||
use kigi_tool_protocol::{ToolErrorWire, ToolId};
|
||||
|
||||
/// Discriminator for tool errors.
|
||||
/// Machine-readable tool-error discriminator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ToolErrorKind {
|
||||
/// The tool has no implementation for the requested operation.
|
||||
/// No implementation for the requested operation.
|
||||
NotImplemented,
|
||||
/// Inputs failed validation.
|
||||
/// Input validation failed.
|
||||
InvalidArguments,
|
||||
/// No tool registered under the given id.
|
||||
NotFound,
|
||||
@@ -27,11 +27,10 @@ pub enum ToolErrorKind {
|
||||
PermissionDenied,
|
||||
/// Authentication failed (401-shaped).
|
||||
Unauthorized,
|
||||
/// The tool ran past its time budget.
|
||||
/// Tool ran past its time budget.
|
||||
Timeout,
|
||||
/// The caller cancelled the tool call.
|
||||
/// Caller cancelled the tool call.
|
||||
Cancelled,
|
||||
/// Rate limit exceeded.
|
||||
RateLimited,
|
||||
/// The caller's usage pool / billing balance is exhausted (out
|
||||
/// of credits). Payment-required-shaped; distinct from
|
||||
@@ -57,13 +56,10 @@ pub enum ToolErrorKind {
|
||||
/// shed) so the surface can tailor a "too many in progress" message.
|
||||
/// Named to match the chat surface's `concurrency_limit` typed error.
|
||||
ConcurrencyLimit,
|
||||
/// Upstream service unavailable.
|
||||
ServiceUnavailable,
|
||||
/// Network-level failure.
|
||||
NetworkError,
|
||||
/// Tool body returned an error.
|
||||
Execution,
|
||||
/// Requested behavior version not supported.
|
||||
BehaviorVersionUnsupported,
|
||||
/// Render-card budget exceeded.
|
||||
RenderLimited,
|
||||
@@ -151,12 +147,10 @@ impl std::error::Error for ToolError {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructors — one per kind for ergonomic tool code
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructors
|
||||
|
||||
impl ToolError {
|
||||
/// Core constructor. All other constructors delegate here.
|
||||
/// Core constructor; other constructors delegate here.
|
||||
pub fn new(kind: ToolErrorKind, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
@@ -166,13 +160,12 @@ impl ToolError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach structured metadata.
|
||||
pub fn with_details(mut self, details: Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a causal error chain (for developer logs, not sent to model).
|
||||
/// Attach a causal chain for developer logs (not sent to the model).
|
||||
pub fn with_source(mut self, source: impl Into<anyhow::Error>) -> Self {
|
||||
self.source = Some(source.into());
|
||||
self
|
||||
@@ -252,16 +245,13 @@ impl ToolError {
|
||||
.with_details(serde_json::json!({ "code": code.into() }))
|
||||
}
|
||||
|
||||
/// Snake-case identifier for the kind. Delegates to
|
||||
/// [`ToolErrorKind::as_str`].
|
||||
/// Snake-case identifier for the kind ([`ToolErrorKind::as_str`]).
|
||||
pub fn variant_name(&self) -> &'static str {
|
||||
self.kind.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From impls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl From<serde_json::Error> for ToolError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
@@ -269,9 +259,7 @@ impl From<serde_json::Error> for ToolError {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire bridge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Carry a [`ToolError`]'s structured `details` onto a `Custom` wire variant
|
||||
/// while keeping the round-trip recognizable: the decoder
|
||||
@@ -451,9 +439,8 @@ mod wire_bridge_tests {
|
||||
|
||||
#[test]
|
||||
fn service_unavailable_details_survive_wire_projection() {
|
||||
// Structured details used to be dropped (`details: None`) for the
|
||||
// Custom-mapped kinds; they must now ride the wire with the subcode
|
||||
// merged in so recognizers keying on `details.code` keep working.
|
||||
// Custom-mapped kinds carry structured details on the wire with the
|
||||
// subcode merged in so recognizers keying on `details.code` keep working.
|
||||
let err = ToolError::service_unavailable("sandbox not ready")
|
||||
.with_details(serde_json::json!({ "retry_after_ms": 1500 }));
|
||||
let wire = ToolErrorWire::from(err);
|
||||
@@ -481,10 +468,9 @@ mod wire_bridge_tests {
|
||||
|
||||
#[test]
|
||||
fn rate_limit_and_usage_kinds_merge_subcode_uniformly() {
|
||||
// Same property as service_unavailable, applied to every
|
||||
// Custom-mapped kind: object details without a `code` key gain the
|
||||
// subcode, so decode-side recognizers keying on `details.code` can
|
||||
// still classify the error.
|
||||
// Same as service_unavailable for every Custom-mapped kind: object
|
||||
// details without a `code` key gain the subcode so decode-side
|
||||
// recognizers keying on `details.code` can still classify the error.
|
||||
let cases: [(ToolError, &str); 5] = [
|
||||
(ToolError::rate_limited("slow down"), "rate_limited"),
|
||||
(
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
//! Unified tool runtime contract.
|
||||
//!
|
||||
//! Single home for the `Tool` trait, `ToolDispatch`, `ToolError`,
|
||||
//! `ToolNotification`, `ToolSearchIndex`, `ToolCallContext`, `ToolStream`,
|
||||
//! the in-process `LocalRegistry`, and the helper constructors that build
|
||||
//! well-formed streams. Adapters for individual tool sources re-export
|
||||
//! from here so every tool author sees the same surface.
|
||||
//! Adapters for individual tool sources re-export from here so every tool
|
||||
//! author sees the same surface.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
|
||||
@@ -38,14 +38,12 @@ impl std::fmt::Debug for LocalRegistry {
|
||||
}
|
||||
|
||||
impl LocalRegistry {
|
||||
/// Construct an empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a typed [`Tool`] implementation by value. Subsequent
|
||||
/// registrations of the same id replace the previous handle and
|
||||
/// return the displaced handle for inspection / drop ordering.
|
||||
/// Register a typed [`Tool`] by value. A later registration of the
|
||||
/// same id replaces the previous handle and returns it.
|
||||
pub fn register<T>(&self, tool: T) -> Option<ArcTool>
|
||||
where
|
||||
T: Tool + 'static,
|
||||
@@ -53,7 +51,7 @@ impl LocalRegistry {
|
||||
self.register_arc(Arc::new(tool))
|
||||
}
|
||||
|
||||
/// Register a typed [`Tool`] implementation already wrapped in `Arc`.
|
||||
/// Register a typed [`Tool`] already wrapped in `Arc`.
|
||||
pub fn register_arc<T>(&self, tool: Arc<T>) -> Option<ArcTool>
|
||||
where
|
||||
T: Tool + 'static,
|
||||
@@ -64,47 +62,41 @@ impl LocalRegistry {
|
||||
|
||||
/// Register a type-erased [`ToolDyn`](crate::tool::ToolDyn) directly.
|
||||
///
|
||||
/// Use this for inherently dynamic tools (e.g. MCP tools retrieved
|
||||
/// from a registry as `Arc<dyn ToolDyn>`) where the concrete type
|
||||
/// is not available. For native tools with a concrete type, prefer
|
||||
/// [`register`](Self::register).
|
||||
/// Use for inherently dynamic tools (e.g. MCP tools as
|
||||
/// `Arc<dyn ToolDyn>`) where the concrete type is unavailable. For
|
||||
/// native tools with a concrete type, prefer [`register`](Self::register).
|
||||
pub fn register_dyn(&self, tool: ArcTool) -> Option<ArcTool> {
|
||||
let id = tool.id();
|
||||
self.entries.write().insert(id, tool)
|
||||
}
|
||||
|
||||
/// Resolve `tool_id` to its in-process handle, if registered.
|
||||
/// Returns a clone of the handle so the caller can dispatch without
|
||||
/// holding the lock across an await point.
|
||||
/// Resolve `tool_id` to its in-process handle, if registered. Returns
|
||||
/// a clone so the caller can dispatch without holding the lock across
|
||||
/// an await point.
|
||||
pub fn find(&self, tool_id: &ToolId) -> Option<ArcTool> {
|
||||
self.entries.read().get(tool_id).cloned()
|
||||
}
|
||||
|
||||
/// Drop the handle bound to `tool_id`. Returns `true` iff a
|
||||
/// matching entry was removed.
|
||||
/// Drop the handle bound to `tool_id`. Returns `true` iff a matching
|
||||
/// entry was found and removed.
|
||||
pub fn unregister(&self, tool_id: &ToolId) -> bool {
|
||||
self.entries.write().shift_remove(tool_id).is_some()
|
||||
}
|
||||
|
||||
/// Number of tools currently registered.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.read().len()
|
||||
}
|
||||
|
||||
/// `true` iff no tools are registered.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.read().is_empty()
|
||||
}
|
||||
|
||||
/// `true` iff `tool_id` is currently registered.
|
||||
pub fn contains(&self, tool_id: &ToolId) -> bool {
|
||||
self.entries.read().contains_key(tool_id)
|
||||
}
|
||||
|
||||
/// Descriptions of registered tools filtered by `should_list`.
|
||||
///
|
||||
/// Returns descriptions in **insertion order** — the order tools
|
||||
/// were registered — so the caller sees the same ordering as the
|
||||
/// Descriptions of registered tools filtered by `should_list`, in
|
||||
/// **insertion order**, so the caller sees the same ordering as the
|
||||
/// config-defined tool list.
|
||||
pub fn list_tools(&self, ctx: &ListToolsContext) -> Vec<ToolDescription> {
|
||||
self.entries
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
//! adapters can serialise them without enabling additional features.
|
||||
//!
|
||||
//! Each `ToolNotification` variant has a parallel `send_*` convenience on
|
||||
//! [`ToolNotificationHandle`]. The two surfaces are kept in lockstep — when
|
||||
//! adding a variant here, add the `send_*` constructor too.
|
||||
//! [`ToolNotificationHandle`]. Keep the two surfaces in lockstep.
|
||||
//!
|
||||
//! The handle is built on `futures::channel::mpsc` so it is runtime-neutral:
|
||||
//! the trait crate doesn't pin a particular async executor on its
|
||||
@@ -24,10 +23,9 @@ use serde::{Deserialize, Serialize};
|
||||
/// made once.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BashNotificationBase {
|
||||
/// Tool call id, used to correlate with the originating tool call.
|
||||
/// Correlates with the originating tool call.
|
||||
pub tool_call_id: String,
|
||||
|
||||
/// The command being executed.
|
||||
pub command: String,
|
||||
|
||||
/// Captured output bytes. May be truncated; use `output_lossy` for a
|
||||
@@ -40,7 +38,6 @@ pub struct BashNotificationBase {
|
||||
/// Whether `output` was truncated to fit a size cap.
|
||||
pub truncated: bool,
|
||||
|
||||
/// Working directory the command ran in.
|
||||
pub cwd: PathBuf,
|
||||
}
|
||||
|
||||
@@ -76,7 +73,6 @@ pub struct BashExecutionComplete {
|
||||
}
|
||||
|
||||
impl BashExecutionComplete {
|
||||
/// `true` when termination was triggered by a signal.
|
||||
pub fn was_signaled(&self) -> bool {
|
||||
self.signal.is_some()
|
||||
}
|
||||
@@ -89,10 +85,8 @@ pub struct BashExecutionTimeout {
|
||||
#[serde(flatten)]
|
||||
pub base: BashNotificationBase,
|
||||
|
||||
/// Wall time the command ran for before being killed.
|
||||
pub elapsed: Duration,
|
||||
|
||||
/// Configured timeout that was exceeded.
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
@@ -122,23 +116,19 @@ pub struct BashExecutionFailed {
|
||||
pub tool_call_id: String,
|
||||
pub command: String,
|
||||
pub cwd: PathBuf,
|
||||
/// Error message describing the spawn / IO failure.
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// Emitted when a tool reads a file. Subscribers use this for state
|
||||
/// snapshotting (rewind, audit) of accessed files.
|
||||
/// Payload for a tool file-read event (rewind / audit subscribers).
|
||||
///
|
||||
/// **Reserved for a future `ToolNotification::FileRead` variant.** The
|
||||
/// struct is kept in the public API so adapters can construct it ahead of
|
||||
/// time, but it is not currently dispatched by any
|
||||
/// [`ToolNotificationHandle`] helper. Adding the enum variant here is a
|
||||
/// breaking change for exhaustive `match` consumers, so the variant is
|
||||
/// deferred until a downstream crate has a real consumer wired up.
|
||||
/// **Reserved for a future `ToolNotification::FileRead` variant.** Public
|
||||
/// so adapters can construct it, but no [`ToolNotificationHandle`] helper
|
||||
/// dispatches it yet. Introducing the enum variant is a breaking change for
|
||||
/// exhaustive `match` consumers, so it waits until a downstream crate has a
|
||||
/// real consumer wired up.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FileRead {
|
||||
pub tool_call_id: String,
|
||||
/// Absolute filesystem path of the file that was read.
|
||||
pub absolute_path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -147,13 +137,11 @@ pub struct FileRead {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FileWritten {
|
||||
pub tool_call_id: String,
|
||||
/// Absolute filesystem path of the file that was written.
|
||||
pub absolute_path: PathBuf,
|
||||
/// Full file content after the write.
|
||||
pub content: String,
|
||||
/// Full file content before the write. `None` for a fresh file.
|
||||
pub previous_content: Option<String>,
|
||||
/// Whether the write created a new file.
|
||||
pub is_new_file: bool,
|
||||
}
|
||||
|
||||
@@ -173,7 +161,6 @@ pub struct PlanModeExited {
|
||||
/// Plan content as captured at exit time. `None` when the plan file
|
||||
/// did not exist or was empty.
|
||||
pub plan_content: Option<String>,
|
||||
/// Path the plan file lives at.
|
||||
pub plan_file_path: String,
|
||||
}
|
||||
|
||||
@@ -243,7 +230,6 @@ pub struct ScheduledTaskRemoved {
|
||||
pub task_id: String,
|
||||
}
|
||||
|
||||
/// Sent when a scheduled task is created.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ScheduledTaskCreated {
|
||||
pub task_id: String,
|
||||
@@ -262,7 +248,6 @@ pub struct MonitorEvent {
|
||||
pub description: String,
|
||||
/// XML-wrapped event text, ready for conversation injection.
|
||||
pub event_text: String,
|
||||
/// Raw text without XML wrapping.
|
||||
pub raw_text: String,
|
||||
}
|
||||
|
||||
@@ -289,7 +274,6 @@ pub struct TaskSnapshot {
|
||||
pub exit_code: Option<i32>,
|
||||
pub signal: Option<String>,
|
||||
pub completed: bool,
|
||||
/// Distinguishes monitor tasks from regular bash tasks.
|
||||
#[serde(default)]
|
||||
pub kind: TaskKind,
|
||||
}
|
||||
@@ -305,18 +289,17 @@ impl TaskSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinguishes background-task kinds.
|
||||
/// Background-task kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskKind {
|
||||
/// Regular bash command.
|
||||
#[default]
|
||||
Bash,
|
||||
/// Monitor tool — streams stdout events with rate limiting.
|
||||
Monitor,
|
||||
}
|
||||
|
||||
/// A typed notification a tool emits during or after execution.
|
||||
/// Typed notification a tool emits during or after execution.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ToolNotification {
|
||||
@@ -382,7 +365,6 @@ pub struct ToolNotificationHandle {
|
||||
}
|
||||
|
||||
impl ToolNotificationHandle {
|
||||
/// Wrap a sender obtained elsewhere.
|
||||
pub fn new(sender: mpsc::UnboundedSender<ToolNotification>) -> Self {
|
||||
Self { sender }
|
||||
}
|
||||
@@ -408,122 +390,83 @@ impl ToolNotificationHandle {
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
/// Send a fully-built notification. Errors are deliberately swallowed;
|
||||
/// notifications are best-effort.
|
||||
/// Best-effort send; errors (closed receiver) are swallowed.
|
||||
pub fn send(&self, notification: ToolNotification) {
|
||||
let _ = self.sender.unbounded_send(notification);
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::BashOutputChunk`]: an incremental
|
||||
/// stdout/stderr chunk while a bash command is still running.
|
||||
pub fn send_bash_output_chunk(&self, chunk: BashOutputChunk) {
|
||||
self.send(ToolNotification::BashOutputChunk(chunk));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::BashExecutionComplete`]: a bash command
|
||||
/// exited (normally or via signal).
|
||||
pub fn send_bash_complete(&self, complete: BashExecutionComplete) {
|
||||
self.send(ToolNotification::BashExecutionComplete(complete));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::BashExecutionTimeout`]: a bash command
|
||||
/// exceeded its configured timeout and was killed.
|
||||
pub fn send_bash_timeout(&self, timeout: BashExecutionTimeout) {
|
||||
self.send(ToolNotification::BashExecutionTimeout(timeout));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::BashExecutionBackgrounded`]: a
|
||||
/// foreground bash command was moved to the background.
|
||||
pub fn send_bash_backgrounded(&self, backgrounded: BashExecutionBackgrounded) {
|
||||
self.send(ToolNotification::BashExecutionBackgrounded(backgrounded));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::BashExecutionFailed`]: a bash command
|
||||
/// could not be spawned.
|
||||
pub fn send_bash_failed(&self, failed: BashExecutionFailed) {
|
||||
self.send(ToolNotification::BashExecutionFailed(failed));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::FileWritten`]: a tool wrote to a file
|
||||
/// on disk.
|
||||
pub fn send_file_written(&self, written: FileWritten) {
|
||||
self.send(ToolNotification::FileWritten(written));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::TaskCompleted`]: a background task
|
||||
/// transitioned to a terminal state.
|
||||
pub fn send_task_complete(&self, task_completed: TaskSnapshot) {
|
||||
self.send(ToolNotification::TaskCompleted(task_completed));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::PlanModeEntered`]: the agent
|
||||
/// transitioned into plan mode.
|
||||
pub fn send_plan_mode_entered(&self, entered: PlanModeEntered) {
|
||||
self.send(ToolNotification::PlanModeEntered(entered));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::PlanModeExited`]: the agent transitioned
|
||||
/// out of plan mode and the captured plan is attached.
|
||||
pub fn send_plan_mode_exited(&self, exited: PlanModeExited) {
|
||||
self.send(ToolNotification::PlanModeExited(exited));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::UserQuestionAsked`]: the agent issued a
|
||||
/// structured question payload to the user.
|
||||
pub fn send_user_question_asked(&self, asked: UserQuestionAsked) {
|
||||
self.send(ToolNotification::UserQuestionAsked(asked));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::LspServerStarting`]: an LSP server is
|
||||
/// being spawned.
|
||||
pub fn send_lsp_starting(&self, starting: LspServerStarting) {
|
||||
self.send(ToolNotification::LspServerStarting(starting));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::LspServerReady`]: an LSP server
|
||||
/// finished its initialise handshake.
|
||||
pub fn send_lsp_ready(&self, ready: LspServerReady) {
|
||||
self.send(ToolNotification::LspServerReady(ready));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::LspServerCrashed`]: an LSP server
|
||||
/// process died unexpectedly.
|
||||
pub fn send_lsp_crashed(&self, crashed: LspServerCrashed) {
|
||||
self.send(ToolNotification::LspServerCrashed(crashed));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::LspServerRetrying`]: an LSP server is
|
||||
/// being restarted after a crash.
|
||||
pub fn send_lsp_retrying(&self, retrying: LspServerRetrying) {
|
||||
self.send(ToolNotification::LspServerRetrying(retrying));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::LspServerFailed`]: an LSP server is
|
||||
/// permanently dead (init failure or retry budget exhausted).
|
||||
pub fn send_lsp_failed(&self, failed: LspServerFailed) {
|
||||
self.send(ToolNotification::LspServerFailed(failed));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::ScheduledTaskFired`]: a recurring or
|
||||
/// one-shot scheduled task fired and its prompt should be executed.
|
||||
pub fn send_scheduled_task_fired(&self, fired: ScheduledTaskFired) {
|
||||
self.send(ToolNotification::ScheduledTaskFired(fired));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::ScheduledTaskRemoved`]: a scheduled task
|
||||
/// was deleted, expired, or a one-shot variant completed.
|
||||
pub fn send_scheduled_task_removed(&self, removed: ScheduledTaskRemoved) {
|
||||
self.send(ToolNotification::ScheduledTaskRemoved(removed));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::ScheduledTaskCreated`]: a new scheduled
|
||||
/// task was registered and should appear in subscriber views.
|
||||
pub fn send_scheduled_task_created(&self, created: ScheduledTaskCreated) {
|
||||
self.send(ToolNotification::ScheduledTaskCreated(created));
|
||||
}
|
||||
|
||||
/// Send a [`ToolNotification::MonitorEvent`]: a streaming event from
|
||||
/// a Monitor background process, ready for conversation injection.
|
||||
pub fn send_monitor_event(&self, event: MonitorEvent) {
|
||||
self.send(ToolNotification::MonitorEvent(event));
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ impl<T: ToolOutput + Serialize + ?Sized> ToolOutput for Box<T> {
|
||||
/// frontend.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ToolChatCompletionResponse {
|
||||
/// The main completion payload.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<ToolChatCompletion>,
|
||||
/// Structured stream error (e.g. rate-limit, tool failure).
|
||||
@@ -126,7 +125,6 @@ pub struct ToolChatCompletion {
|
||||
/// Always `"assistant"`.
|
||||
#[serde(default)]
|
||||
pub sender: String,
|
||||
/// Text body of the response.
|
||||
#[serde(default)]
|
||||
pub message: String,
|
||||
/// Tag discriminator: `"final"`, `"raw_function_result"`,
|
||||
@@ -139,7 +137,6 @@ pub struct ToolChatCompletion {
|
||||
/// JSON-encoded card attachment (images, render cards, files).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub card_attachment: Option<String>,
|
||||
/// Code execution result.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_execution_result: Option<ToolCodeExecutionResult>,
|
||||
/// Catch-all for additional fields the tool wants to set. Merged
|
||||
@@ -183,7 +180,7 @@ pub struct ToolStreamError {
|
||||
/// | 4 | Object with mixed fields | block-shaped fields extracted, rest as JSON text |
|
||||
/// | 5 | Anything else | `ContentBlock::Text` with the stringified value |
|
||||
pub fn extract_content_blocks(value: &Value) -> Vec<ContentBlock> {
|
||||
// 1. Value IS a single ContentBlock.
|
||||
// 1. Value is a single ContentBlock.
|
||||
if let Some(block) = try_parse_block(value) {
|
||||
return vec![block];
|
||||
}
|
||||
@@ -261,9 +258,7 @@ pub fn extract_content_blocks(value: &Value) -> Vec<ContentBlock> {
|
||||
vec![value_to_block(value)]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The `ContentBlock` enum is `#[serde(tag = "type", rename_all =
|
||||
/// "snake_case")]`, so a JSON object can only be a content block when
|
||||
@@ -293,13 +288,11 @@ fn try_parse_block(value: &Value) -> Option<ContentBlock> {
|
||||
serde_json::from_value::<ContentBlock>(value.clone()).ok()
|
||||
}
|
||||
|
||||
/// Result of inspecting a single object field value.
|
||||
enum FieldShape {
|
||||
/// The field value IS a single `ContentBlock`.
|
||||
/// Single `ContentBlock`.
|
||||
Block(ContentBlock),
|
||||
/// The field value is an array where *every* element is a `ContentBlock`.
|
||||
/// Array where every element is a `ContentBlock`.
|
||||
Blocks(Vec<ContentBlock>),
|
||||
/// The field value does not look like block content.
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -309,7 +302,6 @@ enum FieldShape {
|
||||
/// `ContentBlock`; mixed arrays go to `Other` so ambiguous data
|
||||
/// (e.g. `"scores": [0.9, 0.8]`) is not silently dropped.
|
||||
fn classify_field(value: &Value) -> FieldShape {
|
||||
// Single block.
|
||||
if let Some(block) = try_parse_block(value) {
|
||||
return FieldShape::Block(block);
|
||||
}
|
||||
@@ -343,11 +335,8 @@ fn value_to_block(value: &Value) -> ContentBlock {
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-erased extractor (used by the toolbox registry)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-erased extractor (toolbox registry)
|
||||
|
||||
/// Type-erased model output extractor.
|
||||
pub type ModelOutputExtractor = Arc<dyn Fn(&Value) -> Option<Vec<ContentBlock>> + Send + Sync>;
|
||||
|
||||
/// Build a [`ModelOutputExtractor`] for a concrete output type.
|
||||
@@ -374,7 +363,7 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── ToolOutput with custom override ─────────────────────────────
|
||||
// ToolOutput with custom override
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FakeOutput {
|
||||
@@ -423,7 +412,7 @@ mod tests {
|
||||
assert_eq!(o.model_output().len(), 2);
|
||||
}
|
||||
|
||||
// ── ToolOutput default → empty (runtime fills via extract) ──────
|
||||
// ToolOutput default → empty (runtime fills via extract)
|
||||
|
||||
#[test]
|
||||
fn default_model_output_returns_empty() {
|
||||
@@ -464,7 +453,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── extract_content_blocks unit tests ──────────────────────────
|
||||
// extract_content_blocks unit tests
|
||||
|
||||
// Strategy 1: single ContentBlock
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Backend-agnostic tool search interface.
|
||||
//!
|
||||
//! `ToolSearchIndex` is a `Send + Sync` trait so concrete implementations
|
||||
//! can live in different crates (BM25, OpenSearch, in-memory linear) and
|
||||
//! be stored as `Arc<dyn ToolSearchIndex>` for shared access across tasks.
|
||||
//! `ToolSearchIndex` is `Send + Sync` so concrete implementations can live
|
||||
//! in different crates (BM25, OpenSearch, in-memory linear) and be stored
|
||||
//! as `Arc<dyn ToolSearchIndex>` for shared access across tasks.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -13,26 +13,25 @@ pub struct ToolSearchResult {
|
||||
pub tool_name: String,
|
||||
/// Origin server name (e.g. `"linear"`).
|
||||
pub server_name: String,
|
||||
/// Tool description.
|
||||
pub description: String,
|
||||
/// Backend-defined relevance score; comparable within a single
|
||||
/// snapshot but not across snapshots.
|
||||
pub score: f32,
|
||||
/// Parameter names from the tool's input schema, in declaration order.
|
||||
pub parameters: Vec<String>,
|
||||
/// Full JSON Schema for the tool's input. Included so callers can
|
||||
/// construct dispatched tool calls without a separate schema fetch.
|
||||
/// Full JSON Schema for the tool's input so callers can construct
|
||||
/// dispatched tool calls without a separate schema fetch.
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Snapshot of a search query — results plus index metadata captured from
|
||||
/// the same point-in-time view.
|
||||
/// Snapshot of a search query — results plus index metadata from the same
|
||||
/// point-in-time view.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SearchSnapshot {
|
||||
pub results: Vec<ToolSearchResult>,
|
||||
/// Number of indexed tools that did not appear in `results`.
|
||||
pub total_hidden_tools: usize,
|
||||
/// `true` when the index reflects all available tools. `false` while
|
||||
/// `true` when the index reflects all available tools; `false` while
|
||||
/// the index source is still warming up.
|
||||
pub is_ready: bool,
|
||||
}
|
||||
@@ -44,13 +43,11 @@ pub struct ServerSummary {
|
||||
pub name: String,
|
||||
/// Optional short description of the server's surface area.
|
||||
pub description: Option<String>,
|
||||
/// Unqualified tool names, sorted alphabetically. Use
|
||||
/// [`Self::tool_count`] for a count without indirection.
|
||||
/// Unqualified tool names, sorted alphabetically.
|
||||
pub tool_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl ServerSummary {
|
||||
/// Number of tools the server exposes.
|
||||
pub fn tool_count(&self) -> usize {
|
||||
self.tool_names.len()
|
||||
}
|
||||
@@ -61,18 +58,16 @@ impl ServerSummary {
|
||||
/// Implementations must be `Send + Sync` so they can be wrapped in
|
||||
/// `Arc<dyn ToolSearchIndex>` and shared across concurrent tasks.
|
||||
pub trait ToolSearchIndex: Send + Sync {
|
||||
/// Run a query against a single consistent index snapshot. Returning
|
||||
/// the metadata alongside the results lets the caller render an
|
||||
/// accurate "N results out of M" line without a second call.
|
||||
/// Query a single consistent index snapshot. Metadata rides with the
|
||||
/// results so the caller can render "N of M" without a second call.
|
||||
fn search_snapshot(&self, query: &str, limit: usize) -> SearchSnapshot;
|
||||
|
||||
/// Enumerate the unique servers in the index. Used to render the
|
||||
/// system-reminder listing connected integrations.
|
||||
/// Unique servers in the index (e.g. for a system-reminder listing
|
||||
/// connected integrations).
|
||||
fn list_server_summaries(&self) -> Vec<ServerSummary>;
|
||||
}
|
||||
|
||||
/// Resource wrapper for storing a `ToolSearchIndex` behind an `Arc` in
|
||||
/// shared resource maps.
|
||||
/// `ToolSearchIndex` behind an `Arc` for shared resource maps.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolIndex(pub Arc<dyn ToolSearchIndex>);
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Limit / latency invariants ──────────────────────────────────────────
|
||||
// Limit / latency invariants
|
||||
|
||||
/// A backlog drains in exactly `ceil(new / cap)` calls — no extra round-trips.
|
||||
#[test]
|
||||
@@ -383,7 +383,8 @@ mod tests {
|
||||
/// UTF-8 backoff loses at most 3 bytes, so frames stay within 3 of the cap.
|
||||
#[test]
|
||||
fn utf8_backoff_stays_within_three_bytes_of_cap() {
|
||||
let cap = 7usize; // splits a 4-byte char -> backs off to 4 (cap - 3)
|
||||
// Cap 7 splits a 4-byte char; backoff yields 4 (cap - 3).
|
||||
let cap = 7usize;
|
||||
let spec = spec_with(Some(cap as u32));
|
||||
let data = "😀😀😀😀".as_bytes();
|
||||
let total = data.len() as u64;
|
||||
@@ -408,7 +409,8 @@ mod tests {
|
||||
fn gap_with_cap_paces_surviving_tail_and_terminates() {
|
||||
let spec = spec_with(Some(4));
|
||||
let tail = b"abcdefgh";
|
||||
let total = 1000u64; // only 8 of 1000 bytes survived in the tail
|
||||
// Only 8 of 1000 bytes survive in the tail.
|
||||
let total = 1000u64;
|
||||
let mut last = 0;
|
||||
let mut ticks = 0usize;
|
||||
let mut emitted = 0usize;
|
||||
|
||||
@@ -141,11 +141,9 @@ pub enum ToolProgress {
|
||||
Text { text: String },
|
||||
/// Rich content blocks.
|
||||
Content { blocks: Vec<ContentBlock> },
|
||||
/// Tool-defined progress payload. `subkind` is a stable snake-case
|
||||
/// discriminator owned by the tool. The outer `"kind"` serde tag is
|
||||
/// always `"custom"` for this variant; `subkind` is the producer's
|
||||
/// own discriminator and lives one level deeper to avoid colliding
|
||||
/// with the tag.
|
||||
/// Tool-defined progress. Outer serde tag is always `"custom"`; `subkind`
|
||||
/// is the producer's discriminator one level deeper (avoids colliding
|
||||
/// with the tag).
|
||||
Custom {
|
||||
subkind: String,
|
||||
payload: serde_json::Value,
|
||||
@@ -244,7 +242,6 @@ where
|
||||
/// least one block.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypedToolOutput {
|
||||
/// Identity of the tool that produced this output.
|
||||
pub tool_id: ToolId,
|
||||
/// Serialised JSON representation of the tool output.
|
||||
pub value: Value,
|
||||
@@ -301,21 +298,17 @@ impl ToolOutput for TypedToolOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type erased tool trait. Auto-generated for every typed Tool implementation.
|
||||
/// Type-erased tool trait. Blanket-impl'd for every typed [`Tool`].
|
||||
#[async_trait]
|
||||
pub trait ToolDyn: Send + Sync {
|
||||
/// Stable identity. Same value as [`Tool::id`].
|
||||
fn id(&self) -> ToolId;
|
||||
|
||||
/// Model-facing description. Same value as [`Tool::description`].
|
||||
fn description(&self, ctx: &ListToolsContext) -> ToolDescription;
|
||||
|
||||
/// Per-tool capability flags. Same value as [`Tool::capabilities`].
|
||||
fn capabilities(&self) -> ToolCapabilities {
|
||||
ToolCapabilities::default()
|
||||
}
|
||||
|
||||
/// Same value as [`Tool::has_dynamic_description`].
|
||||
fn has_dynamic_description(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -401,23 +394,21 @@ impl<T: Tool> ToolDyn for T {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience alias for the most common [`ToolDyn`] handle shape.
|
||||
pub type ArcTool = Arc<dyn ToolDyn>;
|
||||
|
||||
/// Variant identifier for tools that ship multiple implementations under
|
||||
/// one stable [`ToolId`].
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
|
||||
pub enum ToolVariant {
|
||||
/// The implicit fallback variant.
|
||||
/// Implicit fallback variant.
|
||||
Default,
|
||||
/// A named variant. The string is treated opaquely by the registry.
|
||||
/// Named variant; treated opaquely by the registry.
|
||||
Variant(String),
|
||||
}
|
||||
|
||||
/// Group of related tools that share one [`ToolId`] but route to different
|
||||
/// implementations chosen by a [`ToolVariant`].
|
||||
pub trait ToolFamily: Send + Sync {
|
||||
/// Identity shared by every variant in this family.
|
||||
fn id(&self) -> ToolId;
|
||||
|
||||
/// Resolve a `variant` to its concrete tool. Returns `None` when the
|
||||
@@ -436,5 +427,4 @@ pub trait ToolFamily: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience alias for the most common [`ToolFamily`] handle shape.
|
||||
pub type ArcToolFamily = Arc<dyn ToolFamily>;
|
||||
|
||||
@@ -88,8 +88,7 @@ fn remove_returns_value_then_none() {
|
||||
|
||||
#[test]
|
||||
fn insert_arc_shares_allocation() {
|
||||
// Inserting an existing Arc means the stored value and the original
|
||||
// share strong-count.
|
||||
// insert_arc shares the Arc (strong-count rises).
|
||||
let arc = Arc::new(Config {
|
||||
base_url: "shared".into(),
|
||||
timeout_ms: 9,
|
||||
@@ -97,10 +96,7 @@ fn insert_arc_shares_allocation() {
|
||||
let mut ctx = ToolCallContext::default();
|
||||
ctx.extensions.insert_arc(arc.clone());
|
||||
let from_ctx = ctx.extensions.get::<Config>().unwrap();
|
||||
// Strong-count on the original Arc should reflect at least:
|
||||
// - the original `arc` binding
|
||||
// - the value stored in the extension map
|
||||
// - the clone returned from `get`
|
||||
// Strong-count: original binding + map entry + get() clone.
|
||||
assert!(Arc::strong_count(&arc) >= 3);
|
||||
assert_eq!(*from_ctx, *arc);
|
||||
}
|
||||
@@ -152,13 +148,10 @@ fn clone_preserves_call_id_and_extensions() {
|
||||
assert_eq!(copy.call_id, ctx.call_id);
|
||||
assert_eq!(copy.extensions.len(), 1);
|
||||
|
||||
// Both clones see the same Arc-backed extension value.
|
||||
let from_orig = ctx.extensions.get::<AuthToken>().unwrap();
|
||||
let from_copy = copy.extensions.get::<AuthToken>().unwrap();
|
||||
assert_eq!(from_orig.0, from_copy.0);
|
||||
// The Arc allocation is shared; mutating via one path is impossible
|
||||
// (extensions are immutable through `get`), but strong-count rises
|
||||
// because of the clone.
|
||||
// Arc is shared; get() only clones the handle (immutable).
|
||||
assert!(Arc::strong_count(&from_orig) >= 3);
|
||||
}
|
||||
|
||||
@@ -176,21 +169,12 @@ fn clone_extension_map_is_independent_after_remove() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-concept client/SDK-side extensions.
|
||||
//
|
||||
// These exist as separate extensions (one per concept) rather than a
|
||||
// single bundle. The tests below pin three contracts:
|
||||
//
|
||||
// 1. Each extension round-trips through the typed-extension store
|
||||
// independently of the others.
|
||||
// 2. A dispatcher with only some of the concepts can install them
|
||||
// individually — installing `Cwd` MUST NOT make `BehaviorVersion`
|
||||
// look "present" with a default value, and vice versa.
|
||||
// 3. Absence of every well-known extension is the legitimate "backend
|
||||
// dispatcher" shape; tools that require one MUST treat absence as
|
||||
// a hard error rather than fall back to a process-wide default.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-concept client/SDK-side extensions (one type per concept, not a bundle).
|
||||
// Pins three contracts:
|
||||
// 1. Each extension round-trips independently.
|
||||
// 2. Installing one MUST NOT make another look "present" with a default.
|
||||
// 3. Absence is the legitimate backend-dispatcher shape; tools that need
|
||||
// an extension MUST treat absence as a hard error.
|
||||
|
||||
#[test]
|
||||
fn each_well_known_extension_round_trips_independently() {
|
||||
@@ -217,9 +201,7 @@ fn each_well_known_extension_round_trips_independently() {
|
||||
|
||||
#[test]
|
||||
fn dispatcher_can_install_only_what_it_has() {
|
||||
// A dispatcher that knows the cwd but not the trace context installs
|
||||
// only `Cwd`. The other extensions stay absent (not "default"),
|
||||
// which is the discriminator a tool can rely on.
|
||||
// Only Cwd installed — other extensions stay absent (not defaulted).
|
||||
let mut ctx = ToolCallContext::default();
|
||||
ctx.extensions
|
||||
.insert(Cwd(std::path::PathBuf::from("/work")));
|
||||
@@ -229,8 +211,7 @@ fn dispatcher_can_install_only_what_it_has() {
|
||||
assert!(!ctx.extensions.contains::<TraceContext>());
|
||||
assert_eq!(ctx.extensions.len(), 1);
|
||||
|
||||
// Adding `TraceContext` later does not implicitly conjure a
|
||||
// `BehaviorVersion` — extensions are independent.
|
||||
// Installing TraceContext does not conjure BehaviorVersion.
|
||||
ctx.extensions.insert(TraceContext("tp".into()));
|
||||
assert!(ctx.extensions.contains::<TraceContext>());
|
||||
assert!(!ctx.extensions.contains::<BehaviorVersion>());
|
||||
@@ -239,9 +220,7 @@ fn dispatcher_can_install_only_what_it_has() {
|
||||
|
||||
#[test]
|
||||
fn absence_signals_backend_or_other_mode() {
|
||||
// A backend dispatcher installs none of the client-side extensions.
|
||||
// Tools that require any of them must treat absence as a hard error
|
||||
// — this test pins the contract.
|
||||
// Backend dispatcher: no client-side extensions present.
|
||||
let ctx = ToolCallContext::default();
|
||||
assert!(ctx.extensions.get::<Cwd>().is_none());
|
||||
assert!(ctx.extensions.get::<BehaviorVersion>().is_none());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `From<ToolError> for ToolErrorWire` coverage for the struct-based ToolError.
|
||||
//! `From<ToolError> for ToolErrorWire` coverage.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -390,7 +390,6 @@ fn noop_handle_does_not_panic_or_record() {
|
||||
handle.send_lsp_ready(LspServerReady {
|
||||
server_name: "x".into(),
|
||||
});
|
||||
// No assertion needed — the handle drops sends silently.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -93,10 +93,8 @@ fn tool_index_wrapper_clones_arc() {
|
||||
});
|
||||
let wrapped = ToolIndex(inner.clone());
|
||||
let copy = wrapped.clone();
|
||||
// Both wrappers hold the same Arc — strong-count includes both
|
||||
// wrappers and the original `inner` binding.
|
||||
// Both wrappers share the Arc with `inner`.
|
||||
assert!(Arc::strong_count(&inner) >= 3);
|
||||
// Debug impl renders without leaking the inner type.
|
||||
let debug = format!("{wrapped:?}");
|
||||
assert_eq!(debug, "ToolIndex");
|
||||
drop(copy);
|
||||
|
||||
@@ -77,8 +77,6 @@ impl Tool for NeedsAttachmentTool {
|
||||
}
|
||||
}
|
||||
|
||||
// Tool::should_list (typed)
|
||||
|
||||
#[test]
|
||||
fn default_returns_true() {
|
||||
assert!(Tool::should_list(&AlwaysTool, &ListToolsContext::default()));
|
||||
@@ -109,8 +107,6 @@ fn reads_custom_extension() {
|
||||
assert!(Tool::should_list(&tool, &some));
|
||||
}
|
||||
|
||||
// ToolDyn blanket forwarding
|
||||
|
||||
#[test]
|
||||
fn dyn_forwards_default() {
|
||||
let tool: ArcTool = Arc::new(AlwaysTool);
|
||||
@@ -136,8 +132,6 @@ fn arc_dyn_callable() {
|
||||
assert!(tool.should_list(&ctx));
|
||||
}
|
||||
|
||||
// ListToolsContext
|
||||
|
||||
#[test]
|
||||
fn list_ctx_default_is_empty() {
|
||||
let ctx = ListToolsContext::default();
|
||||
@@ -164,8 +158,6 @@ fn list_ctx_clone_is_independent() {
|
||||
assert!(!copy.extensions.contains::<AttachmentCount>());
|
||||
}
|
||||
|
||||
// TypedExtensions standalone
|
||||
|
||||
#[test]
|
||||
fn typed_extensions_insert_get_remove() {
|
||||
let mut ext = kigi_tool_runtime::TypedExtensions::new();
|
||||
|
||||
@@ -151,8 +151,7 @@ async fn unimplemented_tool_returns_not_implemented_terminal() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_takes_args_by_value() {
|
||||
// The trait `run` consumes args; this would not compile if the
|
||||
// signature accidentally borrowed.
|
||||
// run consumes args (would not compile if the signature borrowed).
|
||||
let tool = BlockingOk;
|
||||
let args = EchoArgs {
|
||||
text: "consumed".into(),
|
||||
@@ -163,7 +162,6 @@ async fn run_takes_args_by_value() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_default_drains_in_one_pass() {
|
||||
// A stream from the default impl should always have exactly one item.
|
||||
let tool = BlockingOk;
|
||||
let count = tool
|
||||
.execute(ToolCallContext::default(), EchoArgs { text: "n".into() })
|
||||
|
||||
@@ -130,7 +130,7 @@ impl Tool for UnencodableTool {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool with custom ToolOutput (non-empty) ──────────────────
|
||||
// Tool with custom ToolOutput (non-empty)
|
||||
|
||||
/// Output that provides its own model-facing content blocks. The blanket
|
||||
/// impl must forward these as-is rather than filling in the JSON fallback.
|
||||
@@ -201,7 +201,6 @@ async fn tool_dyn_preserves_custom_model_output() {
|
||||
{"type": "image", "mime_type": "image/png", "data": "base64data"},
|
||||
]})
|
||||
);
|
||||
// Custom model output preserved verbatim — no JSON fallback.
|
||||
assert_eq!(typed.model_output.len(), 2);
|
||||
assert_eq!(
|
||||
typed.model_output[0],
|
||||
@@ -238,8 +237,7 @@ async fn tool_dyn_blanket_encodes_terminal_output() {
|
||||
ToolStreamItem::Terminal(Ok(typed)) => {
|
||||
assert_eq!(typed.tool_id, tid("blocking_echo"));
|
||||
assert_eq!(typed.value, json!({"text": "hi"}));
|
||||
// EchoOutput uses the default ToolOutput which
|
||||
// serialises self to a JSON text block (MCP-compliant).
|
||||
// Default ToolOutput serialises self to a JSON text block (MCP).
|
||||
assert_eq!(typed.model_output.len(), 1);
|
||||
assert_eq!(
|
||||
typed.model_output[0],
|
||||
@@ -283,7 +281,7 @@ async fn tool_dyn_blanket_passes_progress_through() {
|
||||
#[tokio::test]
|
||||
async fn tool_dyn_invalid_args_become_invalid_arguments_terminal() {
|
||||
let tool: ArcTool = Arc::new(BlockingEcho);
|
||||
// `text` is required and must be a string — `null` fails serde.
|
||||
// `text` is required and must be a string.
|
||||
let mut stream = tool
|
||||
.execute(ToolCallContext::default(), json!({"text": null}))
|
||||
.await;
|
||||
@@ -314,9 +312,7 @@ async fn tool_dyn_unencodable_output_becomes_execution_terminal() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolFamily
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Backend-flavoured echo. Two variants share the `echo` tool id and only
|
||||
/// differ in the prefix attached to the output text — enough to assert
|
||||
@@ -437,9 +433,7 @@ async fn tool_family_default_variant_name_defaults_to_none() {
|
||||
assert!(family.default_variant_name().is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object safety / ergonomic checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tool_dyn_is_object_safe_in_arc_and_box() {
|
||||
@@ -455,8 +449,7 @@ fn tool_family_is_object_safe_in_arc_and_box() {
|
||||
|
||||
#[test]
|
||||
fn arc_tool_alias_holds_heterogeneous_tools() {
|
||||
// The whole point of `ArcTool` — many typed `Tool` impls collapse
|
||||
// into one container shape via the blanket impl.
|
||||
// ArcTool: many typed Tool impls collapse into one container via the blanket.
|
||||
let tools: Vec<ArcTool> = vec![Arc::new(BlockingEcho), Arc::new(StreamingEcho)];
|
||||
assert_eq!(tools.len(), 2);
|
||||
let ids: Vec<_> = tools.iter().map(|t| t.id()).collect();
|
||||
@@ -484,8 +477,7 @@ fn _compile_time_blanket_check() {
|
||||
let tool = StreamingEcho;
|
||||
_accepts_dyn(&tool);
|
||||
|
||||
// The trait objects themselves must be `Send + Sync` so they can be
|
||||
// shared across tasks without further bounds at the call site.
|
||||
// Trait objects are Send + Sync for sharing across tasks.
|
||||
fn _is_send_sync<T: Send + Sync + ?Sized>() {}
|
||||
_is_send_sync::<dyn ToolDyn>();
|
||||
_is_send_sync::<dyn ToolFamily>();
|
||||
|
||||
@@ -150,7 +150,6 @@ async fn streaming_err_propagates_through_terminal() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_progress_count_is_independent_of_args() {
|
||||
// Distinct invocations on the same tool produce the same shape.
|
||||
let tool = StreamingOk;
|
||||
for _ in 0..3 {
|
||||
let count = tool
|
||||
@@ -164,8 +163,7 @@ async fn streaming_progress_count_is_independent_of_args() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_progress_still_yields_terminal() {
|
||||
// Building `with_progress` on an empty stream still produces exactly
|
||||
// one terminal item — the same shape `terminal_only` produces.
|
||||
// Empty progress stream still yields exactly one terminal item.
|
||||
let progress = stream::iter(Vec::<ToolProgress>::new());
|
||||
let mut stream = with_progress(progress, async move { Ok::<u32, ToolError>(99) });
|
||||
let item = stream.next().await.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user