Skip to main content

everruns_capability/
definition.rs

1//! Code-defined capability authoring: typed tool handlers, schema metadata,
2//! and structured execution errors.
3//!
4//! This module is the neutral service-provider interface for reusable
5//! capability packages that need several typed tools, capability-level
6//! instructions and metadata, execution context, progress events, or
7//! call-scoped cancellation. It deliberately depends on no engine, host, or
8//! async-runtime crate: hosts adapt [`Context`] onto their own runtime via
9//! the [`ProgressSink`] and [`CancellationSignal`] seams, and third-party
10//! capability crates can depend on `everruns-capability` alone.
11//!
12//! Application authors normally consume these types re-exported as
13//! `everruns::capability`.
14//!
15//! # Example
16//!
17//! ```
18//! use everruns_capability::definition::{
19//!     self as capability, Context, Definition, Error, Handler,
20//! };
21//!
22//! #[derive(capability::Deserialize, capability::JsonSchema)]
23//! #[serde(crate = "everruns_capability::serde")]
24//! #[schemars(crate = "everruns_capability::schemars")]
25//! struct LookupInput {
26//!     city: String,
27//! }
28//!
29//! #[derive(capability::Serialize, capability::JsonSchema)]
30//! #[serde(crate = "everruns_capability::serde")]
31//! #[schemars(crate = "everruns_capability::schemars")]
32//! struct LookupOutput {
33//!     city: String,
34//!     forecast: String,
35//! }
36//!
37//! struct Lookup;
38//!
39//! #[capability::async_trait]
40//! impl Handler for Lookup {
41//!     type Input = LookupInput;
42//!     type Output = LookupOutput;
43//!     type Error = Error;
44//!
45//!     fn name(&self) -> &str { "lookup_weather" }
46//!     fn description(&self) -> &str { "Look up the weather for a city." }
47//!
48//!     async fn execute(
49//!         &self,
50//!         input: Self::Input,
51//!         context: Context,
52//!     ) -> Result<Self::Output, Self::Error> {
53//!         context.progress("Looking up the forecast").await;
54//!         Ok(LookupOutput { city: input.city, forecast: "sunny".into() })
55//!     }
56//! }
57//!
58//! let weather = Definition::new("weather", "Weather", "Typed weather lookup tools.")
59//!     .instructions("Use weather data only when a user asks about a location.")
60//!     .tool(Lookup);
61//!
62//! weather.validate().unwrap();
63//! assert_eq!(weather.tools()[0].spec().name(), "lookup_weather");
64//! ```
65
66use std::fmt;
67use std::future::Future;
68use std::pin::Pin;
69use std::sync::Arc;
70
71use serde::de::DeserializeOwned;
72use serde_json::Value;
73
74use crate::error::CapabilityError;
75use crate::id::validate_capability_id;
76
77pub use async_trait::async_trait;
78pub use schemars::{self, JsonSchema};
79pub use serde::{self, Deserialize, Serialize};
80pub use serde_json;
81
82/// A boxed future used by the host-adaptation seams in this module.
83pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
84
85/// A reusable code-defined capability.
86///
87/// A definition is an immutable value: clone it to install the same
88/// capability on several agents. The consuming host registers it privately
89/// when the agent's session starts; capability authors never manipulate an
90/// engine registry.
91#[derive(Clone)]
92pub struct Definition {
93    id: String,
94    name: String,
95    description: String,
96    instructions: Option<String>,
97    metadata: Option<Value>,
98    tools: Vec<Tool>,
99}
100
101impl Definition {
102    /// Start a capability definition.
103    ///
104    /// `id` is the stable persisted identifier. `name` and `description` are
105    /// human-facing catalog text. These values, tool names, and tool input
106    /// schemas are validated by the consuming boundary (e.g. the Framework's
107    /// `AgentBuilder::build`).
108    pub fn new(
109        id: impl Into<String>,
110        name: impl Into<String>,
111        description: impl Into<String>,
112    ) -> Self {
113        Self {
114            id: id.into(),
115            name: name.into(),
116            description: description.into(),
117            instructions: None,
118            metadata: None,
119            tools: Vec::new(),
120        }
121    }
122
123    /// Add capability-level behavioral guidance to the agent's system prompt.
124    ///
125    /// Do not repeat facts already expressed by tool names, descriptions, or
126    /// schemas. Use this for cross-tool ordering, constraints, and semantics.
127    pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
128        self.instructions = Some(instructions.into());
129        self
130    }
131
132    /// Attach host-owned, JSON metadata for catalogs and embedding hosts.
133    ///
134    /// The engine does not interpret this value. It may be persisted or shown
135    /// to clients, so it must never contain credentials or sensitive payloads.
136    pub fn metadata(mut self, metadata: impl Into<Value>) -> Self {
137        self.metadata = Some(metadata.into());
138        self
139    }
140
141    /// Add one typed tool handler.
142    pub fn tool<H>(mut self, handler: H) -> Self
143    where
144        H: Handler,
145    {
146        self.tools.push(Tool::new(handler));
147        self
148    }
149
150    /// The stable capability identifier.
151    pub fn id(&self) -> &str {
152        &self.id
153    }
154
155    /// The human-readable capability name.
156    pub fn name(&self) -> &str {
157        &self.name
158    }
159
160    /// The capability description.
161    pub fn description(&self) -> &str {
162        &self.description
163    }
164
165    /// Capability-level instructions, when configured.
166    pub fn instructions_text(&self) -> Option<&str> {
167        self.instructions.as_deref()
168    }
169
170    /// Host-owned capability metadata, when configured.
171    pub fn metadata_value(&self) -> Option<&Value> {
172        self.metadata.as_ref()
173    }
174
175    /// Typed tool descriptors in registration order.
176    pub fn tools(&self) -> &[Tool] {
177        &self.tools
178    }
179
180    /// Validate the definition's identity and structural rules.
181    pub fn validate(&self) -> Result<(), CapabilityError> {
182        validate_capability_id(&self.id)?;
183        let invalid = |reason: &str| CapabilityError::InvalidDefinition {
184            id: self.id.clone(),
185            reason: reason.to_string(),
186        };
187        if self.name.trim().is_empty() {
188            return Err(invalid("capability name must not be blank"));
189        }
190        if self.description.trim().is_empty() {
191            return Err(invalid("capability description must not be blank"));
192        }
193        if self
194            .instructions
195            .as_ref()
196            .is_some_and(|text| text.trim().is_empty())
197        {
198            return Err(invalid("capability instructions must not be blank"));
199        }
200        if self.tools.is_empty() {
201            return Err(invalid("capability must define at least one tool"));
202        }
203        if let Some(tool) = self
204            .tools
205            .iter()
206            .find(|tool| tool.spec.description.trim().is_empty())
207        {
208            return Err(CapabilityError::InvalidDefinition {
209                id: self.id.clone(),
210                reason: format!("tool {:?} description must not be blank", tool.spec.name),
211            });
212        }
213        Ok(())
214    }
215}
216
217impl fmt::Debug for Definition {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        f.debug_struct("Definition")
220            .field("id", &self.id)
221            .field("name", &self.name)
222            .field("description", &self.description)
223            .field("instructions", &self.instructions)
224            .field("metadata", &self.metadata)
225            .field("tools", &self.tools)
226            .finish()
227    }
228}
229
230/// Implemented by one typed tool inside a code-defined capability.
231///
232/// Input values are deserialized after the model calls the tool. Output
233/// values are serialized as JSON without a `String` intermediary. Both types
234/// also provide schemas, allowing hosts and documentation to inspect the full
235/// protocol even though the current model protocol consumes only the input
236/// schema.
237#[async_trait]
238pub trait Handler: Send + Sync + 'static {
239    /// Typed tool arguments.
240    type Input: DeserializeOwned + JsonSchema + Send + 'static;
241    /// Typed, JSON-serializable tool result.
242    type Output: Serialize + JsonSchema + Send + 'static;
243    /// Structured handler error. Custom error enums can implement `Into<Error>`.
244    type Error: Into<Error> + Send + 'static;
245
246    /// Stable model-facing tool name.
247    fn name(&self) -> &str;
248    /// Model-facing description of when and how to use the tool.
249    fn description(&self) -> &str;
250
251    /// Optional human-readable display name for clients.
252    fn display_name(&self) -> Option<&str> {
253        None
254    }
255
256    /// Semantic and host-owned tool metadata.
257    fn hints(&self) -> Hints {
258        Hints::default()
259    }
260
261    /// Execute one call.
262    ///
263    /// Awaited work is cancelled by dropping this future when the turn stops.
264    /// Use [`Context::cancellation`] for child tasks or resources that can
265    /// outlive this future, and [`Context::progress`] for correlated status.
266    async fn execute(
267        &self,
268        input: Self::Input,
269        context: Context,
270    ) -> Result<Self::Output, Self::Error>;
271}
272
273/// A type-erased typed tool plus its stable protocol descriptor.
274///
275/// Constructed through [`Definition::tool`] in normal use. The public
276/// accessors exist so capability packages can test and document their
277/// exported schema, and so hosts can execute calls via [`Tool::invoke`].
278#[derive(Clone)]
279pub struct Tool {
280    spec: ToolSpec,
281    handler: Arc<dyn ErasedHandler>,
282}
283
284impl Tool {
285    fn new<H: Handler>(handler: H) -> Self {
286        let spec = ToolSpec {
287            name: handler.name().to_string(),
288            display_name: handler.display_name().map(str::to_string),
289            description: handler.description().to_string(),
290            input_schema: schema_for::<H::Input>(),
291            output_schema: schema_for::<H::Output>(),
292            hints: handler.hints(),
293        };
294        Self {
295            spec,
296            handler: Arc::new(HandlerAdapter(handler)),
297        }
298    }
299
300    /// The stable tool protocol descriptor.
301    pub fn spec(&self) -> &ToolSpec {
302        &self.spec
303    }
304
305    /// Execute one call: deserialize arguments, run the handler, serialize
306    /// the typed output.
307    ///
308    /// Invalid arguments surface as a model-visible
309    /// `invalid_arguments` [`Error`]; output serialization failures surface
310    /// as an internal `result_serialization` [`Error`].
311    pub async fn invoke(&self, arguments: Value, context: Context) -> Result<Value, Error> {
312        self.handler.call(arguments, context).await
313    }
314}
315
316impl fmt::Debug for Tool {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        f.debug_struct("Tool").field("spec", &self.spec).finish()
319    }
320}
321
322#[async_trait]
323trait ErasedHandler: Send + Sync {
324    async fn call(&self, input: Value, context: Context) -> Result<Value, Error>;
325}
326
327struct HandlerAdapter<H>(H);
328
329#[async_trait]
330impl<H: Handler> ErasedHandler for HandlerAdapter<H> {
331    async fn call(&self, input: Value, context: Context) -> Result<Value, Error> {
332        let input = serde_json::from_value::<H::Input>(input).map_err(|error| {
333            Error::user(
334                "invalid_arguments",
335                format!("tool arguments did not match the declared schema: {error}"),
336            )
337        })?;
338        let output = self.0.execute(input, context).await.map_err(Into::into)?;
339        serde_json::to_value(output).map_err(|error| {
340            Error::internal(
341                "result_serialization",
342                format!("failed to serialize capability result: {error}"),
343            )
344        })
345    }
346}
347
348fn schema_for<T: JsonSchema>() -> Value {
349    serde_json::to_value(schemars::schema_for!(T)).unwrap_or(Value::Null)
350}
351
352/// Stable metadata and JSON schemas for one capability tool.
353#[derive(Clone, Debug)]
354pub struct ToolSpec {
355    name: String,
356    display_name: Option<String>,
357    description: String,
358    input_schema: Value,
359    output_schema: Value,
360    hints: Hints,
361}
362
363impl ToolSpec {
364    /// Stable model-facing name.
365    pub fn name(&self) -> &str {
366        &self.name
367    }
368    /// Optional human-readable display name.
369    pub fn display_name(&self) -> Option<&str> {
370        self.display_name.as_deref()
371    }
372    /// Model-facing tool description.
373    pub fn description(&self) -> &str {
374        &self.description
375    }
376    /// Generated JSON Schema for [`Handler::Input`].
377    pub fn input_schema(&self) -> &Value {
378        &self.input_schema
379    }
380    /// Generated JSON Schema for [`Handler::Output`].
381    pub fn output_schema(&self) -> &Value {
382        &self.output_schema
383    }
384    /// Semantic and host-owned annotations.
385    pub fn hints(&self) -> &Hints {
386        &self.hints
387    }
388}
389
390/// Semantic and host-owned annotations for a capability tool.
391///
392/// Boolean values are `Option<bool>` so unspecified remains distinct from
393/// false. Hints inform scheduling and clients; they do not grant authority.
394#[derive(Clone, Debug, Default, PartialEq)]
395pub struct Hints {
396    /// The tool does not modify state.
397    pub readonly: Option<bool>,
398    /// The tool may irreversibly delete or destroy state.
399    pub destructive: Option<bool>,
400    /// Repeating a call with the same arguments is safe.
401    pub idempotent: Option<bool>,
402    /// The tool interacts with systems outside the local process.
403    pub open_world: Option<bool>,
404    /// The tool may commonly take more than a few seconds.
405    pub long_running: Option<bool>,
406    /// Calls sharing this non-empty key execute sequentially.
407    pub concurrency_class: Option<String>,
408    /// Host-owned annotations. Never include credentials or sensitive data.
409    pub metadata: Option<Value>,
410}
411
412impl Hints {
413    /// Mark whether the tool is read-only.
414    pub fn readonly(mut self, value: bool) -> Self {
415        self.readonly = Some(value);
416        self
417    }
418    /// Mark whether the tool can destroy state.
419    pub fn destructive(mut self, value: bool) -> Self {
420        self.destructive = Some(value);
421        self
422    }
423    /// Mark whether identical calls are safe to repeat.
424    pub fn idempotent(mut self, value: bool) -> Self {
425        self.idempotent = Some(value);
426        self
427    }
428    /// Mark whether the tool reaches external systems.
429    pub fn open_world(mut self, value: bool) -> Self {
430        self.open_world = Some(value);
431        self
432    }
433    /// Mark whether the tool is commonly long-running.
434    pub fn long_running(mut self, value: bool) -> Self {
435        self.long_running = Some(value);
436        self
437    }
438    /// Serialize calls that share this scheduling class.
439    pub fn concurrency_class(mut self, value: impl Into<String>) -> Self {
440        self.concurrency_class = Some(value.into());
441        self
442    }
443    /// Attach host-owned JSON annotations.
444    pub fn metadata(mut self, value: impl Into<Value>) -> Self {
445        self.metadata = Some(value.into());
446        self
447    }
448}
449
450/// Host seam: deliver a best-effort, correlated progress event.
451///
452/// Implemented by the consuming runtime (e.g. `everruns` adapts this onto its
453/// session event stream). Delivery failure must never fail the tool.
454#[async_trait]
455pub trait ProgressSink: Send + Sync {
456    /// Deliver one progress message for the named tool.
457    async fn emit(&self, tool_name: &str, message: &str);
458}
459
460/// Host seam: a call-scoped cancellation signal.
461///
462/// Implemented by the consuming runtime over its own cancellation primitive.
463pub trait CancellationSignal: Send + Sync {
464    /// Whether the call has ended or been cancelled.
465    fn is_cancelled(&self) -> bool;
466
467    /// Resolve when the call ends or is cancelled.
468    fn cancelled<'a>(&'a self) -> BoxFuture<'a, ()>;
469}
470
471struct NoopProgressSink;
472
473#[async_trait]
474impl ProgressSink for NoopProgressSink {
475    async fn emit(&self, _tool_name: &str, _message: &str) {}
476}
477
478struct NeverCancelled;
479
480impl CancellationSignal for NeverCancelled {
481    fn is_cancelled(&self) -> bool {
482        false
483    }
484
485    fn cancelled<'a>(&'a self) -> BoxFuture<'a, ()> {
486        Box::pin(std::future::pending())
487    }
488}
489
490/// Runtime context available to a code-defined capability tool.
491///
492/// This is a narrow projection: identity, locale, progress, and cancellation.
493/// Backend stores, credentials, tenant objects, registries, and host
494/// extensions intentionally remain host implementation details. Hosts build
495/// one per call via [`Context::new`] and the `with_*` seams; tests can do the
496/// same without any runtime.
497#[derive(Clone)]
498pub struct Context {
499    tool_name: String,
500    session_id: String,
501    workspace_id: String,
502    locale: Option<String>,
503    progress: Arc<dyn ProgressSink>,
504    cancellation: CallCancellation,
505}
506
507impl Context {
508    /// Create a context with no-op progress and a never-cancelled signal.
509    pub fn new(
510        tool_name: impl Into<String>,
511        session_id: impl Into<String>,
512        workspace_id: impl Into<String>,
513    ) -> Self {
514        Self {
515            tool_name: tool_name.into(),
516            session_id: session_id.into(),
517            workspace_id: workspace_id.into(),
518            locale: None,
519            progress: Arc::new(NoopProgressSink),
520            cancellation: CallCancellation {
521                inner: Arc::new(NeverCancelled),
522            },
523        }
524    }
525
526    /// Set the resolved BCP 47 locale.
527    pub fn with_locale(mut self, locale: Option<String>) -> Self {
528        self.locale = locale;
529        self
530    }
531
532    /// Attach the host's progress delivery seam.
533    pub fn with_progress_sink(mut self, sink: Arc<dyn ProgressSink>) -> Self {
534        self.progress = sink;
535        self
536    }
537
538    /// Attach the host's call-scoped cancellation signal.
539    pub fn with_cancellation_signal(mut self, signal: Arc<dyn CancellationSignal>) -> Self {
540        self.cancellation = CallCancellation { inner: signal };
541        self
542    }
543
544    /// The tool name this context was created for.
545    pub fn tool_name(&self) -> &str {
546        &self.tool_name
547    }
548
549    /// Opaque session identifier for correlation and application-side scoping.
550    pub fn session_id(&self) -> &str {
551        &self.session_id
552    }
553
554    /// Opaque identifier of the workspace attached to this session.
555    pub fn workspace_id(&self) -> &str {
556        &self.workspace_id
557    }
558
559    /// Resolved BCP 47 locale, when the host supplied one.
560    pub fn locale(&self) -> Option<&str> {
561        self.locale.as_deref()
562    }
563
564    /// Call-scoped cancellation signal for work that may outlive `execute`.
565    pub fn cancellation(&self) -> &CallCancellation {
566        &self.cancellation
567    }
568
569    /// Emit a best-effort, correlated `tool.progress` event.
570    ///
571    /// Delivery failure never fails the tool.
572    pub async fn progress(&self, message: impl AsRef<str>) {
573        self.progress.emit(&self.tool_name, message.as_ref()).await;
574    }
575}
576
577impl fmt::Debug for Context {
578    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
579        f.debug_struct("Context")
580            .field("tool_name", &self.tool_name)
581            .field("session_id", &self.session_id)
582            .field("workspace_id", &self.workspace_id)
583            .field("locale", &self.locale)
584            .field("cancelled", &self.cancellation.is_cancelled())
585            .finish()
586    }
587}
588
589/// Cancellation signal tied to one tool call's lifetime.
590///
591/// The host cancels it when the call returns, fails, or is dropped because
592/// the turn was cancelled. Ordinary awaited work needs no special handling:
593/// its future is dropped with the call. Clone this signal into child tasks,
594/// processes, or watchers that could otherwise outlive `execute`.
595#[derive(Clone)]
596pub struct CallCancellation {
597    inner: Arc<dyn CancellationSignal>,
598}
599
600impl CallCancellation {
601    /// Whether the tool call has ended or been cancelled.
602    pub fn is_cancelled(&self) -> bool {
603        self.inner.is_cancelled()
604    }
605
606    /// Resolve when the tool call ends or is cancelled.
607    pub async fn cancelled(&self) {
608        self.inner.cancelled().await;
609    }
610}
611
612impl fmt::Debug for CallCancellation {
613    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
614        f.debug_struct("CallCancellation")
615            .field("cancelled", &self.is_cancelled())
616            .finish()
617    }
618}
619
620/// Whether a capability error is safe to show to the model.
621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
622#[non_exhaustive]
623pub enum ErrorVisibility {
624    /// Expected domain failure; code, message, and details are model-visible.
625    User,
626    /// Unexpected implementation failure; details are hidden from the model.
627    Internal,
628}
629
630/// A structured capability error with stable code, message, and JSON details.
631///
632/// Use [`Error::user`] for expected failures the model can act on and
633/// [`Error::internal`] for diagnostic details that are unsafe to show to the
634/// model. Internal messages are logged by the host and replaced with a
635/// generic model-visible error, so they must not contain credentials or other
636/// secrets.
637#[derive(Debug)]
638pub struct Error {
639    visibility: ErrorVisibility,
640    code: String,
641    message: String,
642    details: Option<Value>,
643}
644
645impl Error {
646    /// Create an expected, model-visible domain error.
647    pub fn user(code: impl Into<String>, message: impl Into<String>) -> Self {
648        Self {
649            visibility: ErrorVisibility::User,
650            code: code.into(),
651            message: message.into(),
652            details: None,
653        }
654    }
655
656    /// Create an internal error whose details must not reach the model.
657    pub fn internal(code: impl Into<String>, message: impl Into<String>) -> Self {
658        Self {
659            visibility: ErrorVisibility::Internal,
660            code: code.into(),
661            message: message.into(),
662            details: None,
663        }
664    }
665
666    /// Attach structured JSON details.
667    pub fn details(mut self, details: impl Into<Value>) -> Self {
668        self.details = Some(details.into());
669        self
670    }
671
672    /// Stable machine-readable error code.
673    pub fn code(&self) -> &str {
674        &self.code
675    }
676
677    /// Human-readable error message.
678    pub fn message(&self) -> &str {
679        &self.message
680    }
681
682    /// Structured details, when present.
683    pub fn details_value(&self) -> Option<&Value> {
684        self.details.as_ref()
685    }
686
687    /// Whether the error is model-visible or internal.
688    pub fn visibility(&self) -> ErrorVisibility {
689        self.visibility
690    }
691
692    /// Decompose the error for host serialization.
693    pub fn into_parts(self) -> (ErrorVisibility, String, String, Option<Value>) {
694        (self.visibility, self.code, self.message, self.details)
695    }
696}
697
698impl fmt::Display for Error {
699    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
700        write!(f, "[{}] {}", self.code, self.message)
701    }
702}
703
704impl std::error::Error for Error {}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use serde_json::json;
710
711    #[derive(Deserialize, JsonSchema)]
712    struct LookupInput {
713        city: String,
714    }
715
716    #[derive(Debug, Serialize, JsonSchema)]
717    struct LookupOutput {
718        city: String,
719        temperatures: Vec<i32>,
720    }
721
722    struct Lookup;
723
724    #[async_trait]
725    impl Handler for Lookup {
726        type Input = LookupInput;
727        type Output = LookupOutput;
728        type Error = Error;
729
730        fn name(&self) -> &str {
731            "lookup_weather"
732        }
733
734        fn description(&self) -> &str {
735            "Look up a typed weather forecast."
736        }
737
738        fn hints(&self) -> Hints {
739            Hints::default().readonly(true).idempotent(true)
740        }
741
742        async fn execute(
743            &self,
744            input: Self::Input,
745            context: Context,
746        ) -> Result<Self::Output, Self::Error> {
747            context.progress("forecast ready").await;
748            Ok(LookupOutput {
749                city: input.city,
750                temperatures: vec![18, 21],
751            })
752        }
753    }
754
755    fn lookup_capability() -> Definition {
756        Definition::new("weather", "Weather", "Typed weather tools.")
757            .metadata(json!({ "owner": "example" }))
758            .tool(Lookup)
759    }
760
761    #[test]
762    fn exposes_input_output_schemas_and_hints() {
763        let capability = lookup_capability();
764        let spec = capability.tools()[0].spec();
765
766        assert_eq!(spec.name(), "lookup_weather");
767        assert_eq!(spec.input_schema()["type"], "object");
768        assert_eq!(spec.input_schema()["required"], json!(["city"]));
769        assert_eq!(spec.input_schema()["properties"]["city"]["type"], "string");
770        assert_eq!(spec.output_schema()["type"], "object");
771        assert_eq!(spec.output_schema()["properties"]["city"]["type"], "string");
772        assert_eq!(
773            spec.output_schema()["properties"]["temperatures"]["type"],
774            "array"
775        );
776        assert_eq!(
777            spec.output_schema()["properties"]["temperatures"]["items"]["type"],
778            "integer"
779        );
780        assert_eq!(spec.hints().readonly, Some(true));
781        assert_eq!(spec.hints().idempotent, Some(true));
782        assert_eq!(
783            capability.metadata_value(),
784            Some(&json!({ "owner": "example" }))
785        );
786        capability.validate().unwrap();
787    }
788
789    #[test]
790    fn validate_rejects_structural_problems() {
791        let err = Definition::new("weather", "Weather", "d")
792            .validate()
793            .unwrap_err();
794        assert!(err.reason().contains("at least one tool"));
795
796        let err = Definition::new("2fast", "N", "d")
797            .tool(Lookup)
798            .validate()
799            .unwrap_err();
800        assert!(err.reason().contains("start with a letter"));
801
802        let err = Definition::new("weather", " ", "d")
803            .tool(Lookup)
804            .validate()
805            .unwrap_err();
806        assert!(err.reason().contains("name must not be blank"));
807        for (definition, expected) in [
808            (
809                Definition::new("weather", "Weather", " ").tool(Lookup),
810                "description must not be blank",
811            ),
812            (
813                lookup_capability().instructions(" "),
814                "instructions must not be blank",
815            ),
816        ] {
817            assert!(
818                definition
819                    .validate()
820                    .unwrap_err()
821                    .reason()
822                    .contains(expected)
823            );
824        }
825        let mut malformed = lookup_capability();
826        malformed.tools[0].spec.description = " ".into();
827        assert!(
828            malformed
829                .validate()
830                .unwrap_err()
831                .reason()
832                .contains("tool \"lookup_weather\" description must not be blank")
833        );
834    }
835
836    // These local futures must finish in one poll; unexpected I/O fails promptly.
837    fn ready<F: Future>(future: F) -> F::Output {
838        let mut future = std::pin::pin!(future);
839        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
840        match future.as_mut().poll(&mut context) {
841            std::task::Poll::Ready(value) => value,
842            std::task::Poll::Pending => panic!("test future unexpectedly pending"),
843        }
844    }
845
846    #[derive(Default)]
847    struct RecordingProgress(std::sync::Mutex<Vec<(String, String)>>);
848    #[async_trait]
849    impl ProgressSink for RecordingProgress {
850        async fn emit(&self, tool: &str, message: &str) {
851            self.0.lock().unwrap().push((tool.into(), message.into()));
852        }
853    }
854
855    #[test]
856    fn invoke_serializes_typed_output() {
857        let tool = lookup_capability().tools()[0].clone();
858        let progress = Arc::new(RecordingProgress::default());
859        let context = Context::new("lookup_weather", "session", "workspace")
860            .with_progress_sink(progress.clone());
861        let value = ready(tool.invoke(json!({ "city": "Kyiv" }), context)).unwrap();
862        assert_eq!(value, json!({ "city": "Kyiv", "temperatures": [18, 21] }));
863        assert_eq!(
864            *progress.0.lock().unwrap(),
865            [("lookup_weather".into(), "forecast ready".into())]
866        );
867    }
868
869    #[test]
870    fn invoke_rejects_invalid_arguments_as_user_error() {
871        let tool = lookup_capability().tools()[0].clone();
872        let context = Context::new("lookup_weather", "session", "workspace");
873        let error = ready(tool.invoke(json!({ "city": 42 }), context)).unwrap_err();
874        assert_eq!(error.code(), "invalid_arguments");
875        assert_eq!(error.visibility(), ErrorVisibility::User);
876    }
877
878    #[test]
879    fn context_defaults_are_inert() {
880        let context = Context::new("t", "s", "w");
881        assert!(!context.cancellation().is_cancelled());
882        ready(context.progress("no-op"));
883        let mut cancelled = std::pin::pin!(context.cancellation().cancelled());
884        let mut task = std::task::Context::from_waker(std::task::Waker::noop());
885        assert!(cancelled.as_mut().poll(&mut task).is_pending());
886    }
887}