Skip to main content

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}