clark_agent/plugin.rs
1//! Plugin extension points.
2//!
3//! All cross-cutting concerns plug into the loop through these traits.
4//! No inline `if special_case_X` branches inside the loop; keep hook
5//! discipline in explicit extension points.
6//!
7//! Two families:
8//!
9//! 1. **Capability traits** (this module) — `BeforeToolCall`,
10//! `AfterToolCall`, `ContextTransform`, `EventObserver`,
11//! `SteeringSource`, `FollowUpSource`. Each is narrow: a hook that
12//! needs the assistant message gets the assistant message, never a
13//! fat `&mut LoopState`. New capabilities add a new trait; they do
14//! not widen an existing one.
15//!
16//! 2. **`Plugin` marker** — a single registry entry that may implement
17//! one or more capability traits. `AgentBuilder` holds plugins as
18//! `Arc<dyn Plugin>` and dispatches to whichever capabilities the
19//! plugin declares via [`Plugin::capabilities`].
20
21use async_trait::async_trait;
22use serde_json::Value;
23use std::sync::Arc;
24use tokio_util::sync::CancellationToken;
25
26use crate::event::AgentEvent;
27use crate::tokens::{TokenEstimator, CHAR_HEURISTIC};
28use crate::tool::{ToolCall, ToolResult};
29use crate::types::{AgentMessage, AssistantContent, Usage};
30
31// ─── Plugin marker ─────────────────────────────────────────────────
32
33/// A registered extension. Each plugin declares which capability traits
34/// it implements via [`PluginCapabilities`].
35///
36/// A plugin can implement any subset of: `BeforeToolCall`, `AfterToolCall`,
37/// `ContextTransform`, `EventObserver`, `SteeringSource`, `FollowUpSource`.
38/// The loop's plugin dispatcher iterates registered plugins for each
39/// extension point.
40pub trait Plugin: Send + Sync + 'static {
41 /// Stable identifier for logs and telemetry.
42 fn name(&self) -> &'static str;
43
44 /// Which capabilities this plugin implements. Default: none — meaning
45 /// pure observation by inheriting from `EventObserver`. Override and
46 /// return the relevant set when adding behavior.
47 fn capabilities(&self) -> PluginCapabilities {
48 PluginCapabilities::default()
49 }
50}
51
52/// Bitset of which extension points a plugin participates in.
53///
54/// The dispatcher reads this to skip plugins that don't implement a
55/// given hook, avoiding wasteful trait-object cast attempts.
56///
57/// `inheritable_to_child` is the spawn-time signal: when a parent run
58/// calls [`crate::LoopConfig::child_builder`], every parent plugin
59/// whose capabilities have `inheritable_to_child = true` is carried
60/// into the child's plugin registry as-is. Default `false` — plugins
61/// that hold conversation-scoped state, mutate parent-only stores, or
62/// know about the parent's UI/persistence must opt in explicitly so a
63/// child run cannot silently inherit parent identity.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
65pub struct PluginCapabilities {
66 pub before_tool_call: bool,
67 pub after_tool_call: bool,
68 pub context_transform: bool,
69 pub event_observer: bool,
70 pub steering: bool,
71 pub follow_up: bool,
72 pub tool_gate: bool,
73 /// When `true`, [`crate::LoopConfig::child_builder`] carries this
74 /// plugin into every spawned child run. When `false` (default),
75 /// the plugin is parent-only and the caller assembling the child
76 /// must register the child-specific equivalent.
77 pub inheritable_to_child: bool,
78}
79
80impl PluginCapabilities {
81 pub fn before_tool_call() -> Self {
82 Self {
83 before_tool_call: true,
84 ..Self::default()
85 }
86 }
87 pub fn after_tool_call() -> Self {
88 Self {
89 after_tool_call: true,
90 ..Self::default()
91 }
92 }
93 pub fn context_transform() -> Self {
94 Self {
95 context_transform: true,
96 ..Self::default()
97 }
98 }
99 pub fn event_observer() -> Self {
100 Self {
101 event_observer: true,
102 ..Self::default()
103 }
104 }
105 pub fn steering() -> Self {
106 Self {
107 steering: true,
108 ..Self::default()
109 }
110 }
111 pub fn follow_up() -> Self {
112 Self {
113 follow_up: true,
114 ..Self::default()
115 }
116 }
117 pub fn tool_gate() -> Self {
118 Self {
119 tool_gate: true,
120 ..Self::default()
121 }
122 }
123
124 pub fn with_follow_up(mut self) -> Self {
125 self.follow_up = true;
126 self
127 }
128 pub fn with_tool_gate(mut self) -> Self {
129 self.tool_gate = true;
130 self
131 }
132 /// Mark this plugin as inheritable to child runs spawned via
133 /// [`crate::LoopConfig::child_builder`].
134 pub fn with_inheritable_to_child(mut self) -> Self {
135 self.inheritable_to_child = true;
136 self
137 }
138}
139
140// ─── BeforeToolCall ────────────────────────────────────────────────
141
142/// Read-only context handed to a `BeforeToolCall` hook.
143///
144/// Narrow on purpose: the hook gets the assistant message that requested
145/// the call, the call itself, and the validated arguments. It does not
146/// get a fat `&mut LoopState`.
147pub struct BeforeToolCallContext<'a> {
148 pub assistant_message: &'a AgentMessage,
149 pub assistant_content: &'a AssistantContent,
150 pub tool_call: &'a ToolCall,
151 pub args: &'a Value,
152 pub messages: &'a [AgentMessage],
153}
154
155/// Decision returned by a `BeforeToolCall` hook.
156///
157/// `block: true` short-circuits execution; the loop synthesizes an error
158/// tool result with `reason` (or a default message) and emits a
159/// `ToolExecutionEnd` with `is_error = true`.
160#[derive(Debug, Clone, Default)]
161pub struct BeforeToolDecision {
162 pub block: bool,
163 pub reason: Option<String>,
164 pub details: Option<Value>,
165}
166
167impl BeforeToolDecision {
168 pub fn allow() -> Self {
169 Self::default()
170 }
171 pub fn block(reason: impl Into<String>) -> Self {
172 Self {
173 block: true,
174 reason: Some(reason.into()),
175 details: None,
176 }
177 }
178
179 pub fn block_with_details(reason: impl Into<String>, details: Value) -> Self {
180 Self {
181 block: true,
182 reason: Some(reason.into()),
183 details: Some(details),
184 }
185 }
186}
187
188/// Hook that runs after argument validation, before tool execution.
189///
190/// Cheap and side-effect-free: no I/O, no LLM calls, no spawning, no
191/// state mutation. Pure transform of context → decision.
192#[async_trait]
193pub trait BeforeToolCall: Plugin {
194 async fn on_before_tool_call(&self, ctx: BeforeToolCallContext<'_>) -> BeforeToolDecision;
195}
196
197// ─── AfterToolCall ─────────────────────────────────────────────────
198
199/// Read-only context handed to an `AfterToolCall` hook.
200///
201/// Includes the executed result so the hook can override it. The hook
202/// cannot re-execute the tool; it can only transform the result the
203/// model will see.
204pub struct AfterToolCallContext<'a> {
205 pub assistant_message: &'a AgentMessage,
206 pub tool_call: &'a ToolCall,
207 pub args: &'a Value,
208 pub result: &'a ToolResult,
209 pub is_error: bool,
210 pub messages: &'a [AgentMessage],
211}
212
213/// Override returned by an `AfterToolCall` hook. Each field is opt-in:
214/// omitted fields keep the original tool result. No deep merge.
215#[derive(Debug, Clone, Default)]
216pub struct AfterToolDecision {
217 pub result: Option<ToolResult>,
218 pub mark_error: Option<bool>,
219 pub terminate: Option<bool>,
220}
221
222impl AfterToolDecision {
223 pub fn passthrough() -> Self {
224 Self::default()
225 }
226
227 pub fn override_result(result: ToolResult) -> Self {
228 Self {
229 result: Some(result),
230 ..Self::default()
231 }
232 }
233}
234
235/// Hook that runs after tool execution, before the result is appended to
236/// history. May override the result, flip the error flag, or vote to
237/// terminate.
238///
239/// Termination semantics are unanimous across the batch: the
240/// run only ends when *every* finalized tool result in the batch has
241/// `terminate = true`.
242#[async_trait]
243pub trait AfterToolCall: Plugin {
244 async fn on_after_tool_call(&self, ctx: AfterToolCallContext<'_>) -> AfterToolDecision;
245}
246
247// ─── ContextTransform ──────────────────────────────────────────────
248
249/// Read-only context handed to a `ContextTransform` hook.
250///
251/// Carries the cancellation signal plus a few cheap observables that
252/// transforms key on (model identity, iteration index, last-turn token
253/// usage, the loop's configured token estimator). Gathering these on
254/// the hook context — rather than widening the trait one parameter at
255/// a time — keeps the trait stable as later compaction layers
256/// (per-tool-result cap, cache-aware microcompact, auto-compact) come
257/// online.
258///
259/// New fields are additive: transforms that don't care can ignore them.
260pub struct TransformContext<'a> {
261 /// Cancellation signal for the current run.
262 pub signal: &'a CancellationToken,
263 /// Model identifier the run is targeting (e.g. provider/model). May
264 /// be empty when the host runtime doesn't surface one — tests,
265 /// fixture-replay transports, etc. Plugins that key per-model
266 /// behavior should treat empty as "unknown".
267 pub model_id: &'a str,
268 /// Zero-indexed iteration within the current run. Same semantics as
269 /// [`ToolGateContext::iteration`]: the very first LLM call of the
270 /// run is `0`.
271 pub iteration: usize,
272 /// Token usage reported by the provider on the most recent assistant
273 /// turn that surfaced a `Usage` block. `None` on the very first turn
274 /// or when the provider didn't surface usage. Useful for
275 /// cache-aware decisions (read `cache_read_input_tokens` to see if
276 /// the prompt prefix actually hit cache last turn).
277 pub last_provider_usage: Option<&'a Usage>,
278 /// Estimator the loop is configured with. Plugins use this to count
279 /// tokens for budgeting and compaction without duplicating the
280 /// loop's tokenizer choice.
281 pub estimator: &'a dyn TokenEstimator,
282}
283
284impl<'a> TransformContext<'a> {
285 /// Convenience constructor for tests and ad-hoc callers that don't
286 /// have a model id, iteration counter, or usage data. Picks the
287 /// default char-heuristic estimator.
288 pub fn for_test(signal: &'a CancellationToken) -> Self {
289 Self {
290 signal,
291 model_id: "",
292 iteration: 0,
293 last_provider_usage: None,
294 estimator: &CHAR_HEURISTIC,
295 }
296 }
297}
298
299/// Hook that transforms the message slice before it's converted to the
300/// LLM provider format.
301///
302/// Common use: token-budget pruning. See [`crate::budget`] for the
303/// default implementation.
304///
305/// Contract: must not throw; on failure return the input unchanged.
306/// Multiple plugins compose left-to-right.
307#[async_trait]
308pub trait ContextTransform: Plugin {
309 /// Cheap predicate the loop consults before invoking `transform`.
310 /// Default returns `true` — preserves existing behavior. Plugins that
311 /// can decide locally that they have nothing to do (no browser
312 /// snapshots in history, history under budget, idle timer not
313 /// elapsed, no queued recovery notice, …) should override to return
314 /// `false` in those states.
315 ///
316 /// When `false`, the loop skips the full message-vec clone + the
317 /// `ContextTransformApplied` diff event — eliminating the
318 /// per-transform cost on rounds where the plugin is a no-op. This
319 /// shows up most clearly in long-running scenarios: with several
320 /// transforms installed, each firing hundreds of times as a no-op,
321 /// the full before-clone + event emit otherwise happens every time.
322 ///
323 /// Predicates MUST be O(1) or O(small-constant); a predicate that
324 /// itself walks the entire history defeats the optimization.
325 fn should_run(&self, _messages: &[AgentMessage], _cx: &TransformContext<'_>) -> bool {
326 true
327 }
328
329 async fn transform(
330 &self,
331 messages: Vec<AgentMessage>,
332 cx: &TransformContext<'_>,
333 ) -> Vec<AgentMessage>;
334}
335
336// ─── ContextOverflowRecovery ───────────────────────────────────────
337
338/// Recovers from a provider context-window rejection MID-RUN.
339///
340/// The token-estimate heuristics that drive `ContextTransform` are
341/// approximate, so a request can still exceed the model's real window
342/// and come back as [`crate::StreamError::ContextOverflow`]. When one is
343/// installed, the loop hands this hook the current history, the impl
344/// returns a smaller one (typically an aggressive compaction), and the
345/// loop retries the SAME LLM call — bounded by [`max_attempts`], and
346/// with the shrunk history persisted into the live transcript so later
347/// turns don't immediately re-expand.
348///
349/// Distinct from [`ContextTransform`] on purpose: this fires ONLY on an
350/// overflow (never on every round), its result is written back to the
351/// caller's transcript (not just the request clone), and returning an
352/// unshrunk history ends recovery rather than spinning.
353///
354/// [`max_attempts`]: ContextOverflowRecovery::max_attempts
355#[async_trait]
356pub trait ContextOverflowRecovery: Send + Sync {
357 /// Produce a smaller history for the retried request. `cx` carries the
358 /// same observables as a [`ContextTransform`] (cancellation signal,
359 /// model id, iteration, last provider usage, estimator). Returning a
360 /// history no shorter than the input signals "cannot shrink further"
361 /// and the loop stops retrying.
362 async fn recover(
363 &self,
364 messages: Vec<AgentMessage>,
365 cx: &TransformContext<'_>,
366 ) -> Vec<AgentMessage>;
367
368 /// Maximum recovery attempts within a single run. Default 1.
369 fn max_attempts(&self) -> u8 {
370 1
371 }
372
373 /// Label for the [`AgentEvent::ContextTransformApplied`] diff event
374 /// emitted after a successful shrink, so observers can attribute the
375 /// history change.
376 ///
377 /// [`AgentEvent::ContextTransformApplied`]: crate::AgentEvent::ContextTransformApplied
378 fn name(&self) -> &'static str {
379 "context_overflow_recovery"
380 }
381}
382
383// ─── EventObserver ─────────────────────────────────────────────────
384
385/// Pure observation hook. Logs, telemetry, replay writers. Cannot change
386/// loop state — the event sink (`crate::event::EventSink`) is the formal
387/// channel; this trait exists so plugins can subscribe declaratively
388/// alongside their other hooks instead of wiring a separate sink.
389#[async_trait]
390pub trait EventObserver: Plugin {
391 async fn on_event(&self, event: &AgentEvent);
392}
393
394// ─── SteeringSource (steer()) ──────────────────────────────────────
395
396/// Source of "steering messages" — extra messages the user / harness
397/// wants to inject mid-run.
398///
399/// The loop calls `next_steering_messages` after the current assistant
400/// turn finishes executing its tool calls and before the next LLM call.
401/// Returned messages are appended verbatim to the transcript, then the
402/// loop continues. Use cases: user typed something while the agent was
403/// thinking, harness wants to inject a hint, watchdog wants to force a
404/// checkpoint.
405///
406/// Tool calls already in flight are not interrupted — steering messages
407/// land between batches.
408#[async_trait]
409pub trait SteeringSource: Plugin {
410 async fn next_steering_messages(&self) -> Vec<AgentMessage>;
411}
412
413// ─── FollowUpSource ────────────────────────────────────────────────
414
415/// Source of "follow-up messages" — extra messages the loop should
416/// process after the agent would otherwise stop.
417///
418/// Distinct from steering: steering is consulted *between batches* and
419/// keeps the agent running; follow-up is consulted *after natural stop*
420/// and re-starts the agent if there's more to do. Use case: queued user
421/// turns that arrived while the previous turn was still running.
422#[async_trait]
423pub trait FollowUpSource: Plugin {
424 async fn next_follow_up_messages(&self) -> Vec<AgentMessage>;
425}
426
427// ─── ToolGate ──────────────────────────────────────────────────────
428
429/// Read-only loop state handed to a `ToolGate` so its decision is a
430/// pure function of observables, not of internal flag bookkeeping.
431/// New fields are additive — gates that don't care can ignore them.
432pub struct ToolGateContext<'a> {
433 /// Zero-indexed iteration within the current run. The very first
434 /// LLM call after the user message has `iteration == 0`. Increments
435 /// once per `stream_assistant_response`.
436 pub iteration: usize,
437 /// Full message history that will be sent on the next request,
438 /// after any `ContextTransform` reshaping. Use this to derive
439 /// signals like "have we seen a terminator yet" or "how many tool
440 /// results in a row didn't make progress".
441 pub messages: &'a [AgentMessage],
442 /// Conversation identifier when the host runtime knows one (a
443 /// session runner threads it through). `None` for embeddings of the
444 /// loop that don't carry conversation identity (tests, isolated
445 /// subagent runs). Gates can use this for diagnostics or
446 /// conversation-scoped policy.
447 pub conversation_id: Option<&'a str>,
448 /// Names of every tool the loop is about to advertise on the next
449 /// request, in registration order. Lets gates compute denylist-style
450 /// allowlists ("everything except these terminators") without
451 /// hardcoding the catalog or extending the trait. Empty in tests
452 /// that don't care about the universe.
453 pub available_tool_names: &'a [&'a str],
454}
455
456/// How a tool gate should compose with explicit recovery owners.
457///
458/// Required gates encode typed boundaries: phase capability, workflow
459/// ownership, delivery repair, scenario contracts, and similar constraints.
460/// Advisory gates encode pressure or nudges: budget wrap-up and terminal
461/// recovery. When a required recovery owner says it has live repair work,
462/// advisory gates may be ignored for that turn so they cannot erase the
463/// tools needed to perform the repair.
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum ToolGateClass {
466 Required,
467 Advisory,
468}
469
470/// Per-turn allowlist of tool names the model may invoke.
471///
472/// Returning `Some(set)` means: for the *very next* LLM call, narrow
473/// the advertised tools to those whose names appear in `set`. Every
474/// other tool the agent has access to is omitted from that one
475/// request. `None` means no narrowing — the loop sends all tools.
476///
477/// Composition across multiple gates: the loop intersects every
478/// `Some` allowlist; absent (`None`) gates do not constrain. If multiple
479/// non-empty gate allowlists conflict to the empty set, the loop repairs
480/// the composition by choosing the highest-priority gate and emits a
481/// typed conflict event. Gates that own urgent recovery states should
482/// override [`ToolGate::conflict_priority`].
483///
484/// Single-shot semantics emerge from the trigger condition, not from
485/// internal mutability: a gate that fires only on `iteration == 0`
486/// is naturally single-shot per run. Conversation-scoped gates should
487/// keep their cross-run state in an external store, not in the plugin
488/// instance.
489#[async_trait]
490pub trait ToolGate: Plugin {
491 async fn next_turn_tool_allowlist(
492 &self,
493 ctx: ToolGateContext<'_>,
494 ) -> Option<std::collections::HashSet<String>>;
495
496 /// This gate's specific reason for denying `tool_name` in the given
497 /// context. The runtime queries every gate after a hidden-tool call
498 /// so the error message names the actual narrower instead of guessing
499 /// from the intersected allowlist's shape — that guess sent the model
500 /// to repair the wrong gate (e.g. a `delivery_repair_gate` strip read
501 /// as a `capability_gate` phase mismatch and triggered futile
502 /// plan-updates until wall-clock timeout).
503 ///
504 /// Default: `None` — the runtime falls back to its shape-based
505 /// heuristic. Return `Some(reason)` only when this gate is actively
506 /// narrowing in a way that excludes `tool_name` in this context.
507 async fn denial_reason(&self, _tool_name: &str, _ctx: ToolGateContext<'_>) -> Option<String> {
508 None
509 }
510
511 fn conflict_priority(&self) -> i32 {
512 0
513 }
514
515 fn tool_gate_class(&self) -> ToolGateClass {
516 ToolGateClass::Required
517 }
518
519 fn suppresses_advisory_gates(&self, _ctx: ToolGateContext<'_>) -> bool {
520 false
521 }
522}
523
524// ─── Helper: stand-alone steering channel ──────────────────────────
525
526/// `tokio::sync::mpsc`-backed steering source. Producer side
527/// (`SteeringHandle`) lets external code call `.steer(message)` from
528/// anywhere; consumer side implements `SteeringSource` and drains the
529/// channel each batch.
530pub struct ChannelSteering {
531 rx: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<AgentMessage>>,
532}
533
534#[derive(Clone)]
535pub struct SteeringHandle {
536 tx: tokio::sync::mpsc::UnboundedSender<AgentMessage>,
537}
538
539impl SteeringHandle {
540 /// Inject a steering message. Returns `Ok` if the loop is still
541 /// running, `Err` if it has already shut down.
542 // Preserve the standard mpsc error so callers can recover the unsent
543 // message; boxing it would make this small helper harder to use.
544 #[allow(clippy::result_large_err)]
545 pub fn steer(
546 &self,
547 message: AgentMessage,
548 ) -> Result<(), tokio::sync::mpsc::error::SendError<AgentMessage>> {
549 self.tx.send(message)
550 }
551}
552
553impl ChannelSteering {
554 pub fn new() -> (Arc<Self>, SteeringHandle) {
555 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
556 (
557 Arc::new(Self {
558 rx: tokio::sync::Mutex::new(rx),
559 }),
560 SteeringHandle { tx },
561 )
562 }
563}
564
565impl Plugin for ChannelSteering {
566 fn name(&self) -> &'static str {
567 "channel_steering"
568 }
569 fn capabilities(&self) -> PluginCapabilities {
570 PluginCapabilities::steering()
571 }
572}
573
574#[async_trait]
575impl SteeringSource for ChannelSteering {
576 async fn next_steering_messages(&self) -> Vec<AgentMessage> {
577 let mut rx = self.rx.lock().await;
578 let mut out = Vec::new();
579 while let Ok(msg) = rx.try_recv() {
580 out.push(msg);
581 }
582 out
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use crate::types::UserContent;
590
591 #[tokio::test]
592 async fn channel_steering_drains() {
593 let (source, handle) = ChannelSteering::new();
594 handle
595 .steer(AgentMessage::User {
596 content: UserContent::Text("hi".into()),
597 timestamp: None,
598 })
599 .unwrap();
600 handle
601 .steer(AgentMessage::User {
602 content: UserContent::Text("again".into()),
603 timestamp: None,
604 })
605 .unwrap();
606
607 let drained = source.next_steering_messages().await;
608 assert_eq!(drained.len(), 2);
609
610 // Second call returns empty.
611 let drained2 = source.next_steering_messages().await;
612 assert!(drained2.is_empty());
613 }
614}