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 repeated tool-call signature, indicating the model
52    /// is stuck in a cycle.
53    ///
54    /// The `signature` field is a stable hash of the tool-call batch that was
55    /// repeated beyond [`AgentConfig::max_repeated_batch_steps`].
56    #[error("tool-call loop detected (repeated signature: {signature})")]
57    LoopDetected {
58        /// Stable hash of the repeated tool-call batch (for diagnostics).
59        signature: String,
60    },
61
62    /// The LLM produced more tool calls in a single batch than configured by
63    /// [`AgentConfig::max_parallel_tools`].
64    ///
65    /// This is a model protocol violation: the LLM returned more concurrent
66    /// calls than the loop is configured to dispatch.  The loop aborts rather
67    /// than silently truncating the batch, because partial execution could
68    /// leave the model with an incoherent view of which calls were handled.
69    #[error("LLM requested {count} tool calls in one batch, exceeds max_parallel_tools ({limit})")]
70    ParallelToolLimitExceeded {
71        /// Number of tool calls the LLM returned.
72        count: usize,
73        /// The configured maximum ([`AgentConfig::max_parallel_tools`]).
74        limit: usize,
75    },
76
77    /// The assistant produced the same text content for too many consecutive
78    /// iterations, indicating a non-tool-calling repetition loop.
79    ///
80    /// Preserves the FNV-1a hash of the repeated text, the total session-wide
81    /// occurrence count (including baseline), and the configured
82    /// `max_stagnation_steps` limit — giving callers structured access to the
83    /// stagnation evidence without parsing an error string.
84    ///
85    /// Detection is session-wide: both strictly consecutive repetitions and
86    /// A → B → A oscillations are caught.
87    #[error(
88        "agent stagnated: same response text seen {count} time(s) in this session \
89         (max_stagnation_steps = {max_steps})"
90    )]
91    StagnationDetected {
92        /// FNV-1a hash of the repeated assistant text, hex-encoded for diagnostics.
93        /// Stored as `String` to decouple the public API from the internal u64
94        /// representation so callers never need to know the hashing algorithm.
95        repeated_text_hash: String,
96        /// Total number of times this text has been seen in the session
97        /// (including the baseline occurrence).
98        count: usize,
99        /// The configured stagnation limit at the time of detection.
100        max_steps: usize,
101    },
102
103    /// An unrecoverable internal error inside the loop implementation.
104    #[error("internal agent error: {0}")]
105    Internal(String),
106}
107
108// =============================================================================
109// AgentRunOutput — structured return value for a successful run
110// =============================================================================
111
112/// Output returned by a successful [`AgentLoopPort::run`] invocation.
113///
114/// Using a named struct instead of a bare tuple keeps call sites
115/// self-documenting and allows new fields to be added without breaking
116/// existing destructures.
117#[derive(Debug)]
118pub struct AgentRunOutput {
119    /// The final answer text produced by the agent.
120    pub answer: String,
121    /// Full accumulated conversation history: the caller-supplied messages
122    /// **plus** every assistant and tool-result message appended during the
123    /// loop, including the final assistant reply.
124    ///
125    /// Safe to pass directly as the `messages` argument for the next turn.
126    pub history: Vec<AgentMessage>,
127    /// Number of loop iterations consumed before the agent produced its final
128    /// answer.  Always ≥ 1.  Useful for logging and telemetry.
129    pub total_iterations: usize,
130}
131
132// =============================================================================
133// ToolExecutorPort
134// =============================================================================
135
136/// Port: dispatches tool calls to the underlying execution backend.
137///
138/// # Implementing this trait
139///
140/// ```ignore
141/// use gglib_core::ports::{AgentError, ToolExecutorPort};
142/// use gglib_core::domain::{ToolCall, ToolDefinition, ToolResult};
143///
144/// struct McpToolExecutor { /* ... */ }
145///
146/// #[async_trait::async_trait]
147/// impl ToolExecutorPort for McpToolExecutor {
148///     async fn list_tools(&self) -> Vec<ToolDefinition> { /* ... */ }
149///
150///     async fn execute(&self, call: &ToolCall) -> Result<ToolResult, anyhow::Error> {
151///         // Call the MCP client; convert McpToolResult → ToolResult.
152///         // Return Err(_) only if the infrastructure itself is unavailable.
153///     }
154/// }
155/// ```
156///
157/// # Error contract
158///
159/// - Returns `Ok(ToolResult { success: false, .. })` when the tool ran but
160///   produced an application-level error (wrong args, resource not found, etc.).
161///   The loop implementation **must** feed this back to the LLM as context.
162/// - Returns `Err(anyhow::Error)` only when the executor infrastructure is
163///   unavailable (e.g. MCP process died, network unreachable).  The loop
164///   implementation converts this into `ToolResult { success: false, content:
165///   "executor unavailable: …" }` so the LLM still receives context.
166#[async_trait]
167pub trait ToolExecutorPort: Send + Sync {
168    /// Return all tool definitions available in this executor.
169    ///
170    /// Called once per agent `run` invocation to build the tool list sent to
171    /// the LLM.
172    async fn list_tools(&self) -> Vec<ToolDefinition>;
173
174    /// Execute a single tool call.
175    ///
176    /// Returns `Err` only for infrastructure failures (see error contract above).
177    async fn execute(&self, call: &ToolCall) -> Result<ToolResult, anyhow::Error>;
178}
179
180// =============================================================================
181// AgentLoopPort
182// =============================================================================
183
184/// Port: drives the full backend agentic loop.
185///
186/// # Usage
187///
188/// ```ignore
189/// use tokio::sync::mpsc;
190/// use gglib_core::ports::AgentLoopPort;
191/// use gglib_core::domain::{AgentConfig, AgentEvent, AgentMessage};
192///
193/// async fn run_loop(agent: &dyn AgentLoopPort) {
194///     let (tx, mut rx) = mpsc::channel::<AgentEvent>(64);
195///
196///     // Spawn a task to consume the event stream (e.g. SSE or logging).
197///     tokio::spawn(async move {
198///         while let Some(event) = rx.recv().await {
199///             println!("{:?}", event);
200///         }
201///         // rx.recv() returns None when tx is dropped (loop ended).
202///     });
203///
204///     let messages = vec![AgentMessage::User { content: "Hello".into() }];
205///     let output = agent.run(messages, AgentConfig::default(), tx).await?;
206///     println!("Final: {}", output.answer);
207///     // `history` contains the full accumulated message list including all
208///     // assistant and tool-result messages appended during the loop — safe
209///     // to pass directly as the `messages` argument for the next turn.
210/// }
211/// ```
212///
213/// # Channel ownership and stream termination
214///
215/// `events` is taken **by value**. When `run` returns (whether `Ok` or `Err`)
216/// the `Sender` is dropped, which closes the channel and signals `None` to
217/// the `Receiver`. Axum SSE handlers and CLI consumers can rely on this to
218/// know the stream has ended without needing an explicit sentinel event.
219#[async_trait]
220pub trait AgentLoopPort: Send + Sync {
221    /// Execute the agentic loop and return the final answer.
222    ///
223    /// # Parameters
224    ///
225    /// * `messages` — The initial conversation history (system prompt + user
226    ///   message at minimum).
227    /// * `config` — Loop control parameters (iteration limits, timeouts, etc.).
228    /// * `tx` — Async channel over which the loop streams [`AgentEvent`]s.
229    ///   Taken by value; dropped on completion to close the SSE stream.
230    ///
231    /// # Returns
232    ///
233    /// * `Ok(AgentRunOutput)` — The final answer and full accumulated message
234    ///   history (safe to pass back as `messages` on the next turn).
235    /// * `Err(AgentError)` — A fatal loop-level failure (max iterations reached,
236    ///   loop detection, stagnation, or internal error).  No partial history is
237    ///   returned on failure; the caller's existing history is left intact.
238    async fn run(
239        &self,
240        messages: Vec<AgentMessage>,
241        config: AgentConfig,
242        tx: mpsc::Sender<AgentEvent>,
243    ) -> Result<AgentRunOutput, AgentError>;
244}