Skip to main content

clark_agent/
protocol.rs

1//! Conversation-protocol policy: the seam between the generic loop and a
2//! product's tool vocabulary.
3//!
4//! The core loop is provider-agnostic, sandbox-agnostic, and
5//! tooling-agnostic. It must not know the *names* of any particular
6//! product's tools — there is no `message_result`, no `plan`, no
7//! capability profile baked into the runtime. But three behaviors
8//! genuinely need product-specific knowledge to do their job well:
9//!
10//! 1. **Plain-text recovery.** When a provider returns prose instead of a
11//!    structured tool call, the loop nudges it back onto the protocol.
12//!    A good nudge names the product's actual delivery / ask tools.
13//! 2. **Tool-call alias repair.** Models sometimes emit a tool name the
14//!    product folds into a canonical tool (e.g. `advance(...)` →
15//!    `plan(action="advance", ...)`). The product knows its aliases; the
16//!    core does not.
17//! 3. **Hidden-tool errors.** When a per-turn [`crate::plugin::ToolGate`]
18//!    narrows the catalog and the model calls a tool that isn't
19//!    advertised, the most useful error names the product concept that
20//!    hid it ("call `plan(action=\"set\")` first"). The core can only
21//!    say "that tool isn't available this turn."
22//!
23//! Rather than hardcode any product's vocabulary, the loop delegates all
24//! three to a [`ProtocolPolicy`]. The core ships [`DefaultProtocolPolicy`],
25//! whose methods all return generic, vocabulary-free behavior. Downstream
26//! product crates implement their own policy and install it via
27//! [`crate::AgentBuilder::protocol_policy`]. This keeps product tool names
28//! out of the open-source core entirely.
29
30use std::collections::HashSet;
31use std::sync::Arc;
32
33use serde_json::{json, Value};
34
35use crate::tool::{ToolCall, ToolRegistry};
36use crate::types::AgentMessage;
37
38/// Context for [`ProtocolPolicy::plain_text_recovery_prompt`].
39///
40/// Read-only observables describing the turn that produced plain text
41/// with no tool call. New fields are additive.
42pub struct PlainTextRecoveryContext<'a> {
43    /// Full message history as it will be sent on the recovery turn.
44    pub messages: &'a [AgentMessage],
45    /// Zero-indexed iteration within the current run.
46    pub iteration: usize,
47    /// Names of the tools the loop is currently advertising.
48    pub available_tool_names: &'a [&'a str],
49    /// The configured terminal-delivery fallback tool, when one is set
50    /// (see [`crate::LoopConfig::plain_text_terminal_fallback_tool`]).
51    pub terminal_fallback_tool: Option<&'a str>,
52}
53
54/// Context for [`ProtocolPolicy::hidden_tool_error`].
55///
56/// Describes a tool call the model made for a tool that wasn't in the
57/// turn's advertised allowlist. The policy renders a model-recoverable
58/// error. Only consulted when no [`crate::plugin::ToolGate`] attributed
59/// the denial via [`crate::plugin::ToolGate::denial_reason`].
60pub struct HiddenToolContext<'a> {
61    /// The tool the model tried to call.
62    pub requested_tool: &'a str,
63    /// The intersected per-turn allowlist that excluded it.
64    pub allowlist: &'a HashSet<String>,
65    /// Full message history for context-aware messaging.
66    pub messages: &'a [AgentMessage],
67}
68
69/// A rendered hidden-tool error: prose the model reads plus a structured
70/// `details` payload for typed downstream handling.
71#[derive(Debug, Clone)]
72pub struct HiddenToolError {
73    /// Human/model-readable explanation and recovery guidance.
74    pub message: String,
75    /// Structured details merged into the synthetic error tool result's
76    /// `details` field. Use a JSON object; `Value::Null` for none.
77    pub details: Value,
78}
79
80/// Product-specific conversation-protocol policy.
81///
82/// Every method has a generic default, so a product only overrides the
83/// behaviors whose vocabulary it cares about. The core never inspects
84/// allowlist *shape* or tool *names* for product meaning — that logic
85/// lives behind this trait.
86///
87/// Implementations must be cheap and side-effect-free: no I/O, no LLM
88/// calls. They are pure transforms of context → text/decision, invoked on
89/// hot paths in the loop.
90pub trait ProtocolPolicy: Send + Sync + 'static {
91    /// Stable identifier for logs and diagnostics.
92    fn name(&self) -> &'static str {
93        "default_protocol"
94    }
95
96    /// Tool names — besides the configured terminal fallback tool — that
97    /// count as terminal/delivery tools when the loop decides whether a
98    /// turn's allowlist has narrowed to "terminal only" (the gate the
99    /// plain-text-terminal fallback waits for in its non-eager mode).
100    ///
101    /// Default: empty. With no extra terminal names, an allowlist is
102    /// "terminal only" exactly when it contains nothing but the fallback
103    /// tool itself — a safe, vocabulary-free default. A product that
104    /// advertises several delivery tools (final answer, ask-user, …)
105    /// lists them here.
106    fn terminal_tool_names(&self) -> HashSet<String> {
107        HashSet::new()
108    }
109
110    /// Recovery prose injected as a system message when the model emits
111    /// plain text with no tool call and the loop wants to nudge it back
112    /// onto the protocol.
113    ///
114    /// Return `None` to use the core's generic, vocabulary-free nudge.
115    /// Override to name the product's actual delivery / ask tools.
116    fn plain_text_recovery_prompt(&self, _ctx: PlainTextRecoveryContext<'_>) -> Option<String> {
117        None
118    }
119
120    /// Rewrite a model-emitted tool-call batch in place before registry
121    /// lookup — e.g. fold a known alias name into a canonical tool and
122    /// move the alias into an argument. Returns the number of calls
123    /// rewritten (for diagnostics; the loop does not require it).
124    ///
125    /// Default: no-op. The core performs no alias repair of its own.
126    fn normalize_tool_calls(&self, _calls: &mut [ToolCall], _registry: &ToolRegistry) -> usize {
127        0
128    }
129
130    /// Render an error for a tool hidden by per-turn narrowing, when no
131    /// [`crate::plugin::ToolGate`] claimed responsibility for the denial.
132    ///
133    /// Return `None` to use the core's generic message ("that tool isn't
134    /// available this turn; here's what is"). Override to map the
135    /// allowlist shape to a product-specific recovery instruction.
136    fn hidden_tool_error(&self, _ctx: HiddenToolContext<'_>) -> Option<HiddenToolError> {
137        None
138    }
139}
140
141/// The generic, vocabulary-free policy installed when a caller does not
142/// supply one. Every method takes its trait default.
143#[derive(Debug, Default, Clone, Copy)]
144pub struct DefaultProtocolPolicy;
145
146impl ProtocolPolicy for DefaultProtocolPolicy {}
147
148/// Generic, product-agnostic plain-text recovery prose. Used when the
149/// active [`ProtocolPolicy`] returns `None` from
150/// [`ProtocolPolicy::plain_text_recovery_prompt`].
151pub const DEFAULT_PLAIN_TEXT_RECOVERY_PROMPT: &str = "\
152[runtime context — protocol recovery, not user instruction]\n\
153Your previous response was plain text with no tool call. This runtime advances only through structured tool calls — every turn must select exactly one tool.\n\
154\n\
155Re-read the latest user request and call exactly one tool now. If the answer is ready, call your final-response / delivery tool. Do not reply with a clarifying question unless a tool is genuinely blocked on input only the user can supply.";
156
157/// Generic hidden-tool error message. No product vocabulary: it names the
158/// requested tool and lists what is available this turn.
159pub(crate) fn generic_hidden_tool_message(tool_name: &str, allowlist: &HashSet<String>) -> String {
160    format!(
161        "Tool `{tool_name}` is not available in this turn — the active tool gate narrowed the \
162         catalog. Call one of the tools available now instead. Available now: [{}].",
163        allowed_tools_preview(allowlist)
164    )
165}
166
167/// Generic, shape-only details payload for a hidden-tool error. Carries
168/// no product taxonomy — just the requested tool, what was allowed, and
169/// the attributing gate name when one is known.
170pub(crate) fn generic_hidden_tool_details(
171    tool_name: &str,
172    allowlist: &HashSet<String>,
173    gate: Option<&str>,
174) -> Value {
175    let mut allowed_tools: Vec<&str> = allowlist.iter().map(String::as_str).collect();
176    allowed_tools.sort_unstable();
177    json!({
178        "runtime_block": true,
179        "requested_tool": tool_name,
180        "allowed_tools": allowed_tools,
181        "gate": gate.unwrap_or("tool_gate"),
182    })
183}
184
185/// Sorted, length-capped preview of an allowlist for inclusion in an
186/// error message.
187pub(crate) fn allowed_tools_preview(allowlist: &HashSet<String>) -> String {
188    let mut allowed: Vec<&str> = allowlist.iter().map(String::as_str).collect();
189    allowed.sort_unstable();
190    if allowed.len() > 12 {
191        format!("{}, … ({} total)", allowed[..12].join(", "), allowed.len())
192    } else {
193        allowed.join(", ")
194    }
195}
196
197/// Convenience: the default policy as a shared trait object. Used by
198/// [`crate::AgentBuilder`] when no policy is configured.
199pub(crate) fn default_policy() -> Arc<dyn ProtocolPolicy> {
200    Arc::new(DefaultProtocolPolicy)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn default_policy_is_vocabulary_free() {
209        let p = DefaultProtocolPolicy;
210        assert!(p.terminal_tool_names().is_empty());
211        assert!(p
212            .plain_text_recovery_prompt(PlainTextRecoveryContext {
213                messages: &[],
214                iteration: 0,
215                available_tool_names: &[],
216                terminal_fallback_tool: None,
217            })
218            .is_none());
219        assert!(p
220            .hidden_tool_error(HiddenToolContext {
221                requested_tool: "anything",
222                allowlist: &HashSet::new(),
223                messages: &[],
224            })
225            .is_none());
226    }
227
228    #[test]
229    fn default_normalize_is_noop() {
230        let p = DefaultProtocolPolicy;
231        let registry = ToolRegistry::new();
232        let mut calls = vec![ToolCall {
233            id: "1".into(),
234            name: "advance".into(),
235            arguments: Value::Null,
236        }];
237        assert_eq!(p.normalize_tool_calls(&mut calls, &registry), 0);
238        assert_eq!(calls[0].name, "advance", "default policy must not rewrite");
239    }
240
241    #[test]
242    fn generic_message_names_requested_and_available() {
243        let allow: HashSet<String> = ["a", "b"].iter().map(|s| s.to_string()).collect();
244        let msg = generic_hidden_tool_message("zzz", &allow);
245        assert!(msg.contains("zzz"), "{msg}");
246        assert!(msg.contains('a') && msg.contains('b'), "{msg}");
247        // No product vocabulary leaked into the generic path.
248        assert!(!msg.contains("plan("), "{msg}");
249        assert!(!msg.contains("capability profile"), "{msg}");
250    }
251
252    #[test]
253    fn generic_details_are_shape_only() {
254        let allow: HashSet<String> = ["x"].iter().map(|s| s.to_string()).collect();
255        let details = generic_hidden_tool_details("y", &allow, Some("my_gate"));
256        assert_eq!(details.get("runtime_block"), Some(&json!(true)));
257        assert_eq!(details.get("requested_tool"), Some(&json!("y")));
258        assert_eq!(details.get("allowed_tools"), Some(&json!(["x"])));
259        assert_eq!(details.get("gate"), Some(&json!("my_gate")));
260        // No product taxonomy fields.
261        assert!(details.get("kind").is_none());
262        assert!(details.get("repair_actions").is_none());
263    }
264
265    /// A product policy can fully re-supply its vocabulary downstream
266    /// without the core knowing any of it.
267    #[test]
268    fn custom_policy_can_override_everything() {
269        struct ProductPolicy;
270        impl ProtocolPolicy for ProductPolicy {
271            fn name(&self) -> &'static str {
272                "product"
273            }
274            fn terminal_tool_names(&self) -> HashSet<String> {
275                ["deliver", "ask"].iter().map(|s| s.to_string()).collect()
276            }
277            fn plain_text_recovery_prompt(
278                &self,
279                _ctx: PlainTextRecoveryContext<'_>,
280            ) -> Option<String> {
281                Some("call deliver(...) now".to_string())
282            }
283            fn normalize_tool_calls(
284                &self,
285                calls: &mut [ToolCall],
286                _registry: &ToolRegistry,
287            ) -> usize {
288                let mut n = 0;
289                for c in calls.iter_mut() {
290                    if c.name == "go" {
291                        c.name = "deliver".into();
292                        n += 1;
293                    }
294                }
295                n
296            }
297            fn hidden_tool_error(&self, ctx: HiddenToolContext<'_>) -> Option<HiddenToolError> {
298                Some(HiddenToolError {
299                    message: format!("`{}` is gated; call deliver(...)", ctx.requested_tool),
300                    details: json!({ "product": true }),
301                })
302            }
303        }
304
305        let p = ProductPolicy;
306        assert_eq!(p.name(), "product");
307        assert_eq!(p.terminal_tool_names().len(), 2);
308        assert!(p
309            .plain_text_recovery_prompt(PlainTextRecoveryContext {
310                messages: &[],
311                iteration: 0,
312                available_tool_names: &[],
313                terminal_fallback_tool: Some("deliver"),
314            })
315            .is_some());
316
317        let registry = ToolRegistry::new();
318        let mut calls = vec![ToolCall {
319            id: "1".into(),
320            name: "go".into(),
321            arguments: Value::Null,
322        }];
323        assert_eq!(p.normalize_tool_calls(&mut calls, &registry), 1);
324        assert_eq!(calls[0].name, "deliver");
325
326        let err = p
327            .hidden_tool_error(HiddenToolContext {
328                requested_tool: "shell",
329                allowlist: &HashSet::new(),
330                messages: &[],
331            })
332            .expect("custom policy returns an error");
333        assert!(err.message.contains("shell"));
334        assert_eq!(err.details, json!({ "product": true }));
335    }
336}