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}