Skip to main content

everruns_contracts/
tool_types.rs

1// Tool definitions and policies for agent execution
2//
3// Design Decision: Tools are identified by name (string) for extensibility.
4// The BuiltinToolKind enum has been removed to allow adding new tools
5// without code changes. Tool execution happens via the ToolRegistry
6// which looks up tools by name.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11#[cfg(feature = "openapi")]
12use utoipa::ToSchema;
13
14pub const HUMAN_INTENT_ARGUMENT: &str = "human_intent";
15
16/// An image returned by a tool execution.
17///
18/// This allows tools (built-in or MCP) to return images that are sent
19/// to the LLM as native image content blocks, not stringified JSON.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ToolResultImage {
22    /// Base64-encoded image data
23    pub base64: String,
24    /// MIME type (e.g., "image/png", "image/jpeg")
25    pub media_type: String,
26}
27
28const HUMAN_INTENT_DESCRIPTION: &str = "Short user-facing narration of what this tool call will do, written as an action phrase like \"Listing all harnesses\". Do not include hidden reasoning, private chain of thought, secrets, or credential values.";
29
30/// Tool policy determines how tool calls are handled
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
32#[cfg_attr(feature = "openapi", derive(ToSchema))]
33#[serde(rename_all = "snake_case")]
34pub enum ToolPolicy {
35    /// Execute immediately without user approval
36    #[default]
37    Auto,
38    /// Require user approval before execution (HITL)
39    RequiresApproval,
40    /// Client-side tool: pause workflow, send to client for execution
41    ClientSide,
42}
43
44/// Controls whether a tool's full schema can be deferred (tool_search).
45///
46/// When tool_search is active and a model supports it, tools marked as
47/// `Automatic` or `Always` will have `defer_loading: true` set, meaning
48/// only the name+description are sent upfront and full parameter schemas
49/// are loaded on-demand by the model.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
51#[cfg_attr(feature = "openapi", derive(ToSchema))]
52#[serde(rename_all = "snake_case")]
53pub enum DeferrablePolicy {
54    /// Never defer — always send full schema (e.g., high-frequency tools like write_todos)
55    Never,
56    /// Let the driver decide based on tool count threshold (default)
57    #[default]
58    Automatic,
59    /// Always defer when tool_search is active, regardless of threshold
60    Always,
61}
62
63impl DeferrablePolicy {
64    /// Returns true when the value is the default (`Automatic`).
65    pub fn is_default(&self) -> bool {
66        matches!(self, DeferrablePolicy::Automatic)
67    }
68}
69
70/// Tool definition in agent configuration
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[cfg_attr(feature = "openapi", derive(ToSchema))]
73#[serde(tag = "type", rename_all = "snake_case")]
74pub enum ToolDefinition {
75    /// Built-in tool - executed by the worker via ToolRegistry
76    Builtin(BuiltinTool),
77    /// Client-side tool - executed by the client, not the server
78    ClientSide(ClientSideTool),
79}
80
81/// Built-in tool configuration
82///
83/// Note: The `kind` field has been removed. Tools are now identified
84/// solely by their `name` field, and execution happens via the ToolRegistry
85/// which looks up tools by name.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87#[cfg_attr(feature = "openapi", derive(ToSchema))]
88pub struct BuiltinTool {
89    /// Tool name (used by LLM and for registry lookup)
90    pub name: String,
91    /// Human-readable display name for UI rendering (e.g., "Get Current Time" for `get_current_time`)
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub display_name: Option<String>,
94    /// Tool description for LLM
95    pub description: String,
96    /// JSON schema for tool parameters
97    pub parameters: serde_json::Value,
98    /// Tool policy (auto or requires_approval)
99    #[serde(default)]
100    pub policy: ToolPolicy,
101    /// Category for tool_search namespace grouping (from parent capability)
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub category: Option<String>,
104    /// Whether this tool's schema can be deferred via tool_search
105    #[serde(default, skip_serializing_if = "DeferrablePolicy::is_default")]
106    pub deferrable: DeferrablePolicy,
107    /// Semantic hints describing the tool's behavioral properties
108    #[serde(default, skip_serializing_if = "ToolHints::is_empty")]
109    pub hints: ToolHints,
110    /// Original full parameter schema saved by `DeferSchemaHook` before stripping.
111    /// Serialized only when present so durable reason-to-act scheduling can
112    /// preserve deferred schemas for `tool_search` in the act phase.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub full_parameters: Option<serde_json::Value>,
115}
116
117/// Client-side tool - executed by the client, not the server
118/// The server pauses execution and waits for the client to submit results.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[cfg_attr(feature = "openapi", derive(ToSchema))]
121#[non_exhaustive]
122pub struct ClientSideTool {
123    /// Tool name (used by LLM and for correlation)
124    pub name: String,
125    /// Human-readable display name for UI rendering
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub display_name: Option<String>,
128    /// Tool description for LLM
129    pub description: String,
130    /// JSON schema for tool parameters
131    pub parameters: serde_json::Value,
132    /// Category for tool_search namespace grouping (from parent capability)
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub category: Option<String>,
135    /// Whether this tool's schema can be deferred via tool_search
136    #[serde(default, skip_serializing_if = "DeferrablePolicy::is_default")]
137    pub deferrable: DeferrablePolicy,
138    /// Semantic hints describing the tool's behavioral properties
139    #[serde(default, skip_serializing_if = "ToolHints::is_empty")]
140    pub hints: ToolHints,
141    /// Original full parameter schema saved by `DeferSchemaHook` before stripping.
142    /// Serialized only when present so durable reason-to-act scheduling can
143    /// preserve deferred schemas for `tool_search` in the act phase.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub full_parameters: Option<serde_json::Value>,
146}
147
148impl ClientSideTool {
149    /// A tool the caller executes: a name, a description, and a JSON schema.
150    ///
151    /// Every other field keeps its default, so declaring a tool needs the
152    /// three things that describe it and nothing about how the runtime would
153    /// have scheduled one it owned.
154    pub fn new(
155        name: impl Into<String>,
156        description: impl Into<String>,
157        parameters: serde_json::Value,
158    ) -> Self {
159        ClientSideTool {
160            name: name.into(),
161            display_name: None,
162            description: description.into(),
163            parameters,
164            category: None,
165            deferrable: DeferrablePolicy::default(),
166            hints: ToolHints::default(),
167            full_parameters: None,
168        }
169    }
170
171    /// Set the human-readable display name used when rendering this tool.
172    #[must_use]
173    pub fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
174        self.display_name = Some(display_name.into());
175        self
176    }
177
178    /// Group this tool under a `tool_search` category.
179    #[must_use]
180    pub fn with_category(mut self, category: impl Into<String>) -> Self {
181        self.category = Some(category.into());
182        self
183    }
184
185    /// Set whether this tool's schema may be deferred via `tool_search`.
186    #[must_use]
187    pub fn with_deferrable(mut self, deferrable: DeferrablePolicy) -> Self {
188        self.deferrable = deferrable;
189        self
190    }
191
192    /// Set the semantic hints describing this tool's behavior.
193    #[must_use]
194    pub fn with_hints(mut self, hints: ToolHints) -> Self {
195        self.hints = hints;
196        self
197    }
198}
199
200impl ToolDefinition {
201    /// A client-side function tool: a name, a description, and a JSON schema.
202    ///
203    /// The shortest path from "the model may call this" to a
204    /// [`ToolDefinition`], for callers that run the tool themselves and so
205    /// need none of [`ClientSideTool`]'s registry, deferral or hint fields.
206    /// Those keep their defaults and can be set afterwards.
207    ///
208    /// ```
209    /// use everruns_contracts::ToolDefinition;
210    /// use serde_json::json;
211    ///
212    /// let tool = ToolDefinition::function(
213    ///     "search",
214    ///     "look things up",
215    ///     json!({"type": "object", "properties": {"q": {"type": "string"}}}),
216    /// );
217    /// assert_eq!(tool.name(), "search");
218    /// ```
219    pub fn function(
220        name: impl Into<String>,
221        description: impl Into<String>,
222        parameters: serde_json::Value,
223    ) -> Self {
224        ToolDefinition::ClientSide(ClientSideTool::new(name, description, parameters))
225    }
226
227    /// Get the tool name regardless of variant
228    pub fn name(&self) -> &str {
229        match self {
230            ToolDefinition::Builtin(b) => &b.name,
231            ToolDefinition::ClientSide(c) => &c.name,
232        }
233    }
234
235    /// Get the tool display name regardless of variant
236    pub fn display_name(&self) -> Option<&str> {
237        match self {
238            ToolDefinition::Builtin(b) => b.display_name.as_deref(),
239            ToolDefinition::ClientSide(c) => c.display_name.as_deref(),
240        }
241    }
242
243    /// Get the tool description regardless of variant
244    pub fn description(&self) -> &str {
245        match self {
246            ToolDefinition::Builtin(b) => &b.description,
247            ToolDefinition::ClientSide(c) => &c.description,
248        }
249    }
250
251    /// Get the tool parameters schema regardless of variant
252    pub fn parameters(&self) -> &serde_json::Value {
253        match self {
254            ToolDefinition::Builtin(b) => &b.parameters,
255            ToolDefinition::ClientSide(c) => &c.parameters,
256        }
257    }
258
259    /// Get the full (pre-deferral) parameter schema, falling back to `parameters()`.
260    ///
261    /// When `DeferSchemaHook` strips a tool's schema it saves the original in
262    /// `full_parameters`. Callers that need the real schema (e.g. `tool_search`)
263    /// should use this method so deferred tools still return useful results.
264    pub fn full_parameters(&self) -> &serde_json::Value {
265        match self {
266            ToolDefinition::Builtin(b) => b.full_parameters.as_ref().unwrap_or(&b.parameters),
267            ToolDefinition::ClientSide(c) => c.full_parameters.as_ref().unwrap_or(&c.parameters),
268        }
269    }
270
271    /// Get the tool policy regardless of variant
272    pub fn policy(&self) -> &ToolPolicy {
273        match self {
274            ToolDefinition::Builtin(b) => &b.policy,
275            ToolDefinition::ClientSide(_) => &ToolPolicy::ClientSide,
276        }
277    }
278
279    /// Get the tool category for namespace grouping
280    pub fn category(&self) -> Option<&str> {
281        match self {
282            ToolDefinition::Builtin(b) => b.category.as_deref(),
283            ToolDefinition::ClientSide(c) => c.category.as_deref(),
284        }
285    }
286
287    /// Get the deferrable policy for tool_search
288    pub fn deferrable(&self) -> &DeferrablePolicy {
289        match self {
290            ToolDefinition::Builtin(b) => &b.deferrable,
291            ToolDefinition::ClientSide(c) => &c.deferrable,
292        }
293    }
294
295    /// Get the tool hints
296    pub fn hints(&self) -> &ToolHints {
297        match self {
298            ToolDefinition::Builtin(b) => &b.hints,
299            ToolDefinition::ClientSide(c) => &c.hints,
300        }
301    }
302
303    /// Scheduling conflict key for this tool, if any (see
304    /// `ToolHints::concurrency_class`). `None` means the tool has no mutation
305    /// conflicts and may always run concurrently with others.
306    pub fn concurrency_class(&self) -> Option<&str> {
307        self.hints().concurrency_class.as_deref()
308    }
309
310    /// Whether this tool performs CPU-bound/non-yielding in-process work and
311    /// should be offloaded to its own task by the act scheduler.
312    pub fn is_cpu_bound(&self) -> bool {
313        self.hints().cpu_bound.unwrap_or(false)
314    }
315
316    /// Effective side-effect class for this tool (defaults to `AtMostOnce`).
317    pub fn side_effect_class(&self) -> SideEffectClass {
318        self.hints().effective_side_effect_class()
319    }
320
321    /// Get reporting attribution for the capability that contributed this tool.
322    pub fn capability_attribution(&self) -> Option<(&str, Option<&str>)> {
323        self.hints()
324            .capability_id
325            .as_deref()
326            .map(|id| (id, self.hints().capability_name.as_deref()))
327    }
328
329    /// Set the category on this tool definition (builder pattern)
330    pub fn with_category(mut self, category: impl Into<String>) -> Self {
331        match &mut self {
332            ToolDefinition::Builtin(b) => b.category = Some(category.into()),
333            ToolDefinition::ClientSide(c) => c.category = Some(category.into()),
334        }
335        self
336    }
337
338    /// Set the hints on this tool definition (builder pattern)
339    pub fn with_hints(mut self, hints: ToolHints) -> Self {
340        match &mut self {
341            ToolDefinition::Builtin(b) => b.hints = hints,
342            ToolDefinition::ClientSide(c) => c.hints = hints,
343        }
344        self
345    }
346
347    /// Set reporting attribution on this tool definition (builder pattern).
348    pub fn with_capability_attribution(
349        mut self,
350        capability_id: impl Into<String>,
351        capability_name: Option<impl Into<String>>,
352    ) -> Self {
353        let capability_id = capability_id.into();
354        let capability_name = capability_name.map(Into::into);
355        match &mut self {
356            ToolDefinition::Builtin(b) => {
357                b.hints.capability_id = Some(capability_id);
358                b.hints.capability_name = capability_name;
359            }
360            ToolDefinition::ClientSide(c) => {
361                c.hints.capability_id = Some(capability_id);
362                c.hints.capability_name = capability_name;
363            }
364        }
365        self
366    }
367
368    /// Add the cross-cutting `human_intent` argument to the tool's JSON schema.
369    ///
370    /// This field is model-authored narration for UI rendering. Tool execution
371    /// strips it before invoking the underlying tool implementation.
372    pub fn with_human_intent_argument(mut self) -> Self {
373        match &mut self {
374            ToolDefinition::Builtin(b) => add_human_intent_to_schema(&mut b.parameters),
375            ToolDefinition::ClientSide(c) => add_human_intent_to_schema(&mut c.parameters),
376        }
377        self
378    }
379}
380
381pub fn add_human_intent_to_tool_definitions(tools: &[ToolDefinition]) -> Vec<ToolDefinition> {
382    tools
383        .iter()
384        .cloned()
385        .map(ToolDefinition::with_human_intent_argument)
386        .collect()
387}
388
389pub fn human_intent(arguments: &Value) -> Option<&str> {
390    arguments
391        .get(HUMAN_INTENT_ARGUMENT)
392        .and_then(Value::as_str)
393        .map(str::trim)
394        .filter(|value| !value.is_empty())
395}
396
397pub fn strip_human_intent_argument(arguments: &Value) -> Value {
398    let mut stripped = arguments.clone();
399    if let Value::Object(ref mut object) = stripped {
400        object.remove(HUMAN_INTENT_ARGUMENT);
401    }
402    stripped
403}
404
405fn add_human_intent_to_schema(schema: &mut Value) {
406    let Value::Object(schema_obj) = schema else {
407        return;
408    };
409
410    schema_obj
411        .entry("type")
412        .or_insert_with(|| Value::String("object".to_string()));
413
414    let properties = schema_obj
415        .entry("properties")
416        .or_insert_with(|| Value::Object(serde_json::Map::new()));
417    if let Value::Object(properties_obj) = properties {
418        properties_obj.insert(
419            HUMAN_INTENT_ARGUMENT.to_string(),
420            serde_json::json!({
421                "type": "string",
422                "description": HUMAN_INTENT_DESCRIPTION,
423                "maxLength": 120,
424            }),
425        );
426    }
427
428    // `human_intent` is intentionally optional: models should provide it when
429    // useful, but old calls, provider quirks, and client-side calls remain valid.
430}
431
432/// How many times a tool call may safely be executed given the same inputs.
433///
434/// Used by the durable Act activity (EVE-530) to decide what to do when a
435/// prior execution attempt left a `running` claim in `durable_tool_results`:
436///
437/// * `Pure` / `Idempotent` — the running claim is stale; re-execute freely.
438/// * `AtMostOnce` — never re-execute from a stale running claim; settle it
439///   as `interrupted` and surface an uncertain result to the model instead.
440///
441/// When unset (`None`), the conservative default is `AtMostOnce`.
442#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
443#[cfg_attr(feature = "openapi", derive(ToSchema))]
444pub enum SideEffectClass {
445    /// No external side effects; always safe to re-execute (e.g. read-only queries).
446    Pure,
447    /// Idempotent side effects; safe to re-execute with the same arguments
448    /// (e.g. create-or-update, PUT-style writes).
449    Idempotent,
450    /// Exactly-once semantics required; must not be re-executed from a stale
451    /// running claim (e.g. charge a card, send an email, open a PR).
452    #[default]
453    AtMostOnce,
454}
455
456/// Semantic hints describing a tool's behavioral properties.
457///
458/// Follows the MCP tool annotations convention (readOnlyHint, destructiveHint,
459/// idempotentHint, openWorldHint) plus everruns-specific hints. All fields are
460/// optional booleans — `None` means "unknown/unspecified". Consumers should
461/// treat `None` as the conservative default (e.g., assume not readonly, assume
462/// not idempotent).
463///
464/// These hints are informational — they do not enforce policy. Use `ToolPolicy`
465/// for execution gating (auto vs requires_approval).
466// `Eq` is deliberately absent: `metadata` is an opaque `serde_json::Value`, which
467// is only `PartialEq`. Hints are compared for equality (`is_empty`), never hashed
468// or used as a map key, so `PartialEq` is sufficient.
469#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
470#[cfg_attr(feature = "openapi", derive(ToSchema))]
471pub struct ToolHints {
472    /// Tool does not modify any state (read-only queries, lookups).
473    /// When true: safe to call speculatively, result can be cached.
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub readonly: Option<bool>,
476
477    /// Tool may irreversibly destroy or delete data.
478    /// Subset of non-readonly — a tool can be non-readonly (writes) without
479    /// being destructive (e.g., create/update operations).
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub destructive: Option<bool>,
482
483    /// Calling the tool repeatedly with the same arguments produces the same
484    /// effect. Safe to retry on transient failures.
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub idempotent: Option<bool>,
487
488    /// Tool interacts with external entities beyond the local system
489    /// (network calls, third-party APIs, cloud services).
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub open_world: Option<bool>,
492
493    /// Tool requires API keys, credentials, or other secrets to function.
494    /// Useful for UI to show connection prompts and for LLMs to anticipate
495    /// authentication failures.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub requires_secrets: Option<bool>,
498
499    /// Tool may take significant time to complete (> ~5s typical).
500    /// Useful for clients to show progress indicators and set timeouts.
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    pub long_running: Option<bool>,
503
504    /// Tool supports detached background execution via `spawn_background`.
505    /// When true, the tool may be executed asynchronously outside the current
506    /// foreground tool call and report status back later.
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub supports_background: Option<bool>,
509
510    /// Scheduling conflict key. Tool calls within the same act batch that share
511    /// a non-empty `concurrency_class` are executed sequentially in arrival
512    /// order; calls in different classes (or with no class) run concurrently.
513    ///
514    /// Set this on tools that mutate shared session state so that, e.g., two
515    /// file writes or two SQL mutations in one batch do not race. Read-only
516    /// tools should leave this `None` so they always parallelize. See
517    /// `everruns-engine`'s tool scheduler for how the act phase consumes it.
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub concurrency_class: Option<String>,
520
521    /// Tool performs significant CPU-bound or otherwise non-yielding work in
522    /// process (e.g. an in-process interpreter). When true, the act scheduler
523    /// runs the call on its own task (`tokio::spawn`) so a long CPU burst does
524    /// not starve the cooperative polling of I/O-bound tools in the same batch.
525    ///
526    /// Distinct from `long_running`, which describes wall-clock time for
527    /// I/O-bound work (those tools yield at await points and need no offload).
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub cpu_bound: Option<bool>,
530
531    /// Tool output should be persisted to session VFS before truncation.
532    /// When set, the `tool_output_persistence` capability (EVE-222, EVE-245) writes
533    /// stdout to `/outputs/{tool_call_id}.stdout` and stderr to
534    /// `/outputs/{tool_call_id}.stderr`, injecting `full_output`, `total_lines`,
535    /// and `output_files` into the result.
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub persist_output: Option<bool>,
538
539    /// Capability that contributed this tool definition.
540    ///
541    /// Reporting uses this attribution only as metadata. It must never contain
542    /// tool arguments, results, prompts, or any other sensitive payload.
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    pub capability_id: Option<String>,
545
546    /// Human-readable capability name snapshot for reporting.
547    #[serde(default, skip_serializing_if = "Option::is_none")]
548    pub capability_name: Option<String>,
549
550    /// Entity noun for operation-based narration (e.g. "agent", "harness").
551    /// When set, the narration system reads the `operation` argument and
552    /// produces verb-based narration like "Created agent: Neon Cartographer"
553    /// instead of the generic "Ran Manage Agents".
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub narration_noun: Option<String>,
556
557    /// Replay-safety class used by the durable Act activity (EVE-530).
558    ///
559    /// Controls what happens when a worker reclaims a stale `running` claim:
560    /// `Pure`/`Idempotent` tools are re-executed; `AtMostOnce` tools are
561    /// settled as `interrupted` to prevent double side-effects.
562    ///
563    /// `None` is treated conservatively as `AtMostOnce`.
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub side_effect_class: Option<SideEffectClass>,
566
567    /// Host-owned annotations that core does not interpret.
568    ///
569    /// The typed hints above are the vocabulary core itself reasons about. This
570    /// is the escape hatch for everything a *host* wants to carry alongside a
571    /// tool — risk tiers for an approval UI, presentation hints, an embedder's
572    /// routing keys — without adding a field to core for each one. Core reads
573    /// nothing here and no driver sends it to a provider; it travels with the
574    /// definition so a consumer sees it at the point of decision (e.g. a
575    /// `PreToolUseHook` gating on what the tool declared).
576    ///
577    /// The schema belongs to whoever writes it. Never put credentials or other
578    /// sensitive payload here: like the rest of the definition, it is persisted
579    /// and surfaced to clients.
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub metadata: Option<serde_json::Value>,
582}
583
584impl ToolHints {
585    /// Returns true when all fields are None (default/empty state).
586    pub fn is_empty(&self) -> bool {
587        *self == Self::default()
588    }
589
590    /// Builder: attach host-owned metadata (see [`ToolHints::metadata`]).
591    pub fn with_metadata(mut self, value: serde_json::Value) -> Self {
592        self.metadata = Some(value);
593        self
594    }
595
596    /// Builder: set readonly hint.
597    pub fn with_readonly(mut self, value: bool) -> Self {
598        self.readonly = Some(value);
599        self
600    }
601
602    /// Builder: set destructive hint.
603    pub fn with_destructive(mut self, value: bool) -> Self {
604        self.destructive = Some(value);
605        self
606    }
607
608    /// Builder: set idempotent hint.
609    pub fn with_idempotent(mut self, value: bool) -> Self {
610        self.idempotent = Some(value);
611        self
612    }
613
614    /// Builder: set open_world hint.
615    pub fn with_open_world(mut self, value: bool) -> Self {
616        self.open_world = Some(value);
617        self
618    }
619
620    /// Builder: set reporting attribution.
621    pub fn with_capability_attribution(
622        mut self,
623        capability_id: impl Into<String>,
624        capability_name: Option<impl Into<String>>,
625    ) -> Self {
626        self.capability_id = Some(capability_id.into());
627        self.capability_name = capability_name.map(Into::into);
628        self
629    }
630
631    /// Builder: set requires_secrets hint.
632    pub fn with_requires_secrets(mut self, value: bool) -> Self {
633        self.requires_secrets = Some(value);
634        self
635    }
636
637    /// Builder: set long_running hint.
638    pub fn with_long_running(mut self, value: bool) -> Self {
639        self.long_running = Some(value);
640        self
641    }
642
643    /// Builder: set supports_background hint.
644    pub fn with_supports_background(mut self, value: bool) -> Self {
645        self.supports_background = Some(value);
646        self
647    }
648
649    /// Builder: set the scheduling conflict key (see `concurrency_class`).
650    pub fn with_concurrency_class(mut self, class: impl Into<String>) -> Self {
651        self.concurrency_class = Some(class.into());
652        self
653    }
654
655    /// Builder: set the cpu_bound hint (see `cpu_bound`).
656    pub fn with_cpu_bound(mut self, value: bool) -> Self {
657        self.cpu_bound = Some(value);
658        self
659    }
660
661    /// Builder: set persist_output hint.
662    pub fn with_persist_output(mut self, value: bool) -> Self {
663        self.persist_output = Some(value);
664        self
665    }
666
667    /// Builder: set narration noun for operation-based narration.
668    pub fn with_narration_noun(mut self, noun: impl Into<String>) -> Self {
669        self.narration_noun = Some(noun.into());
670        self
671    }
672
673    /// Builder: set the replay-safety class (EVE-530).
674    pub fn with_side_effect_class(mut self, class: SideEffectClass) -> Self {
675        self.side_effect_class = Some(class);
676        self
677    }
678
679    /// Returns the effective side-effect class, defaulting to `AtMostOnce`
680    /// when unset (conservative default).
681    pub fn effective_side_effect_class(&self) -> SideEffectClass {
682        self.side_effect_class
683            .clone()
684            .unwrap_or(SideEffectClass::AtMostOnce)
685    }
686}
687
688/// Tool call from LLM response
689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
690#[cfg_attr(feature = "openapi", derive(ToSchema))]
691pub struct ToolCall {
692    /// Unique ID for this tool call
693    pub id: String,
694    /// Tool name to execute
695    pub name: String,
696    /// Arguments as JSON
697    #[cfg_attr(feature = "openapi", schema(value_type = Object))]
698    pub arguments: serde_json::Value,
699}
700
701impl ToolCall {
702    /// Arguments safe to pass to the actual tool implementation.
703    pub fn execution_arguments(&self) -> serde_json::Value {
704        strip_human_intent_argument(&self.arguments)
705    }
706
707    /// Convert tool call to OpenAI-compatible format
708    ///
709    /// Returns format: `{id, type: "function", function: {name, arguments}}`
710    /// where arguments is stringified JSON.
711    pub fn to_openai_format(&self) -> serde_json::Value {
712        serde_json::json!({
713            "id": self.id,
714            "type": "function",
715            "function": {
716                "name": self.name,
717                "arguments": serde_json::to_string(&self.arguments).unwrap_or_else(|_| "{}".to_string())
718            }
719        })
720    }
721}
722
723/// The subject whose OAuth grant is required for a tool call.
724#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
725#[serde(rename_all = "snake_case")]
726pub enum ConnectionRequiredSubject {
727    /// The agent's shared service identity needs an administrator-managed grant.
728    Agent,
729    /// The invoking user needs their own grant.
730    User,
731}
732
733/// Structured details for a connection-required tool result.
734///
735/// Provider-only values retain their historical string wire shape. Values with
736/// a subject and setup URL use an object so callers can distinguish who must act.
737#[derive(Debug, Clone, PartialEq, Eq)]
738pub struct ConnectionRequired {
739    /// Connection provider id.
740    pub provider: String,
741    /// Whose grant is missing. Absent on provider-only legacy values.
742    pub subject: Option<ConnectionRequiredSubject>,
743    /// Relative UI route where the missing grant can be configured.
744    pub setup_url: Option<String>,
745}
746
747impl ConnectionRequired {
748    /// Construct the provider-only shape used by existing tool integrations.
749    pub fn provider_only(provider: impl Into<String>) -> Self {
750        Self {
751            provider: provider.into(),
752            subject: None,
753            setup_url: None,
754        }
755    }
756
757    /// Construct a connection requirement with an explicit subject and setup route.
758    pub fn with_setup(
759        provider: impl Into<String>,
760        subject: ConnectionRequiredSubject,
761        setup_url: impl Into<String>,
762    ) -> Self {
763        Self {
764            provider: provider.into(),
765            subject: Some(subject),
766            setup_url: Some(setup_url.into()),
767        }
768    }
769}
770
771impl From<String> for ConnectionRequired {
772    fn from(provider: String) -> Self {
773        Self::provider_only(provider)
774    }
775}
776
777impl From<&str> for ConnectionRequired {
778    fn from(provider: &str) -> Self {
779        Self::provider_only(provider)
780    }
781}
782
783/// Preserve the historical string wire shape until structured details are present.
784impl Serialize for ConnectionRequired {
785    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
786    where
787        S: serde::Serializer,
788    {
789        if self.subject.is_none() && self.setup_url.is_none() {
790            return serializer.serialize_str(&self.provider);
791        }
792
793        #[derive(Serialize)]
794        struct Details<'a> {
795            provider: &'a str,
796            #[serde(skip_serializing_if = "Option::is_none")]
797            subject: Option<ConnectionRequiredSubject>,
798            #[serde(skip_serializing_if = "Option::is_none")]
799            setup_url: Option<&'a str>,
800        }
801
802        Details {
803            provider: &self.provider,
804            subject: self.subject,
805            setup_url: self.setup_url.as_deref(),
806        }
807        .serialize(serializer)
808    }
809}
810
811impl<'de> Deserialize<'de> for ConnectionRequired {
812    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
813    where
814        D: serde::Deserializer<'de>,
815    {
816        #[derive(Deserialize)]
817        #[serde(untagged)]
818        enum Wire {
819            Provider(String),
820            Details {
821                provider: String,
822                #[serde(default)]
823                subject: Option<ConnectionRequiredSubject>,
824                #[serde(default)]
825                setup_url: Option<String>,
826            },
827        }
828
829        Ok(match Wire::deserialize(deserializer)? {
830            Wire::Provider(provider) => Self::provider_only(provider),
831            Wire::Details {
832                provider,
833                subject,
834                setup_url,
835            } => Self {
836                provider,
837                subject,
838                setup_url,
839            },
840        })
841    }
842}
843
844/// Tool execution result
845#[derive(Debug, Clone, Serialize, Deserialize)]
846pub struct ToolResult {
847    /// Tool call ID this result corresponds to
848    pub tool_call_id: String,
849    /// Result data (success)
850    pub result: Option<serde_json::Value>,
851    /// Images returned by the tool (sent as native image content to LLM)
852    #[serde(default, skip_serializing_if = "Option::is_none")]
853    pub images: Option<Vec<ToolResultImage>>,
854    /// Error message (failure)
855    pub error: Option<String>,
856    /// When set, indicates the tool requires a connection before it can continue.
857    /// Provider-only strings remain accepted for compatibility; richer values
858    /// identify whose grant is missing and where to configure it.
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub connection_required: Option<ConnectionRequired>,
861    /// Pre-truncation cleaned output for persistence hooks.
862    /// Populated by exec tools (after ANSI strip + CR collapse, before truncation).
863    /// Consumed by PostToolExecHook (e.g. tool_output_persistence) then cleared.
864    /// Never serialized to messages or sent to LLM.
865    #[serde(skip)]
866    pub raw_output: Option<String>,
867}
868
869/// `result.code` marking a tool result that stopped on a URL mode elicitation.
870///
871/// The MCP executor is the only producer; the engine's `UrlElicitationHook` is
872/// the consumer. It lives here, beside [`ToolResult`], because the two crates
873/// must agree on the shape and neither depends on the other.
874pub const URL_ELICITATION_REQUIRED_CODE: &str = "url_elicitation_required";
875
876/// Name of the synthetic client-side tool call that carries a URL mode
877/// elicitation to a human: the engine emits it, the client renders a consent
878/// surface for it, and the API that collects the decision recognises it.
879pub const CONFIRM_URL_ELICITATION_TOOL: &str = "confirm_url_elicitation";
880
881pub use crate::form_elicitation_types::{
882    FORM_ELICITATION_CALL_ID_PREFIX, FORM_ELICITATION_REQUIRED_CODE, FormElicitationRequired,
883    MCP_ELICITATION_ARGUMENT,
884};
885pub use crate::tool_approval_types::{
886    APPROVE_TOOL_CALL_TOOL, TOOL_APPROVAL_CALL_ID_PREFIX, TOOL_APPROVAL_REQUIRED_CODE,
887    TOOL_ARGUMENTS_PREVIEW_BYTES, ToolApprovalRequired, preview_tool_arguments,
888};
889
890/// Name of the client-side tool an agent uses to ask a structured question.
891///
892/// Lives here rather than in `everruns-builtins` because the engine has to
893/// recognise the call to decide whether the turn may park on it, and the engine
894/// does not depend on the builtins crate. `everruns-builtins` re-exports it, so
895/// the public path capability authors use is unchanged (EVE-1057).
896pub const ASK_USER_TOOL_NAME: &str = "ask_user";
897
898/// Answer an `ask_user` question set with the options the model declared as
899/// defaults, for a client that structurally cannot be asked.
900///
901/// A scheduled run, a trigger, or an SDK caller that never declared it renders
902/// questions has nobody to ask, and parking the turn would burn the whole
903/// timeout waiting for a human who is not there (EVE-1057).
904///
905/// Takes and returns JSON rather than the typed contract because that contract
906/// lives in `everruns-builtins`, above this crate. `DefaultsResponder` is the
907/// typed twin, and `defaults_responder_matches_unattended_result` in that crate
908/// fails if the two ever disagree.
909///
910/// Mirrors `DefaultsResponder`: every declared default is selected, and a
911/// question that declared none falls back to its first option, which the
912/// prompt tells the model to order most-applicable-first.
913pub fn unattended_ask_user_result(arguments: &serde_json::Value) -> serde_json::Value {
914    let questions = arguments
915        .get("questions")
916        .and_then(|value| value.as_array());
917
918    // Questions an MCP server asked have no unattended answer either: a
919    // server's default is not a person's consent (TM-TOOL-047).
920    if arguments.get(MCP_ELICITATION_ARGUMENT).is_some() {
921        return serde_json::json!({
922            "status": "declined",
923            "answered_by": "unattended",
924            "answers": [],
925        });
926    }
927
928    // Free-form text and credentials have no unattended answer. Declining
929    // leaves proceeding without one as the model's explicit decision.
930    if questions.is_some_and(|questions| {
931        questions.iter().any(|question| {
932            matches!(
933                question.get("kind").and_then(|v| v.as_str()),
934                Some("text" | "secret")
935            )
936        })
937    }) {
938        return serde_json::json!({
939            "status": "declined",
940            "answered_by": "unattended",
941            "answers": [],
942        });
943    }
944
945    let answers: Vec<serde_json::Value> = questions
946        .map(|questions| {
947            questions
948                .iter()
949                .map(|question| {
950                    let options = question.get("options").and_then(|v| v.as_array());
951                    let label = |option: &serde_json::Value| {
952                        option
953                            .get("label")
954                            .and_then(|v| v.as_str())
955                            .map(str::to_string)
956                    };
957                    let mut selected: Vec<String> = options
958                        .map(|options| {
959                            options
960                                .iter()
961                                .filter(|option| {
962                                    option
963                                        .get("default")
964                                        .and_then(|v| v.as_bool())
965                                        .unwrap_or(false)
966                                })
967                                .filter_map(label)
968                                .collect()
969                        })
970                        .unwrap_or_default();
971                    if selected.is_empty() {
972                        selected.extend(options.and_then(|o| o.first()).and_then(label));
973                    }
974                    serde_json::json!({
975                        "id": question.get("id").and_then(|v| v.as_str()).unwrap_or_default(),
976                        "selected": selected,
977                        "other_text": serde_json::Value::Null,
978                    })
979                })
980                .collect()
981        })
982        .unwrap_or_default();
983
984    serde_json::json!({
985        "status": "answered",
986        // Not "timeout": telling the two apart is how the model learns whether
987        // waiting would ever have helped.
988        "answered_by": "unattended",
989        "answers": answers,
990    })
991}
992
993/// Structured payload of a tool result that stopped on a URL mode elicitation.
994///
995/// An MCP server answered `tools/call` by asking that a human visit a URL out
996/// of band (a secret to type, an authorization to grant, a payment to make).
997/// The call did not fail, and no credential is missing — it is waiting on a
998/// person. Everything here is safe to show: the URL was validated before any
999/// human saw it and is never fetched by the client.
1000#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1001pub struct UrlElicitationRequired {
1002    /// Always [`URL_ELICITATION_REQUIRED_CODE`]; discriminates the payload.
1003    pub code: String,
1004    /// Model-facing sentence explaining what is being waited on.
1005    pub error: String,
1006    /// The URL a human must open. Shown in full — never shortened, and never
1007    /// turned into a bare "click here" link.
1008    pub url: String,
1009    /// Host of `url`, so a consent surface can highlight the domain instead of
1010    /// re-parsing (clients SHOULD highlight it against subdomain spoofing).
1011    pub url_host: String,
1012    /// Whether the host carries a Punycode label. Legitimate, but worth a
1013    /// warning before a user trusts the domain.
1014    pub url_is_punycode: bool,
1015    /// Logical MCP server that asked.
1016    pub server: String,
1017    /// MCP tool the elicitation interrupted. Consent is recorded against this
1018    /// pair, so it must survive into the payload.
1019    pub tool: String,
1020    /// The tool as the model knows it (`mcp_<server>_<tool>`), so whatever
1021    /// resumes the work can name the call to make once a human has consented.
1022    pub retry_tool: String,
1023    /// The server's own explanation of why the interaction is needed.
1024    pub message: String,
1025    /// True when a human refused. Nothing more to ask; do not prompt again.
1026    pub declined: bool,
1027}
1028
1029impl UrlElicitationRequired {
1030    /// Recover the payload from a tool result, if that is what it carries.
1031    pub fn from_tool_result(result: &ToolResult) -> Option<Self> {
1032        let value = result.result.as_ref()?;
1033        if value.get("code")?.as_str()? != URL_ELICITATION_REQUIRED_CODE {
1034            return None;
1035        }
1036        serde_json::from_value(value.clone()).ok()
1037    }
1038}
1039
1040impl ToolResult {
1041    /// Construct a minimal error-only ToolResult (used for fingerprinting error paths).
1042    pub fn error(msg: &str) -> Self {
1043        Self {
1044            tool_call_id: String::new(),
1045            result: None,
1046            images: None,
1047            error: Some(msg.to_string()),
1048            connection_required: None,
1049            raw_output: None,
1050        }
1051    }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057    use serde_json::json;
1058
1059    fn definition(kind: &str) -> ToolDefinition {
1060        serde_json::from_value(json!({"type":kind,"name":"tool","description":"Description","parameters":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}})).unwrap()
1061    }
1062
1063    #[test]
1064    fn tool_variants_have_complete_literal_wire_defaults_and_effective_policies() {
1065        for kind in ["builtin", "client_side"] {
1066            let tool = definition(kind);
1067            let mut expected = json!({"type":kind,"name":"tool","description":"Description","parameters":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}});
1068            if kind == "builtin" {
1069                expected["policy"] = json!("auto");
1070            }
1071            assert_eq!(serde_json::to_value(&tool).unwrap(), expected);
1072            assert_eq!(tool.name(), "tool");
1073            assert_eq!(tool.description(), "Description");
1074            assert_eq!(tool.display_name(), None);
1075            assert_eq!(
1076                tool.parameters(),
1077                &json!({"type":"object","properties":{"input":{"type":"string"}},"required":["input"]})
1078            );
1079            assert_eq!(
1080                tool.policy(),
1081                if kind == "builtin" {
1082                    &ToolPolicy::Auto
1083                } else {
1084                    &ToolPolicy::ClientSide
1085                }
1086            );
1087            assert_eq!(tool.deferrable(), &DeferrablePolicy::Automatic);
1088            assert!(tool.hints().is_empty());
1089            assert_eq!(tool.concurrency_class(), None);
1090            assert!(!tool.is_cpu_bound());
1091            assert_eq!(tool.side_effect_class(), SideEffectClass::AtMostOnce);
1092        }
1093        let mixed:Vec<ToolDefinition>=serde_json::from_value(json!([
1094            {"type":"builtin","name":"server","description":"Server","parameters":{},"policy":"requires_approval"},
1095            {"type":"client_side","name":"client","description":"Client","parameters":{},"policy":"auto"}
1096        ])).unwrap();
1097        assert_eq!(
1098            (
1099                mixed[0].name(),
1100                mixed[0].policy(),
1101                mixed[1].name(),
1102                mixed[1].policy()
1103            ),
1104            (
1105                "server",
1106                &ToolPolicy::RequiresApproval,
1107                "client",
1108                &ToolPolicy::ClientSide
1109            )
1110        );
1111        assert!(matches!(&mixed[0], ToolDefinition::Builtin(_)));
1112        assert!(matches!(&mixed[1], ToolDefinition::ClientSide(_)));
1113        for (policy, wire) in [
1114            (ToolPolicy::Auto, "auto"),
1115            (ToolPolicy::RequiresApproval, "requires_approval"),
1116            (ToolPolicy::ClientSide, "client_side"),
1117        ] {
1118            assert_eq!(serde_json::to_value(&policy).unwrap(), json!(wire));
1119            assert_eq!(
1120                serde_json::from_value::<ToolPolicy>(json!(wire)).unwrap(),
1121                policy
1122            );
1123        }
1124    }
1125
1126    #[test]
1127    fn hints_builders_preserve_false_values_metadata_and_scheduling_fields() {
1128        for kind in ["builtin", "client_side"] {
1129            let reader = definition(kind).with_hints(ToolHints::default().with_readonly(true));
1130            assert_eq!(reader.concurrency_class(), None);
1131            assert!(!reader.is_cpu_bound());
1132            assert_eq!(reader.side_effect_class(), SideEffectClass::AtMostOnce);
1133        }
1134
1135        for value in [false, true] {
1136            let hints = ToolHints::default()
1137                .with_readonly(value)
1138                .with_destructive(value)
1139                .with_idempotent(value)
1140                .with_open_world(value)
1141                .with_requires_secrets(value)
1142                .with_long_running(value)
1143                .with_supports_background(value)
1144                .with_cpu_bound(value)
1145                .with_persist_output(value)
1146                .with_concurrency_class("session_workspace")
1147                .with_narration_noun("file")
1148                .with_side_effect_class(SideEffectClass::Idempotent)
1149                .with_capability_attribution("files", Some("Files"))
1150                .with_metadata(json!({"risk_tier":"high","nested":[1,false,null]}));
1151            let expected = json!({"readonly":value,"destructive":value,"idempotent":value,"open_world":value,"requires_secrets":value,"long_running":value,"supports_background":value,"cpu_bound":value,"persist_output":value,"concurrency_class":"session_workspace","narration_noun":"file","side_effect_class":"Idempotent","capability_id":"files","capability_name":"Files","metadata":{"risk_tier":"high","nested":[1,false,null]}});
1152            assert_eq!(serde_json::to_value(&hints).unwrap(), expected);
1153            assert_eq!(
1154                serde_json::from_value::<ToolHints>(expected).unwrap(),
1155                hints
1156            );
1157            for kind in ["builtin", "client_side"] {
1158                let tool = definition(kind)
1159                    .with_hints(hints.clone())
1160                    .with_category("workspace")
1161                    .with_capability_attribution("new-files", Some("New Files"));
1162                assert_eq!(tool.category(), Some("workspace"));
1163                assert_eq!(tool.concurrency_class(), Some("session_workspace"));
1164                assert_eq!(tool.is_cpu_bound(), value);
1165                assert_eq!(tool.side_effect_class(), SideEffectClass::Idempotent);
1166                assert_eq!(
1167                    tool.capability_attribution(),
1168                    Some(("new-files", Some("New Files")))
1169                );
1170                let mut expected_hints = serde_json::to_value(&hints).unwrap();
1171                expected_hints["capability_id"] = json!("new-files");
1172                expected_hints["capability_name"] = json!("New Files");
1173                assert_eq!(serde_json::to_value(tool.hints()).unwrap(), expected_hints);
1174            }
1175        }
1176        assert_eq!(
1177            serde_json::to_value(ToolHints::default()).unwrap(),
1178            json!({})
1179        );
1180        let metadata = ToolHints::default().with_metadata(json!({"any":"thing"}));
1181        assert!(!metadata.is_empty());
1182        assert_eq!(
1183            serde_json::to_value(metadata).unwrap(),
1184            json!({"metadata":{"any":"thing"}})
1185        );
1186        for class in [
1187            SideEffectClass::Pure,
1188            SideEffectClass::Idempotent,
1189            SideEffectClass::AtMostOnce,
1190        ] {
1191            assert_eq!(
1192                ToolHints::default()
1193                    .with_side_effect_class(class.clone())
1194                    .effective_side_effect_class(),
1195                class
1196            );
1197        }
1198    }
1199
1200    #[test]
1201    fn tool_display_and_deferred_schemas_survive_both_wire_variants() {
1202        for kind in ["builtin", "client_side"] {
1203            for (deferrable, wire) in [
1204                (DeferrablePolicy::Never, Some("never")),
1205                (DeferrablePolicy::Automatic, None),
1206                (DeferrablePolicy::Always, Some("always")),
1207            ] {
1208                let mut payload = json!({"type":kind,"name":"tool","display_name":"Display","description":"Description","parameters":{"type":"object"},"full_parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]},"category":"files"});
1209                if let Some(wire) = wire {
1210                    payload["deferrable"] = json!(wire);
1211                }
1212                let tool: ToolDefinition = serde_json::from_value(payload.clone()).unwrap();
1213                assert_eq!(tool.display_name(), Some("Display"));
1214                assert_eq!(tool.deferrable(), &deferrable);
1215                assert_eq!(tool.parameters(), &json!({"type":"object"}));
1216                assert_eq!(
1217                    tool.full_parameters(),
1218                    &json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]})
1219                );
1220                if kind == "builtin" {
1221                    payload["policy"] = json!("auto");
1222                }
1223                assert_eq!(serde_json::to_value(tool).unwrap(), payload);
1224            }
1225            let tool = definition(kind);
1226            assert_eq!(tool.full_parameters(), tool.parameters());
1227        }
1228    }
1229
1230    #[test]
1231    fn tool_call_wire_and_openai_arguments_preserve_the_complete_payload() {
1232        for (arguments, text) in [
1233            (json!({"city":"New York"}), r#"{"city":"New York"}"#),
1234            (json!({}), "{}"),
1235            (
1236                json!({"count":9007199254740993_u64,"text":"line\nquoted\""}),
1237                r#"{"count":9007199254740993,"text":"line\nquoted\""}"#,
1238            ),
1239        ] {
1240            let call = ToolCall {
1241                id: "call_123".into(),
1242                name: "get_weather".into(),
1243                arguments: arguments.clone(),
1244            };
1245            assert_eq!(
1246                serde_json::to_value(&call).unwrap(),
1247                json!({"id":"call_123","name":"get_weather","arguments":arguments})
1248            );
1249            let parsed: ToolCall = serde_json::from_value(
1250                json!({"id":"call_123","name":"get_weather","arguments":arguments}),
1251            )
1252            .unwrap();
1253            assert_eq!(parsed.arguments, arguments);
1254            assert_eq!(
1255                call.to_openai_format(),
1256                json!({"id":"call_123","type":"function","function":{"name":"get_weather","arguments":text}})
1257            );
1258        }
1259    }
1260
1261    #[test]
1262    fn tool_result_wire_excludes_raw_output_but_preserves_images_errors_and_data() {
1263        let expected = json!({"tool_call_id":"call_123","result":{"temperature":72},"images":[{"base64":"aGk=","media_type":"image/png"}],"error":"partial failure","connection_required":"provider"});
1264        let mut injected = expected.clone();
1265        injected["raw_output"] = json!("private-raw");
1266        let mut result: ToolResult = serde_json::from_value(injected).unwrap();
1267        assert_eq!(
1268            result.connection_required,
1269            Some(ConnectionRequired::provider_only("provider"))
1270        );
1271        assert!(result.raw_output.is_none());
1272        result.raw_output = Some("private-raw".into());
1273        assert_eq!(serde_json::to_value(result).unwrap(), expected);
1274        assert_eq!(
1275            serde_json::to_value(ToolResult::error("failed")).unwrap(),
1276            json!({"tool_call_id":"","result":null,"error":"failed"})
1277        );
1278        let success: ToolResult = serde_json::from_value(
1279            json!({"tool_call_id":"call_123","result":{"temperature":72},"error":null}),
1280        )
1281        .unwrap();
1282        assert_eq!(
1283            serde_json::to_value(success).unwrap(),
1284            json!({"tool_call_id":"call_123","result":{"temperature":72},"error":null})
1285        );
1286    }
1287
1288    #[test]
1289    fn connection_required_wire_distinguishes_subjects_and_accepts_provider_only_values() {
1290        for (subject, setup_url) in [
1291            (
1292                ConnectionRequiredSubject::Agent,
1293                "/agents/agent_123?tab=mcp",
1294            ),
1295            (ConnectionRequiredSubject::User, "/settings/connections"),
1296        ] {
1297            let required = ConnectionRequired::with_setup("mcp_oauth_123", subject, setup_url);
1298            let wire = serde_json::to_value(&required).unwrap();
1299            assert_eq!(wire["provider"], "mcp_oauth_123");
1300            assert_eq!(
1301                wire["subject"],
1302                match subject {
1303                    ConnectionRequiredSubject::Agent => "agent",
1304                    ConnectionRequiredSubject::User => "user",
1305                }
1306            );
1307            assert_eq!(wire["setup_url"], setup_url);
1308            assert_eq!(
1309                serde_json::from_value::<ConnectionRequired>(wire).unwrap(),
1310                required
1311            );
1312        }
1313
1314        let legacy: ConnectionRequired = serde_json::from_value(json!("daytona")).unwrap();
1315        assert_eq!(legacy, ConnectionRequired::provider_only("daytona"));
1316        assert_eq!(serde_json::to_value(legacy).unwrap(), json!("daytona"));
1317
1318        let provider_object: ConnectionRequired =
1319            serde_json::from_value(json!({"provider":"github"})).unwrap();
1320        assert_eq!(provider_object, ConnectionRequired::provider_only("github"));
1321    }
1322
1323    #[test]
1324    fn narration_schema_is_optional_idempotent_and_does_not_mutate_input_definitions() {
1325        let original = vec![definition("builtin"), definition("client_side")];
1326        let before = serde_json::to_value(&original).unwrap();
1327        let augmented = add_human_intent_to_tool_definitions(&original);
1328        assert_eq!(serde_json::to_value(&original).unwrap(), before);
1329        let property = json!({"type":"string","description":"Short user-facing narration of what this tool call will do, written as an action phrase like \"Listing all harnesses\". Do not include hidden reasoning, private chain of thought, secrets, or credential values.","maxLength":120});
1330        for tool in &augmented {
1331            assert_eq!(
1332                tool.parameters(),
1333                &json!({"type":"object","properties":{"input":{"type":"string"},"human_intent":property},"required":["input"]})
1334            );
1335        }
1336        assert_eq!(
1337            serde_json::to_value(add_human_intent_to_tool_definitions(&augmented)).unwrap(),
1338            serde_json::to_value(&augmented).unwrap()
1339        );
1340        let mut closed = json!({"properties":{"operation":{"type":"string","enum":["list"]}},"required":["operation"],"additionalProperties":false});
1341        add_human_intent_to_schema(&mut closed);
1342        assert_eq!(
1343            closed,
1344            json!({"type":"object","properties":{"operation":{"type":"string","enum":["list"]},"human_intent":property},"required":["operation"],"additionalProperties":false})
1345        );
1346        let mut empty = json!({});
1347        add_human_intent_to_schema(&mut empty);
1348        assert_eq!(
1349            empty,
1350            json!({"type":"object","properties":{"human_intent":property}})
1351        );
1352        for mut scalar in [json!(null), json!(false), json!([])] {
1353            let before = scalar.clone();
1354            add_human_intent_to_schema(&mut scalar);
1355            assert_eq!(scalar, before);
1356        }
1357    }
1358
1359    #[test]
1360    fn execution_strips_only_top_level_narration_and_trims_display_text() {
1361        for (value, expected) in [
1362            (
1363                json!(" Listing all harnesses "),
1364                Some("Listing all harnesses"),
1365            ),
1366            (json!(" \t"), None),
1367            (json!(7), None),
1368            (json!(null), None),
1369        ] {
1370            let arguments = json!({"operation":"list","human_intent":value,"nested":{"human_intent":"ordinary data"}});
1371            let call = ToolCall {
1372                id: "call".into(),
1373                name: "manage_harnesses".into(),
1374                arguments: arguments.clone(),
1375            };
1376            assert_eq!(human_intent(&call.arguments), expected);
1377            assert_eq!(
1378                call.execution_arguments(),
1379                json!({"operation":"list","nested":{"human_intent":"ordinary data"}})
1380            );
1381            assert_eq!(call.arguments, arguments);
1382        }
1383        for value in [json!(null), json!([1, "text"]), json!({"operation":"list"})] {
1384            assert_eq!(strip_human_intent_argument(&value), value);
1385            assert_eq!(human_intent(&value), None);
1386        }
1387    }
1388
1389    #[test]
1390    fn unattended_never_answers_a_servers_questions() {
1391        let arguments = json!({
1392            "questions": [{"id": "plan", "header": "billing", "question": "Plan?",
1393                "options": [{"label": "Pro", "description": "", "default": true},
1394                            {"label": "Team", "description": ""}]}],
1395            "mcp_elicitation": {"server": "billing", "tool": "buy"}
1396        });
1397        let result = unattended_ask_user_result(&arguments);
1398        assert_eq!(result["status"], "declined");
1399        assert_eq!(result["answers"], json!([]));
1400    }
1401
1402    #[test]
1403    fn url_elicitation_requires_discriminator_and_complete_payload() {
1404        let payload = json!({"code":"url_elicitation_required","error":"Waiting","url":"https://consent.example/path","url_host":"consent.example","url_is_punycode":false,"server":"server","tool":"tool","retry_tool":"mcp_server_tool","message":"Connect account","declined":false});
1405        let mut result = ToolResult::error("unrelated");
1406        result.result = Some(payload.clone());
1407        assert_eq!(
1408            serde_json::to_value(UrlElicitationRequired::from_tool_result(&result).unwrap())
1409                .unwrap(),
1410            payload
1411        );
1412        let mut declined = payload.clone();
1413        declined["declined"] = json!(true);
1414        result.result = Some(declined.clone());
1415        assert_eq!(
1416            serde_json::to_value(UrlElicitationRequired::from_tool_result(&result).unwrap())
1417                .unwrap(),
1418            declined
1419        );
1420        for malformed in [
1421            None,
1422            Some(json!(null)),
1423            Some(json!({"code":"url_elicitation_required"})),
1424            Some({
1425                let mut value = payload.clone();
1426                value["code"] = json!("connection_required");
1427                value
1428            }),
1429            Some({
1430                let mut value = payload.clone();
1431                value["declined"] = json!("false");
1432                value
1433            }),
1434        ] {
1435            result.result = malformed;
1436            assert!(UrlElicitationRequired::from_tool_result(&result).is_none());
1437        }
1438    }
1439}