Skip to main content

synwire_core/agents/
agent_node.rs

1//! Agent node trait, builder, and execution context.
2//!
3//! `AgentNode` is the primary abstraction for a runnable agent.  The `Agent<D>`
4//! builder configures all agent parameters and implements `AgentNode`.
5
6use std::collections::HashMap;
7use std::pin::Pin;
8
9use futures_core::Stream;
10use serde::Serialize;
11use serde_json::Value;
12
13use crate::BoxFuture;
14use crate::agents::error::AgentError;
15use crate::agents::hooks::HookRegistry;
16use crate::agents::middleware::MiddlewareStack;
17use crate::agents::model_info::{EffortLevel, ThinkingConfig};
18use crate::agents::output_mode::SystemPromptConfig;
19use crate::agents::permission::{PermissionMode, PermissionRule};
20use crate::agents::plugin::Plugin;
21use crate::agents::sandbox::SandboxConfig;
22use crate::agents::streaming::AgentEvent;
23use crate::tools::Tool;
24use crate::vfs::OutputFormat;
25
26/// Stream of agent events produced during a run.
27pub type AgentEventStream = Pin<Box<dyn Stream<Item = Result<AgentEvent, AgentError>> + Send>>;
28
29// ---------------------------------------------------------------------------
30// AgentNode trait
31// ---------------------------------------------------------------------------
32
33/// A runnable agent that produces a stream of events.
34pub trait AgentNode: Send + Sync {
35    /// Unique agent name (stable identifier for routing and logging).
36    fn name(&self) -> &str;
37
38    /// Human-readable description.
39    fn description(&self) -> &str;
40
41    /// Run the agent, returning a stream of events.
42    ///
43    /// `input` is the initial user message or continuation prompt.
44    /// The stream ends with a `TurnComplete` or `Error` event.
45    fn run(&self, input: Value) -> BoxFuture<'_, Result<AgentEventStream, AgentError>>;
46
47    /// Returns the names of agents this node may spawn as sub-agents.
48    fn sub_agents(&self) -> Vec<String> {
49        Vec::new()
50    }
51}
52
53// ---------------------------------------------------------------------------
54// OutputMode
55// ---------------------------------------------------------------------------
56
57/// Configures how the agent extracts structured output from the model response.
58#[derive(Debug, Clone, Default)]
59#[non_exhaustive]
60pub enum OutputMode {
61    /// Structured output via tool call (most reliable).
62    #[default]
63    Tool,
64    /// Native JSON mode (provider must support it).
65    Native,
66    /// Post-process raw text response via prompt.
67    Prompt,
68    /// Custom extraction via user-supplied function.
69    Custom,
70}
71
72// ---------------------------------------------------------------------------
73// RunContext
74// ---------------------------------------------------------------------------
75
76/// Runtime context made available to agent execution.
77#[derive(Debug)]
78pub struct RunContext {
79    /// Session identifier.
80    pub session_id: Option<String>,
81    /// Model name resolved for this run.
82    pub model: String,
83    /// Retry count for the current turn (0 = first attempt).
84    pub retry_count: u32,
85    /// Cumulative cost so far in this session (USD).
86    pub cumulative_cost_usd: f64,
87    /// Arbitrary metadata attached at call site.
88    pub metadata: HashMap<String, Value>,
89}
90
91impl RunContext {
92    /// Create a new context with default values.
93    #[must_use]
94    pub fn new(model: impl Into<String>) -> Self {
95        Self {
96            session_id: None,
97            model: model.into(),
98            retry_count: 0,
99            cumulative_cost_usd: 0.0,
100            metadata: HashMap::new(),
101        }
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Callbacks
107// ---------------------------------------------------------------------------
108
109/// Called immediately before the agent starts processing a turn.
110pub type BeforeAgentCallback =
111    Box<dyn Fn(&RunContext) -> BoxFuture<'static, Result<(), AgentError>> + Send + Sync>;
112
113/// Called after the agent completes a turn (success or failure).
114pub type AfterAgentCallback =
115    Box<dyn Fn(&RunContext, &Result<(), AgentError>) -> BoxFuture<'static, ()> + Send + Sync>;
116
117/// Called when a model error occurs, allowing custom recovery logic.
118pub type OnModelErrorCallback =
119    Box<dyn Fn(&RunContext, &AgentError) -> BoxFuture<'static, ModelErrorAction> + Send + Sync>;
120
121/// Recovery action returned by `OnModelErrorCallback`.
122#[derive(Debug, Clone)]
123#[non_exhaustive]
124pub enum ModelErrorAction {
125    /// Retry the current request unchanged.
126    Retry,
127    /// Abort the run with the given error.
128    Abort(String),
129    /// Switch to a different model and retry.
130    SwitchModel(String),
131}
132
133// ---------------------------------------------------------------------------
134// Agent builder
135// ---------------------------------------------------------------------------
136
137/// Builder for configuring and constructing a runnable agent.
138///
139/// Type parameter `O` is the optional structured output type (use `()` for
140/// unstructured text).
141#[derive(Default)]
142#[allow(clippy::struct_field_names)]
143pub struct Agent<O: Serialize + Send + Sync + 'static = ()> {
144    // Identity
145    name: String,
146    description: String,
147
148    // Model
149    model: String,
150    fallback_model: Option<String>,
151    effort: Option<EffortLevel>,
152    thinking: Option<ThinkingConfig>,
153
154    // Tools
155    tools: Vec<Box<dyn Tool>>,
156    allowed_tools: Option<Vec<String>>,
157    excluded_tools: Vec<String>,
158
159    // Plugins
160    plugins: Vec<Box<dyn Plugin>>,
161
162    // Middleware
163    middleware: MiddlewareStack,
164
165    // Hooks
166    hooks: HookRegistry,
167
168    // Output
169    output_mode: OutputMode,
170    output_schema: Option<Value>,
171
172    // Tool output serialization — controls how VFS / tool results are
173    // formatted before being passed back to the LLM.
174    tool_output_format: OutputFormat,
175
176    // Limits
177    max_turns: Option<u32>,
178    max_budget: Option<f64>,
179
180    // System prompt
181    system_prompt: Option<SystemPromptConfig>,
182
183    // Permissions
184    permission_mode: PermissionMode,
185    permission_rules: Vec<PermissionRule>,
186
187    // Sandbox
188    sandbox: Option<SandboxConfig>,
189
190    // Environment
191    env: HashMap<String, String>,
192    cwd: Option<String>,
193
194    // Debug
195    debug: bool,
196    debug_file: Option<String>,
197
198    // MCP
199    mcp_servers: Vec<String>,
200
201    // Callbacks
202    before_agent: Option<BeforeAgentCallback>,
203    after_agent: Option<AfterAgentCallback>,
204    on_model_error: Option<OnModelErrorCallback>,
205
206    _output: std::marker::PhantomData<O>,
207}
208
209impl<O: Serialize + Send + Sync + 'static> std::fmt::Debug for Agent<O> {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        f.debug_struct("Agent")
212            .field("name", &self.name)
213            .field("model", &self.model)
214            .field("max_turns", &self.max_turns)
215            .field("max_budget", &self.max_budget)
216            .finish_non_exhaustive()
217    }
218}
219
220impl<O: Serialize + Send + Sync + 'static> Agent<O> {
221    /// Create a new agent builder.
222    #[must_use]
223    pub fn new(name: impl Into<String>, model: impl Into<String>) -> Self {
224        Self {
225            name: name.into(),
226            model: model.into(),
227            ..Self::default()
228        }
229    }
230
231    /// Set a human-readable description.
232    #[must_use]
233    pub fn description(mut self, description: impl Into<String>) -> Self {
234        self.description = description.into();
235        self
236    }
237
238    /// Set the primary model name.
239    #[must_use]
240    pub fn model(mut self, model: impl Into<String>) -> Self {
241        self.model = model.into();
242        self
243    }
244
245    /// Set a fallback model used when the primary is rate-limited or unavailable.
246    #[must_use]
247    pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
248        self.fallback_model = Some(model.into());
249        self
250    }
251
252    /// Set the reasoning effort level.
253    #[must_use]
254    pub const fn effort(mut self, effort: EffortLevel) -> Self {
255        self.effort = Some(effort);
256        self
257    }
258
259    /// Configure extended thinking / chain-of-thought.
260    #[must_use]
261    pub const fn thinking(mut self, thinking: ThinkingConfig) -> Self {
262        self.thinking = Some(thinking);
263        self
264    }
265
266    /// Add a tool available to the agent.
267    #[must_use]
268    pub fn tool(mut self, tool: impl Tool + 'static) -> Self {
269        self.tools.push(Box::new(tool));
270        self
271    }
272
273    /// Restrict the agent to only these tool names (allowlist).
274    #[must_use]
275    pub fn allowed_tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
276        self.allowed_tools = Some(tools.into_iter().map(Into::into).collect());
277        self
278    }
279
280    /// Exclude specific tools by name (denylist within allowlist).
281    #[must_use]
282    pub fn exclude_tool(mut self, tool_name: impl Into<String>) -> Self {
283        self.excluded_tools.push(tool_name.into());
284        self
285    }
286
287    /// Add a plugin.
288    #[must_use]
289    pub fn plugin(mut self, plugin: impl Plugin + 'static) -> Self {
290        self.plugins.push(Box::new(plugin));
291        self
292    }
293
294    /// Add a middleware component.
295    #[must_use]
296    pub fn middleware(mut self, mw: impl crate::agents::middleware::Middleware + 'static) -> Self {
297        self.middleware.push(mw);
298        self
299    }
300
301    /// Configure hooks.
302    #[must_use]
303    pub fn hooks(mut self, hooks: HookRegistry) -> Self {
304        self.hooks = hooks;
305        self
306    }
307
308    /// Set the structured output mode.
309    #[must_use]
310    pub const fn output_mode(mut self, mode: OutputMode) -> Self {
311        self.output_mode = mode;
312        self
313    }
314
315    /// Provide a JSON Schema for structured output validation.
316    #[must_use]
317    pub fn output_schema(mut self, schema: Value) -> Self {
318        self.output_schema = Some(schema);
319        self
320    }
321
322    /// Set the default serialization format for tool and VFS output.
323    ///
324    /// Controls how structured data is rendered before being passed back
325    /// to the LLM as tool results.  Defaults to [`OutputFormat::Json`].
326    /// Individual tools can override this per-call.
327    #[must_use]
328    pub const fn tool_output_format(mut self, format: OutputFormat) -> Self {
329        self.tool_output_format = format;
330        self
331    }
332
333    /// Returns the configured tool output format.
334    #[must_use]
335    pub const fn get_tool_output_format(&self) -> OutputFormat {
336        self.tool_output_format
337    }
338
339    /// Set the maximum number of agent turns per run.
340    #[must_use]
341    pub const fn max_turns(mut self, turns: u32) -> Self {
342        self.max_turns = Some(turns);
343        self
344    }
345
346    /// Set the maximum cumulative cost budget (USD).
347    #[must_use]
348    pub const fn max_budget(mut self, budget_usd: f64) -> Self {
349        self.max_budget = Some(budget_usd);
350        self
351    }
352
353    /// Configure the system prompt.
354    #[must_use]
355    pub fn system_prompt(mut self, config: SystemPromptConfig) -> Self {
356        self.system_prompt = Some(config);
357        self
358    }
359
360    /// Set the permission mode.
361    #[must_use]
362    pub const fn permission_mode(mut self, mode: PermissionMode) -> Self {
363        self.permission_mode = mode;
364        self
365    }
366
367    /// Add a permission rule.
368    #[must_use]
369    pub fn permission_rule(mut self, rule: PermissionRule) -> Self {
370        self.permission_rules.push(rule);
371        self
372    }
373
374    /// Configure the sandbox.
375    #[must_use]
376    pub fn sandbox(mut self, config: SandboxConfig) -> Self {
377        self.sandbox = Some(config);
378        self
379    }
380
381    /// Set an environment variable available to the agent.
382    #[must_use]
383    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
384        let _ = self.env.insert(key.into(), value.into());
385        self
386    }
387
388    /// Set the working directory for the agent.
389    #[must_use]
390    pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
391        self.cwd = Some(cwd.into());
392        self
393    }
394
395    /// Enable debug mode (verbose logging).
396    #[must_use]
397    pub const fn debug(mut self) -> Self {
398        self.debug = true;
399        self
400    }
401
402    /// Write debug output to a file.
403    #[must_use]
404    pub fn debug_file(mut self, path: impl Into<String>) -> Self {
405        self.debug_file = Some(path.into());
406        self
407    }
408
409    /// Register an MCP server by name.
410    #[must_use]
411    pub fn mcp_server(mut self, server_name: impl Into<String>) -> Self {
412        self.mcp_servers.push(server_name.into());
413        self
414    }
415
416    /// Register a before-agent callback.
417    #[must_use]
418    pub fn before_agent<F>(mut self, f: F) -> Self
419    where
420        F: Fn(&RunContext) -> BoxFuture<'static, Result<(), AgentError>> + Send + Sync + 'static,
421    {
422        self.before_agent = Some(Box::new(f));
423        self
424    }
425
426    /// Register an after-agent callback.
427    #[must_use]
428    pub fn after_agent<F>(mut self, f: F) -> Self
429    where
430        F: Fn(&RunContext, &Result<(), AgentError>) -> BoxFuture<'static, ()>
431            + Send
432            + Sync
433            + 'static,
434    {
435        self.after_agent = Some(Box::new(f));
436        self
437    }
438
439    /// Register a model error callback.
440    #[must_use]
441    pub fn on_model_error<F>(mut self, f: F) -> Self
442    where
443        F: Fn(&RunContext, &AgentError) -> BoxFuture<'static, ModelErrorAction>
444            + Send
445            + Sync
446            + 'static,
447    {
448        self.on_model_error = Some(Box::new(f));
449        self
450    }
451
452    // --- Accessors (for the runner) ---
453
454    /// Agent name.
455    #[must_use]
456    pub fn agent_name(&self) -> &str {
457        &self.name
458    }
459
460    /// Agent description.
461    #[must_use]
462    pub fn agent_description(&self) -> &str {
463        &self.description
464    }
465
466    /// Primary model identifier.
467    #[must_use]
468    pub fn model_name(&self) -> &str {
469        &self.model
470    }
471
472    /// Fallback model identifier, if configured.
473    #[must_use]
474    pub fn fallback_model_name(&self) -> Option<&str> {
475        self.fallback_model.as_deref()
476    }
477
478    /// Maximum turns, if set.
479    #[must_use]
480    pub const fn max_turn_count(&self) -> Option<u32> {
481        self.max_turns
482    }
483
484    /// Maximum budget (USD), if set.
485    #[must_use]
486    pub const fn budget_limit(&self) -> Option<f64> {
487        self.max_budget
488    }
489
490    /// Whether debug mode is enabled.
491    #[must_use]
492    pub const fn is_debug(&self) -> bool {
493        self.debug
494    }
495}
496
497impl<O: Serialize + Send + Sync + 'static> Agent<O> {
498    fn default() -> Self {
499        Self {
500            name: String::new(),
501            description: String::new(),
502            model: String::new(),
503            fallback_model: None,
504            effort: None,
505            thinking: None,
506            tools: Vec::new(),
507            allowed_tools: None,
508            excluded_tools: Vec::new(),
509            plugins: Vec::new(),
510            middleware: MiddlewareStack::new(),
511            hooks: HookRegistry::new(),
512            output_mode: OutputMode::default(),
513            output_schema: None,
514            tool_output_format: OutputFormat::Json,
515            max_turns: None,
516            max_budget: None,
517            system_prompt: None,
518            permission_mode: PermissionMode::default(),
519            permission_rules: Vec::new(),
520            sandbox: None,
521            env: HashMap::new(),
522            cwd: None,
523            debug: false,
524            debug_file: None,
525            mcp_servers: Vec::new(),
526            before_agent: None,
527            after_agent: None,
528            on_model_error: None,
529            _output: std::marker::PhantomData,
530        }
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn test_builder_fields() {
540        let agent: Agent = Agent::new("my-agent", "claude-3-5-sonnet")
541            .description("Test agent")
542            .max_turns(10)
543            .max_budget(1.0)
544            .fallback_model("claude-3-haiku");
545
546        assert_eq!(agent.agent_name(), "my-agent");
547        assert_eq!(agent.model_name(), "claude-3-5-sonnet");
548        assert_eq!(agent.max_turn_count(), Some(10));
549        assert_eq!(agent.budget_limit(), Some(1.0));
550        assert_eq!(agent.fallback_model_name(), Some("claude-3-haiku"));
551    }
552}