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}