gglib_core/ports/agent.rs
1//! Agent loop port traits.
2//!
3//! Defines the hexagonal-architecture port interfaces for the backend agentic
4//! loop. All types used in signatures are from `gglib-core`; no adapter- or
5//! crate-specific symbols appear here.
6//!
7//! # Port hierarchy
8//!
9//! ```text
10//! AgentLoopPort
11//! └── uses ──→ ToolExecutorPort (to dispatch individual tool calls)
12//! └── emits ──→ Sender<AgentEvent> (SSE-ready async channel)
13//! ```
14//!
15//! # Error separation
16//!
17//! | Concern | Type |
18//! |---------|------|
19//! | Fatal loop failure | [`AgentError`] — returned from [`AgentLoopPort::run`] |
20//! | Executor infrastructure failure | `anyhow::Error` — from [`ToolExecutorPort::execute`] |
21//! | Tool-level outcome (incl. failures) | [`ToolResult::success`] field — LLM context |
22//!
23//! A tool result with `success: false` is **not** an error; it is fed back into
24//! the conversation so the model can observe and react to the failure.
25//! `AgentError` is reserved for conditions where the loop itself cannot continue.
26
27use async_trait::async_trait;
28use thiserror::Error;
29use tokio::sync::mpsc;
30
31use crate::domain::agent::{
32 AgentConfig, AgentEvent, AgentMessage, ToolCall, ToolDefinition, ToolResult,
33};
34
35// =============================================================================
36// Error type — fatal loop-level failures only
37// =============================================================================
38
39/// Errors that terminate the agentic loop.
40///
41/// These represent conditions where `AgentLoopPort::run` cannot continue.
42/// They do **not** include tool execution failures — those are encoded as
43/// `ToolResult { success: false }` and fed back to the LLM as context.
44#[derive(Debug, Error)]
45pub enum AgentError {
46 /// The loop reached [`AgentConfig::max_iterations`] without producing a
47 /// final answer.
48 #[error("agent loop reached the maximum number of iterations ({0})")]
49 MaxIterationsReached(usize),
50
51 /// The loop detected a tool-call signature repeated **back to back and
52 /// answered the same way**, indicating the model is stuck rather than
53 /// working.
54 ///
55 /// The `signature` field is a stable hash of the tool-call batch that
56 /// recurred, with nothing in between **and getting the same answer back
57 /// each time**, more times than
58 /// [`AgentConfig::max_repeated_batch_steps`] allows. A batch that repeats
59 /// with other work between occurrences is not a cycle and does not raise
60 /// this; neither does one whose answers keep changing, which is an agent
61 /// polling for output rather than a stuck one.
62 ///
63 /// It is also raised when a batch that is *not* read-only has been carried
64 /// past `max_observation_steps` by changing answers. The two are not
65 /// distinguished here: the remedy is the same, and this variant is
66 /// mirrored into the proxy's 400 body.
67 #[error("tool-call loop detected (repeated signature: {signature})")]
68 LoopDetected {
69 /// Stable hash of the repeated tool-call batch (for diagnostics).
70 signature: String,
71 },
72
73 /// The LLM produced more tool calls in a single batch than configured by
74 /// [`AgentConfig::max_parallel_tools`].
75 ///
76 /// This is a model protocol violation: the LLM returned more concurrent
77 /// calls than the loop is configured to dispatch. The loop aborts rather
78 /// than silently truncating the batch, because partial execution could
79 /// leave the model with an incoherent view of which calls were handled.
80 #[error("LLM requested {count} tool calls in one batch, exceeds max_parallel_tools ({limit})")]
81 ParallelToolLimitExceeded {
82 /// Number of tool calls the LLM returned.
83 count: usize,
84 /// The configured maximum ([`AgentConfig::max_parallel_tools`]).
85 limit: usize,
86 },
87
88 /// The assistant produced the same text content too many times in a short
89 /// span of prose turns, indicating a non-tool-calling repetition loop.
90 ///
91 /// Preserves the FNV-1a hash of the repeated text, the occurrence count
92 /// within the window (including baseline), and the configured
93 /// `max_stagnation_steps` limit — giving callers structured access to the
94 /// stagnation evidence without parsing an error string.
95 ///
96 /// Detection is windowed: both strictly consecutive repetitions and
97 /// A → B → A oscillations are caught inside the window.
98 #[error(
99 "agent stagnated: same response text seen {count} time(s) in a short span \
100 (max_stagnation_steps = {max_steps})"
101 )]
102 StagnationDetected {
103 /// FNV-1a hash of the repeated assistant text, hex-encoded for diagnostics.
104 /// Stored as `String` to decouple the public API from the internal u64
105 /// representation so callers never need to know the hashing algorithm.
106 repeated_text_hash: String,
107 /// Total number of times this text has been seen in the session
108 /// (including the baseline occurrence).
109 count: usize,
110 /// The configured stagnation limit at the time of detection.
111 max_steps: usize,
112 },
113
114 /// An unrecoverable internal error inside the loop implementation.
115 #[error("internal agent error: {0}")]
116 Internal(String),
117}
118
119// =============================================================================
120// AgentRunOutput — structured return value for a successful run
121// =============================================================================
122
123/// Output returned by a successful [`AgentLoopPort::run`] invocation.
124///
125/// Using a named struct instead of a bare tuple keeps call sites
126/// self-documenting and allows new fields to be added without breaking
127/// existing destructures.
128#[derive(Debug)]
129pub struct AgentRunOutput {
130 /// The final answer text produced by the agent.
131 pub answer: String,
132 /// Full accumulated conversation history: the caller-supplied messages
133 /// **plus** every assistant and tool-result message appended during the
134 /// loop, including the final assistant reply.
135 ///
136 /// Safe to pass directly as the `messages` argument for the next turn.
137 pub history: Vec<AgentMessage>,
138 /// Number of loop iterations consumed before the agent produced its final
139 /// answer. Always ≥ 1. Useful for logging and telemetry.
140 pub total_iterations: usize,
141 /// Total completion tokens generated across every iteration, summed from
142 /// each response's trailing usage report.
143 ///
144 /// `None` when no upstream response reported usage — distinct from zero,
145 /// which would be a real (if strange) measurement. Feeds the benchmark
146 /// speed axis (completion tokens over wall-clock time).
147 pub total_completion_tokens: Option<u64>,
148}
149
150// =============================================================================
151// ToolExecutorPort
152// =============================================================================
153
154/// Port: dispatches tool calls to the underlying execution backend.
155///
156/// # Implementing this trait
157///
158/// ```ignore
159/// use gglib_core::ports::{AgentError, ToolExecutorPort};
160/// use gglib_core::domain::{ToolCall, ToolDefinition, ToolResult};
161///
162/// struct McpToolExecutor { /* ... */ }
163///
164/// #[async_trait::async_trait]
165/// impl ToolExecutorPort for McpToolExecutor {
166/// async fn list_tools(&self) -> Vec<ToolDefinition> { /* ... */ }
167///
168/// async fn execute(&self, call: &ToolCall) -> Result<ToolResult, anyhow::Error> {
169/// // Call the MCP client; convert McpToolResult → ToolResult.
170/// // Return Err(_) only if the infrastructure itself is unavailable.
171/// }
172/// }
173/// ```
174///
175/// # Error contract
176///
177/// - Returns `Ok(ToolResult { success: false, .. })` when the tool ran but
178/// produced an application-level error (wrong args, resource not found, etc.).
179/// The loop implementation **must** feed this back to the LLM as context.
180/// - Returns `Err(anyhow::Error)` only when the executor infrastructure is
181/// unavailable (e.g. MCP process died, network unreachable). The loop
182/// implementation converts this into `ToolResult { success: false, content:
183/// "executor unavailable: …" }` so the LLM still receives context.
184#[async_trait]
185pub trait ToolExecutorPort: Send + Sync {
186 /// Return all tool definitions available in this executor.
187 ///
188 /// Called once per agent `run` invocation to build the tool list sent to
189 /// the LLM.
190 async fn list_tools(&self) -> Vec<ToolDefinition>;
191
192 /// Execute a single tool call.
193 ///
194 /// Returns `Err` only for infrastructure failures (see error contract above).
195 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, anyhow::Error>;
196}
197
198// =============================================================================
199// AgentLoopPort
200// =============================================================================
201
202/// Port: drives the full backend agentic loop.
203///
204/// # Usage
205///
206/// ```ignore
207/// use tokio::sync::mpsc;
208/// use gglib_core::ports::AgentLoopPort;
209/// use gglib_core::domain::{AgentConfig, AgentEvent, AgentMessage};
210///
211/// async fn run_loop(agent: &dyn AgentLoopPort) {
212/// let (tx, mut rx) = mpsc::channel::<AgentEvent>(64);
213///
214/// // Spawn a task to consume the event stream (e.g. SSE or logging).
215/// tokio::spawn(async move {
216/// while let Some(event) = rx.recv().await {
217/// println!("{:?}", event);
218/// }
219/// // rx.recv() returns None when tx is dropped (loop ended).
220/// });
221///
222/// let messages = vec![AgentMessage::User { content: "Hello".into() }];
223/// let output = agent.run(messages, AgentConfig::default(), tx).await?;
224/// println!("Final: {}", output.answer);
225/// // `history` contains the full accumulated message list including all
226/// // assistant and tool-result messages appended during the loop — safe
227/// // to pass directly as the `messages` argument for the next turn.
228/// }
229/// ```
230///
231/// # Channel ownership and stream termination
232///
233/// `events` is taken **by value**. When `run` returns (whether `Ok` or `Err`)
234/// the `Sender` is dropped, which closes the channel and signals `None` to
235/// the `Receiver`. Axum SSE handlers and CLI consumers can rely on this to
236/// know the stream has ended without needing an explicit sentinel event.
237#[async_trait]
238pub trait AgentLoopPort: Send + Sync {
239 /// Execute the agentic loop and return the final answer.
240 ///
241 /// # Parameters
242 ///
243 /// * `messages` — The initial conversation history (system prompt + user
244 /// message at minimum).
245 /// * `config` — Loop control parameters (iteration limits, timeouts, etc.).
246 /// * `tx` — Async channel over which the loop streams [`AgentEvent`]s.
247 /// Taken by value; dropped on completion to close the SSE stream.
248 ///
249 /// # Returns
250 ///
251 /// * `Ok(AgentRunOutput)` — The final answer and full accumulated message
252 /// history (safe to pass back as `messages` on the next turn).
253 /// * `Err(AgentError)` — A fatal loop-level failure (max iterations reached,
254 /// loop detection, stagnation, or internal error). No partial history is
255 /// returned on failure; the caller's existing history is left intact.
256 async fn run(
257 &self,
258 messages: Vec<AgentMessage>,
259 config: AgentConfig,
260 tx: mpsc::Sender<AgentEvent>,
261 ) -> Result<AgentRunOutput, AgentError>;
262}