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:
2026-07-23 16:55:39 -04:00
parent ff0fb56c67
commit a02b555e66
1458 changed files with 10729 additions and 21750 deletions
+6 -10
View File
@@ -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.
+13 -17
View File
@@ -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 -28
View File
@@ -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"),
(
+2 -5
View File
@@ -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));
}
+7 -18
View File
@@ -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]
+14 -19
View File
@@ -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;
+6 -16
View File
@@ -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();