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, ®istry), 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, ®istry), 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}