Skip to main content

agentyk_engine/
agent.rs

1//! Agent — a composed value, not a record.
2//!
3//! An [`Agent`] is name + system prompt + model + capabilities, built with
4//! [`AgentBuilder`] entirely from values. There is no id to mint, nothing to
5//! register, and no store to create it in. Sessions are spawned *from* the
6//! agent; ids exist only inside them as correlation handles.
7//!
8//! Composition lives in one place: the builder produces a valid
9//! [`AgentDefinition`] and [`AgentEnvironment`], which every
10//! [`TurnHost`](crate::host::TurnHost) consumes directly.
11
12use std::sync::Arc;
13
14use agentyk_core::budget::BudgetChecker;
15use agentyk_core::capability::Capability;
16use agentyk_core::context::ContextAssembler;
17use agentyk_core::driver::{ChatDriver, ModelSpec};
18use agentyk_core::error::{Error, Result};
19use agentyk_core::event::EventListener;
20use agentyk_core::event_log::{EventLog, InMemoryEventLog};
21use agentyk_core::hook::Hook;
22use agentyk_core::id::SessionId;
23use agentyk_core::message::Message;
24use agentyk_core::middleware::TurnMiddleware;
25use agentyk_core::profile::{ModelCatalog, ModelProfile};
26use agentyk_core::tool::Tool;
27use async_trait::async_trait;
28
29use crate::config::AgentConfig;
30use crate::host::TurnResult;
31use crate::session::Session;
32
33/// Agent behavior, separate from the resources that run it.
34///
35/// The model may contain credentials, so this value is sensitive
36/// configuration even though durable events never include it.
37#[derive(Clone)]
38pub struct AgentDefinition {
39    /// Human-readable diagnostic name.
40    pub name: String,
41    /// Base system instructions.
42    pub instructions: String,
43    /// Default model, required for every built definition.
44    pub model: ModelSpec,
45    /// Behavior attached by value.
46    pub capabilities: Vec<Arc<dyn Capability>>,
47    /// Maximum reason steps in one turn.
48    pub max_iterations: usize,
49    /// Ordered turn policy.
50    pub middleware: Vec<Arc<dyn TurnMiddleware>>,
51    /// Ordered user hooks at session, turn, and tool lifecycle points.
52    pub hooks: Vec<Arc<dyn Hook>>,
53    /// Optional budget policy.
54    pub budget_checker: Option<Arc<dyn BudgetChecker>>,
55    /// Shapes durable history into model context.
56    pub context_assembler: Arc<dyn ContextAssembler>,
57    /// What this agent's model supports, when the host said — see
58    /// [`AgentBuilder::model_catalog`]. Read it to size a context budget or
59    /// to show a user what the model can do; the builder has already used it
60    /// to validate the composition.
61    pub model_profile: Option<ModelProfile>,
62    /// Optional input-token ceiling exposed to context assembly.
63    pub context_token_limit: Option<usize>,
64}
65
66impl std::fmt::Debug for AgentDefinition {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("AgentDefinition")
69            .field("name", &self.name)
70            .field("model", &self.model)
71            .field("capabilities", &self.capabilities.len())
72            .field("max_iterations", &self.max_iterations)
73            .finish_non_exhaustive()
74    }
75}
76
77/// Host resources used to execute an [`AgentDefinition`].
78#[derive(Clone)]
79pub struct AgentEnvironment {
80    /// Provider implementations available to the host.
81    pub drivers: agentyk_core::driver::DriverRegistry,
82    /// Observers of durable and ephemeral events.
83    pub listeners: Vec<Arc<dyn EventListener>>,
84    /// Typed services made available to tools.
85    pub extensions: agentyk_core::extensions::Extensions,
86}
87
88impl std::fmt::Debug for AgentEnvironment {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.debug_struct("AgentEnvironment")
91            .field("listeners", &self.listeners.len())
92            .finish_non_exhaustive()
93    }
94}
95
96/// A runnable agent, composed by value. Cheap to clone (shared internals).
97#[derive(Clone)]
98pub struct Agent {
99    definition: Arc<AgentDefinition>,
100    environment: Arc<AgentEnvironment>,
101}
102
103impl std::fmt::Debug for Agent {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("Agent")
106            .field("definition", &self.definition)
107            .field("environment", &self.environment)
108            .finish_non_exhaustive()
109    }
110}
111
112impl Agent {
113    /// Start composing an agent.
114    pub fn builder() -> AgentBuilder {
115        AgentBuilder::new()
116    }
117
118    /// Portable definition of this agent.
119    pub fn definition(&self) -> &AgentDefinition {
120        &self.definition
121    }
122
123    /// Host resources used to run this agent.
124    pub fn environment(&self) -> &AgentEnvironment {
125        &self.environment
126    }
127
128    /// The agent's name, for logs and diagnostics.
129    pub fn name(&self) -> &str {
130        &self.definition.name
131    }
132
133    /// The agent's default model. Per-turn overrides go through
134    /// [`TurnControls`](agentyk_core::controls::TurnControls).
135    pub fn model(&self) -> &ModelSpec {
136        &self.definition.model
137    }
138
139    /// What the attached catalog says this agent's model supports, if
140    /// anything — see [`AgentBuilder::model_catalog`].
141    pub fn model_profile(&self) -> Option<&ModelProfile> {
142        self.definition.model_profile.as_ref()
143    }
144
145    /// Everything attached to this agent, in attachment order.
146    pub fn capabilities(&self) -> &[Arc<dyn Capability>] {
147        &self.definition.capabilities
148    }
149
150    /// The driver that speaks this agent's model protocol. Guaranteed by
151    /// `build()` to exist.
152    pub fn driver_for_model(&self) -> Option<Arc<dyn ChatDriver>> {
153        self.environment.drivers.get(&self.definition.model.driver)
154    }
155
156    /// Start a session with an in-memory event log.
157    pub fn session(&self) -> Session {
158        self.session_with_log(Arc::new(InMemoryEventLog::new()))
159    }
160
161    /// Start a session writing to the given event log.
162    pub fn session_with_log(&self, log: Arc<dyn EventLog>) -> Session {
163        Session::new(self.clone(), log)
164    }
165
166    /// Rebuild a session's message history by replaying its event log.
167    pub async fn resume_session(
168        &self,
169        log: Arc<dyn EventLog>,
170        session_id: SessionId,
171    ) -> Result<Session> {
172        Session::resume(self.clone(), log, session_id).await
173    }
174
175    /// One-shot convenience: run a single turn in a throwaway session.
176    pub async fn run(&self, input: impl Into<Message>) -> Result<TurnResult> {
177        self.session().run(input).await
178    }
179}
180
181/// Capability wrapping tools attached directly on the builder via
182/// [`AgentBuilder::tool`].
183struct AdHocTools {
184    tools: Vec<Arc<dyn Tool>>,
185}
186
187#[async_trait]
188impl Capability for AdHocTools {
189    fn id(&self) -> &str {
190        "tools"
191    }
192
193    fn description(&self) -> &str {
194        "Tools attached directly to the agent."
195    }
196
197    async fn tools(&self) -> Result<Vec<Arc<dyn Tool>>> {
198        Ok(self.tools.clone())
199    }
200}
201
202/// Fluent, by-value agent composition.
203pub struct AgentBuilder {
204    config: AgentConfig,
205    /// Staged until `build()` wraps them in an implicit capability.
206    tools: Vec<Arc<dyn Tool>>,
207}
208
209impl AgentBuilder {
210    /// A builder with defaults for everything but the model, which is
211    /// required.
212    pub fn new() -> Self {
213        Self {
214            config: AgentConfig::default(),
215            tools: Vec::new(),
216        }
217    }
218
219    /// Name the agent, for logs and diagnostics. Defaults to `"agent"`.
220    pub fn name(mut self, name: impl Into<String>) -> Self {
221        self.config.name = name.into();
222        self
223    }
224
225    /// The agent's own instructions. Capability contributions are appended
226    /// to this when a turn assembles its prompt.
227    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
228        self.config.system_prompt = prompt.into();
229        self
230    }
231
232    /// The model, by value — see [`ModelSpec`]. Required.
233    pub fn model(mut self, model: ModelSpec) -> Self {
234        self.config.model = Some(model);
235        self
236    }
237
238    /// Attach a capability by object — no registry, no string reference.
239    pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
240        self.config.capabilities.push(Arc::new(capability));
241        self
242    }
243
244    /// Attach a capability you already hold as an `Arc` — the way to share
245    /// one between agents, or keep a handle on its state.
246    pub fn capability_arc(mut self, capability: Arc<dyn Capability>) -> Self {
247        self.config.capabilities.push(capability);
248        self
249    }
250
251    /// Attach a single tool directly (wrapped in an implicit capability).
252    pub fn tool(mut self, tool: impl Tool + 'static) -> Self {
253        self.tools.push(Arc::new(tool));
254        self
255    }
256
257    /// Register an extra or replacement LLM driver.
258    pub fn driver(mut self, driver: impl ChatDriver + 'static) -> Self {
259        self.config.drivers.register(driver);
260        self
261    }
262
263    /// Observe every event this agent's sessions emit. Listeners cannot
264    /// change a turn — see [`AgentBuilder::middleware`] for that.
265    pub fn listener(mut self, listener: impl EventListener + 'static) -> Self {
266        self.config.listeners.push(Arc::new(listener));
267        self
268    }
269
270    /// Attach a listener you already hold as an `Arc` — the way to keep a
271    /// handle to inspect its state (e.g. a test collector) after the agent
272    /// is built.
273    pub fn listener_arc(mut self, listener: Arc<dyn EventListener>) -> Self {
274        self.config.listeners.push(listener);
275        self
276    }
277
278    /// Ceiling on reason/act iterations within one turn (default: 16).
279    pub fn max_iterations(mut self, max_iterations: usize) -> Self {
280        self.config.max_iterations = max_iterations;
281        self
282    }
283
284    /// Intercept points inside the turn loop, in attachment order — deny a
285    /// tool call, rewrite it before it runs, or transform its output. What
286    /// approval gating, guardrails, and redaction are built on; see
287    /// [`agentyk_core::middleware::TurnMiddleware`].
288    pub fn middleware(mut self, middleware: impl TurnMiddleware + 'static) -> Self {
289        self.config.middleware.push(Arc::new(middleware));
290        self
291    }
292
293    /// Attach middleware you already hold as an `Arc` — the way to keep a
294    /// handle on it (e.g. an approver whose state a test inspects).
295    pub fn middleware_arc(mut self, middleware: Arc<dyn TurnMiddleware>) -> Self {
296        self.config.middleware.push(middleware);
297        self
298    }
299
300    /// Attach a user hook by value.
301    ///
302    /// Hooks cover the same six lifecycle events as Everruns: session
303    /// start/end, prompt submission, pre/post tool use, and turn end. They
304    /// compose in attachment order within each event.
305    pub fn hook(mut self, hook: impl Hook + 'static) -> Self {
306        self.config.hooks.push(Arc::new(hook));
307        self
308    }
309
310    /// Attach a user hook already held behind an [`Arc`].
311    pub fn hook_arc(mut self, hook: Arc<dyn Hook>) -> Self {
312        self.config.hooks.push(hook);
313        self
314    }
315
316    /// Stop a turn deliberately rather than letting it run to
317    /// `MaxIterations` — checked once per action, like cancellation. See
318    /// [`agentyk_core::budget::BudgetChecker`]. No default policy: unset
319    /// means a turn never seals for budget reasons.
320    pub fn budget_checker(mut self, checker: impl BudgetChecker + 'static) -> Self {
321        self.config.budget_checker = Some(Arc::new(checker));
322        self
323    }
324
325    /// Teach the builder what models support, so a composition the provider
326    /// would reject fails here instead — see
327    /// [`agentyk_core::profile::ModelCatalog`].
328    ///
329    /// Nothing is baked in: agentyk ships no model list, because a list in a
330    /// library is stale by its next release and a wrong entry is worse than a
331    /// missing one. A model the catalog does not know is passed through
332    /// untouched, so attaching one can only reject combinations you described.
333    ///
334    /// A catalog states what a model takes, and `build()` refuses a model
335    /// spec that contradicts it — `Error::InvalidAgent`, naming the supported
336    /// values, before a turn ever runs:
337    ///
338    /// ```
339    /// use agentyk_core::{DriverId, InMemoryModelCatalog, ModelCatalog, ModelProfile, ModelSpec};
340    ///
341    /// let catalog = InMemoryModelCatalog::new().with(
342    ///     DriverId::openai(),
343    ///     "gpt-5.5",
344    ///     ModelProfile::new().reasoning_efforts(["low", "high"]),
345    /// );
346    ///
347    /// let profile = catalog.profile(&DriverId::openai(), "gpt-5.5").unwrap();
348    /// let error = profile
349    ///     .validate(&ModelSpec::openai("gpt-5.5").reasoning_effort("medium"))
350    ///     .unwrap_err();
351    /// assert!(error.contains("low, high"));
352    /// ```
353    pub fn model_catalog(mut self, catalog: impl ModelCatalog + 'static) -> Self {
354        self.config.model_catalog = Some(Arc::new(catalog));
355        self
356    }
357
358    /// Attach a catalog you already hold as an `Arc` — the way to share one
359    /// across agents.
360    pub fn model_catalog_arc(mut self, catalog: Arc<dyn ModelCatalog>) -> Self {
361        self.config.model_catalog = Some(catalog);
362        self
363    }
364
365    /// Replace how replayed history is turned into what's sent to the model
366    /// each turn (default: send it unchanged) — trimming, compaction, and
367    /// memory injection are implementations of this seam, not changes to
368    /// the turn machine. See [`agentyk_core::context::ContextAssembler`].
369    pub fn context_assembler(mut self, assembler: impl ContextAssembler + 'static) -> Self {
370        self.config.context_assembler = Arc::new(assembler);
371        self
372    }
373
374    /// Tell context assembly the maximum input-token budget for each model
375    /// call. The assembler owns estimation and reduction; the engine does not
376    /// silently trim messages.
377    ///
378    /// Left unset, an attached [`AgentBuilder::model_catalog`] can supply it:
379    /// a profile stating **both** a context window and an output ceiling
380    /// implies the input budget (window minus output). Only both, because
381    /// with just the window there is no defensible number — reserving room
382    /// for the response would mean inventing a figure the host never stated.
383    pub fn context_token_limit(mut self, token_limit: usize) -> Self {
384        self.config.context_token_limit = Some(token_limit);
385        self
386    }
387
388    /// Make a service available to every tool call via
389    /// [`agentyk_core::tool::ToolContext::extensions`] — a credential
390    /// store, a workspace handle, anything a tool needs but core shouldn't
391    /// know the shape of. Replaces any previous value of the same type.
392    pub fn extension<T: Send + Sync + 'static>(mut self, value: T) -> Self {
393        self.config.extensions.insert(value);
394        self
395    }
396
397    /// Finish composing.
398    ///
399    /// Fails if no model was set, or if no driver is registered for the one
400    /// that was — both are wiring mistakes worth catching before a turn runs
401    /// rather than during one.
402    pub fn build(self) -> Result<Agent> {
403        let mut config = self.config;
404
405        if config.model.is_none() {
406            return Err(Error::InvalidAgent(
407                "a model is required — call .model(...)".into(),
408            ));
409        }
410
411        if !self.tools.is_empty() {
412            config
413                .capabilities
414                .push(Arc::new(AdHocTools { tools: self.tools }));
415        }
416
417        if !config.drivers.contains(&config.model().driver) {
418            return Err(Error::UnknownDriver(config.model().driver.to_string()));
419        }
420
421        for hook in &config.hooks {
422            if let Some(matcher) = hook.matcher() {
423                if !hook.event().supports_matcher() {
424                    return Err(Error::InvalidAgent(format!(
425                        "hook `{}` has a matcher on non-tool event `{}`",
426                        hook.id(),
427                        hook.event().as_str()
428                    )));
429                }
430                matcher.validate().map_err(|error| {
431                    Error::InvalidAgent(format!("hook `{}`: {error}", hook.id()))
432                })?;
433            }
434        }
435
436        // Validated here rather than at the driver, so an unsupported
437        // reasoning effort is a composition error with a message naming the
438        // alternatives — not a provider 400 halfway through someone's turn.
439        let model_profile = config
440            .model_catalog
441            .as_ref()
442            .and_then(|catalog| catalog.profile(&config.model().driver, &config.model().model));
443        if let Some(profile) = &model_profile
444            && let Err(reason) = profile.validate(config.model())
445        {
446            return Err(Error::InvalidAgent(reason));
447        }
448
449        // A stated window and output ceiling are the input budget, so a host
450        // that described its model does not also have to compute this.
451        let context_token_limit = config.context_token_limit.or_else(|| {
452            let profile = model_profile.as_ref()?;
453            let window = usize::try_from(profile.context_window?).ok()?;
454            let output = usize::try_from(profile.max_output_tokens?).ok()?;
455            window.checked_sub(output)
456        });
457
458        let definition = AgentDefinition {
459            name: config.name.clone(),
460            instructions: config.system_prompt.clone(),
461            model: config.model().clone(),
462            capabilities: config.capabilities.clone(),
463            max_iterations: config.max_iterations,
464            middleware: config.middleware.clone(),
465            hooks: config.hooks.clone(),
466            budget_checker: config.budget_checker.clone(),
467            context_assembler: config.context_assembler.clone(),
468            model_profile,
469            context_token_limit,
470        };
471        let environment = AgentEnvironment {
472            drivers: config.drivers.clone(),
473            listeners: config.listeners.clone(),
474            extensions: config.extensions.clone(),
475        };
476        Ok(Agent {
477            definition: Arc::new(definition),
478            environment: Arc::new(environment),
479        })
480    }
481}
482
483impl Default for AgentBuilder {
484    fn default() -> Self {
485        Self::new()
486    }
487}