Skip to main content

agent_core/
lib.rs

1//! `agent-core` — the unified agent runtime.
2//!
3//! It wraps the codex harness-style agent loop and guarantees the **memo
4//! contract**: every [`Agent::run`] first [`recall`](agent_memo::MemoStore::recall)s
5//! context from memo (now vector/semantic recall — see `agent_memo::embed`), then
6//! after producing a reply [`memorize`](agent_memo::MemoStore::memorize)s
7//! both the user turn and the assistant reply. Tool execution is isolated in a
8//! [`Sandbox`](agent_sandbox::Sandbox).
9//!
10//! The LLM is accessed through [`ModelClient`]; a deterministic [`StubModel`]
11//! is the default so the runtime is runnable without an API key. Enable the
12//! `openai` feature to use the real OpenAI Responses/Chat API.
13
14use agent_memo::{ContextFragment, FragmentKind, MemoStore, RecallQuery, SledMemoStore};
15use agent_sandbox::{default_sandbox, Sandbox, SandboxProvider};
16use async_trait::async_trait;
17use futures::stream::{BoxStream, StreamExt};
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::sync::{Arc, RwLock};
21use thiserror::Error;
22
23#[derive(Debug, Error)]
24pub enum CoreError {
25    #[error("memo error: {0}")]
26    Memo(#[from] agent_memo::MemoError),
27    #[error("sandbox error: {0}")]
28    Sandbox(#[from] agent_sandbox::SandboxError),
29    #[error("model error: {0}")]
30    Model(String),
31    #[error("config error: {0}")]
32    Config(String),
33}
34
35/// A single model request.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ModelRequest {
38    pub system: String,
39    pub context: String,
40    pub input: String,
41}
42
43/// A single model response.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ModelResponse {
46    pub text: String,
47}
48
49/// A tool the agent may invoke during an agentic turn.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct Tool {
52    pub name: String,
53    pub description: String,
54    /// JSON-schema object describing the tool's parameters.
55    pub parameters: Value,
56}
57
58/// A request from the model to invoke a tool.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct ToolCall {
61    pub id: String,
62    pub name: String,
63    /// Parsed tool arguments (typically a JSON object).
64    pub arguments: Value,
65}
66
67/// The outcome of executing a tool, fed back to the model.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub struct ToolResult {
70    pub call_id: String,
71    pub content: String,
72    pub is_error: bool,
73}
74
75/// A model reply that may request one or more tool calls (agentic loop).
76#[derive(Debug, Clone, Default)]
77pub struct ModelTurn {
78    pub text: String,
79    pub tool_calls: Vec<ToolCall>,
80}
81
82/// Events emitted by [`Agent::run_event_stream`] so callers can render a live
83/// agentic turn: phase boundaries, tool calls (with their results), streamed
84/// tokens, and the terminal `Done`.
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum AgentEvent {
88    /// A phase boundary: `recall`, `model`, `tool_exec`, `loop_guard`.
89    Step {
90        phase: String,
91        label: Option<String>,
92    },
93    /// A tool invocation and its (already executed) result.
94    ToolCall {
95        id: String,
96        name: String,
97        arguments: Value,
98        result: ToolResult,
99    },
100    /// A model text delta.
101    Token { text: String },
102    /// Terminal event carrying the full final reply.
103    Done { text: String },
104}
105
106/// LLM access seam. Implement this to plug in any backend.
107#[async_trait]
108pub trait ModelClient: Send + Sync {
109    /// Produce a complete reply in one shot.
110    async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError>;
111
112    /// Stream the reply as a sequence of tokens. The default implementation
113    /// yields the full [`ModelResponse::text`] as a single chunk, so backends
114    /// that only support non-streaming calls work unchanged.
115    async fn stream(
116        &self,
117        req: &ModelRequest,
118    ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
119        let resp = self.complete(req).await?;
120        Ok(Box::pin(futures::stream::once(
121            async move { Ok(resp.text) },
122        )))
123    }
124
125    /// Produce a reply that may request tool calls (agentic loop). The default
126    /// implementation ignores `tools` and delegates to
127    /// [`ModelClient::complete`], so backends without function-calling support
128    /// degrade to a single-shot reply. Override this to drive the agentic loop.
129    async fn complete_with_tools(
130        &self,
131        req: &ModelRequest,
132        _tools: &[Tool],
133    ) -> Result<ModelTurn, CoreError> {
134        let resp = self.complete(req).await?;
135        Ok(ModelTurn {
136            text: resp.text,
137            tool_calls: Vec::new(),
138        })
139    }
140}
141
142/// Deterministic stand-in used when no API key / `openai` feature is present.
143pub struct StubModel {
144    agent_name: String,
145}
146
147impl StubModel {
148    pub fn new(agent_name: &str) -> Self {
149        Self {
150            agent_name: agent_name.to_string(),
151        }
152    }
153}
154
155#[async_trait]
156impl ModelClient for StubModel {
157    async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
158        let text = format!(
159            "[{}] (stub) context={} | input={}",
160            self.agent_name,
161            if req.context.is_empty() {
162                "<none>"
163            } else {
164                "<injected>"
165            },
166            req.input
167        );
168        Ok(ModelResponse { text })
169    }
170}
171
172#[cfg(feature = "openai")]
173pub use openai_impl::OpenAiModel;
174
175#[cfg(feature = "openai")]
176mod openai_impl {
177    use super::*;
178    use async_openai::types::{
179        ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage,
180        ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent,
181        ChatCompletionTool, ChatCompletionToolChoiceOption, ChatCompletionToolType,
182        CreateChatCompletionRequestArgs, FunctionObject,
183    };
184    use async_openai::{config::OpenAIConfig, Client};
185
186    /// Calls the OpenAI Chat Completions API as the agent's model backend.
187    pub struct OpenAiModel {
188        client: Client<OpenAIConfig>,
189        model: String,
190    }
191
192    impl OpenAiModel {
193        pub fn new(model: &str) -> Self {
194            let api_key = std::env::var("OPENAI_API_KEY").unwrap_or_default();
195            let mut config = OpenAIConfig::new().with_api_key(api_key);
196            // Optional override so a deployment can point the agent at any
197            // OpenAI-compatible endpoint (e.g. the aria-compute gateway) without
198            // rebuilding. Unset means the public OpenAI API.
199            if let Ok(base) = std::env::var("OPENAI_BASE_URL") {
200                let base = base.trim().trim_end_matches('/').to_string();
201                if !base.is_empty() {
202                    config = config.with_api_base(base);
203                }
204            }
205            Self {
206                client: Client::with_config(config),
207                model: model.to_string(),
208            }
209        }
210    }
211
212    /// Build the OpenAI `tools` argument from our tool contract using the modern
213    /// `tools`/`tool_choice` API (async-openai 0.24.1). The response is parsed as
214    /// real `tool_calls` below.
215    fn build_codex_tools(tools: &[Tool]) -> Vec<ChatCompletionTool> {
216        tools
217            .iter()
218            .map(|t| ChatCompletionTool {
219                r#type: ChatCompletionToolType::Function,
220                function: FunctionObject {
221                    name: t.name.clone(),
222                    description: Some(t.description.clone()),
223                    parameters: Some(t.parameters.clone()),
224                    strict: None,
225                },
226            })
227            .collect()
228    }
229
230    /// Parse a model response into our [`ToolCall`] contract. Prefers the modern
231    /// `tool_calls` shape; falls back to the legacy `function_call` (which has no
232    /// id, so we synthesize one). Malformed JSON arguments fall back to
233    /// `Value::Null` so a single bad call doesn't abort the whole turn.
234    #[allow(deprecated)]
235    fn parse_response_calls(
236        message: &async_openai::types::ChatCompletionResponseMessage,
237    ) -> Vec<ToolCall> {
238        if let Some(calls) = &message.tool_calls {
239            return calls
240                .iter()
241                .map(|c| {
242                    let arguments = serde_json::from_str(&c.function.arguments)
243                        .unwrap_or(serde_json::Value::Null);
244                    ToolCall {
245                        id: c.id.clone(),
246                        name: c.function.name.clone(),
247                        arguments,
248                    }
249                })
250                .collect();
251        }
252        if let Some(fc) = &message.function_call {
253            let arguments = serde_json::from_str(&fc.arguments).unwrap_or(serde_json::Value::Null);
254            return vec![ToolCall {
255                id: "fn_0".into(),
256                name: fc.name.clone(),
257                arguments,
258            }];
259        }
260        Vec::new()
261    }
262
263    #[async_trait]
264    impl ModelClient for OpenAiModel {
265        async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
266            use async_openai::types::CreateChatCompletionRequestArgs;
267            let messages = vec![
268                ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
269                    content: req.system.clone().into(),
270                    ..Default::default()
271                }),
272                ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
273                    content: ChatCompletionRequestUserMessageContent::Text(format!(
274                        "{}\n\nUSER: {}",
275                        req.context, req.input
276                    )),
277                    ..Default::default()
278                }),
279            ];
280            let request = CreateChatCompletionRequestArgs::default()
281                .model(self.model.clone())
282                .messages(messages)
283                .build()
284                .map_err(|e| CoreError::Model(e.to_string()))?;
285            let resp = self
286                .client
287                .chat()
288                .create(request)
289                .await
290                .map_err(|e| CoreError::Model(e.to_string()))?;
291            let text = resp
292                .choices
293                .first()
294                .and_then(|c| c.message.content.clone())
295                .unwrap_or_default();
296            Ok(ModelResponse { text })
297        }
298
299        async fn stream(
300            &self,
301            req: &ModelRequest,
302        ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
303            use async_openai::types::CreateChatCompletionRequestArgs;
304            use futures::StreamExt as _;
305            let messages = vec![
306                ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
307                    content: req.system.clone().into(),
308                    ..Default::default()
309                }),
310                ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
311                    content: ChatCompletionRequestUserMessageContent::Text(format!(
312                        "{}\n\nUSER: {}",
313                        req.context, req.input
314                    )),
315                    ..Default::default()
316                }),
317            ];
318            let request = CreateChatCompletionRequestArgs::default()
319                .model(self.model.clone())
320                .messages(messages)
321                .stream(true)
322                .build()
323                .map_err(|e| CoreError::Model(e.to_string()))?;
324            let client = self.client.clone();
325            let s = async_stream::stream! {
326                let mut stream = match client.chat().create_stream(request).await {
327                    Ok(s) => s,
328                    Err(e) => {
329                        yield Err(CoreError::Model(e.to_string()));
330                        return;
331                    }
332                };
333                while let Some(chunk) = stream.next().await {
334                    match chunk {
335                        Ok(resp) => {
336                            if let Some(tok) = resp
337                                .choices
338                                .into_iter()
339                                .next()
340                                .and_then(|c| c.delta.content)
341                            {
342                                yield Ok(tok);
343                            }
344                        }
345                        Err(e) => yield Err(CoreError::Model(e.to_string())),
346                    }
347                }
348            };
349            Ok(Box::pin(s))
350        }
351
352        async fn complete_with_tools(
353            &self,
354            req: &ModelRequest,
355            tools: &[Tool],
356        ) -> Result<ModelTurn, CoreError> {
357            let messages = vec![
358                ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
359                    content: req.system.clone().into(),
360                    ..Default::default()
361                }),
362                ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
363                    content: ChatCompletionRequestUserMessageContent::Text(format!(
364                        "{}\n\nUSER: {}",
365                        req.context, req.input
366                    )),
367                    ..Default::default()
368                }),
369            ];
370            let tools = build_codex_tools(tools);
371            let mut args = CreateChatCompletionRequestArgs::default();
372            let mut b = args.model(self.model.clone()).messages(messages);
373            if !tools.is_empty() {
374                b = b
375                    .tools(tools)
376                    .tool_choice(ChatCompletionToolChoiceOption::Auto);
377            }
378            let request = b.build().map_err(|e| CoreError::Model(e.to_string()))?;
379            let resp = self
380                .client
381                .chat()
382                .create(request)
383                .await
384                .map_err(|e| CoreError::Model(e.to_string()))?;
385            let choice = resp
386                .choices
387                .first()
388                .ok_or_else(|| CoreError::Model("empty choices from model".into()))?;
389            let text = choice.message.content.clone().unwrap_or_default();
390            let tool_calls = parse_response_calls(&choice.message);
391            Ok(ModelTurn { text, tool_calls })
392        }
393    }
394
395    #[cfg(test)]
396    mod tests {
397        use super::*;
398        use async_openai::types::ChatCompletionMessageToolCall;
399        use async_openai::types::ChatCompletionResponseMessage;
400        use async_openai::types::ChatCompletionToolType;
401        use async_openai::types::FunctionCall;
402        use async_openai::types::Role;
403
404        #[test]
405        fn build_codex_tools_maps_schema() {
406            let tools = vec![Tool {
407                name: "shell".into(),
408                description: "run a command".into(),
409                parameters: serde_json::json!({
410                    "type": "object",
411                    "properties": { "command": { "type": "string" } }
412                }),
413            }];
414            let out = build_codex_tools(&tools);
415            assert_eq!(out.len(), 1);
416            assert_eq!(out[0].r#type, ChatCompletionToolType::Function);
417            assert_eq!(out[0].function.name, "shell");
418            assert_eq!(
419                out[0].function.description.as_deref(),
420                Some("run a command")
421            );
422            assert!(out[0].function.parameters.as_ref().unwrap().is_object());
423        }
424
425        #[test]
426        fn parse_response_calls_reads_modern_tool_calls() {
427            let message = ChatCompletionResponseMessage {
428                content: Some("thinking".into()),
429                refusal: None,
430                tool_calls: Some(vec![ChatCompletionMessageToolCall {
431                    id: "call_1".into(),
432                    r#type: ChatCompletionToolType::Function,
433                    function: FunctionCall {
434                        name: "shell".into(),
435                        arguments: "{\"command\":[\"echo\",\"hi\"]}".into(),
436                    },
437                }]),
438                role: Role::Assistant,
439                #[allow(deprecated)]
440                function_call: None,
441            };
442            let parsed = parse_response_calls(&message);
443            assert_eq!(parsed.len(), 1);
444            assert_eq!(parsed[0].id, "call_1");
445            assert_eq!(parsed[0].name, "shell");
446            assert_eq!(
447                parsed[0].arguments,
448                serde_json::json!({"command": ["echo", "hi"]})
449            );
450        }
451
452        #[test]
453        fn parse_response_calls_reads_legacy_function_call() {
454            let message = ChatCompletionResponseMessage {
455                content: Some("thinking".into()),
456                refusal: None,
457                tool_calls: None,
458                role: Role::Assistant,
459                #[allow(deprecated)]
460                function_call: Some(FunctionCall {
461                    name: "shell".into(),
462                    arguments: "{\"command\":[\"echo\",\"hi\"]}".into(),
463                }),
464            };
465            let parsed = parse_response_calls(&message);
466            assert_eq!(parsed.len(), 1);
467            assert_eq!(parsed[0].id, "fn_0");
468            assert_eq!(parsed[0].name, "shell");
469        }
470
471        #[test]
472        fn parse_response_calls_handles_invalid_json() {
473            let message = ChatCompletionResponseMessage {
474                content: None,
475                refusal: None,
476                tool_calls: Some(vec![ChatCompletionMessageToolCall {
477                    id: "bad".into(),
478                    r#type: ChatCompletionToolType::Function,
479                    function: FunctionCall {
480                        name: "shell".into(),
481                        arguments: "not-json".into(),
482                    },
483                }]),
484                role: Role::Assistant,
485                #[allow(deprecated)]
486                function_call: None,
487            };
488            let parsed = parse_response_calls(&message);
489            // Invalid JSON falls back to Null rather than aborting the turn.
490            assert_eq!(parsed.len(), 1);
491            assert_eq!(parsed[0].arguments, serde_json::Value::Null);
492        }
493    }
494}
495
496/// Configuration for constructing an [`Agent`]. Serializable for SDK/Ffi.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct AgentConfig {
499    pub session: String,
500    pub agent_name: String,
501    pub sandbox_provider: String,
502    pub model: String,
503}
504
505impl Default for AgentConfig {
506    fn default() -> Self {
507        Self {
508            session: "default".to_string(),
509            agent_name: "agent".to_string(),
510            // Default to the self-contained Docker sandbox. The codex backend
511            // (ADR-0005 §Decision) is provided by the `aria-agent-cloud` runtime
512            // and injected via `Agent::with_sandbox`; tests use `with_sandbox`
513            // with a local `Sandbox` too.
514            sandbox_provider: "docker".to_string(),
515            model: "gpt-4o-mini".to_string(),
516        }
517    }
518}
519
520/// A single named skill — a reusable capability the agent may draw on.
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
522pub struct Skill {
523    pub name: String,
524    pub body: String,
525}
526
527/// A single named rule — a constraint the agent must obey.
528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
529pub struct Rule {
530    pub name: String,
531    pub body: String,
532}
533
534/// The evolvable harness of an agent: system prompt plus its skills and rules.
535/// This is the unit Reef-style self-improvement evolves, versions in Git, and
536/// hot-serves back to the running agent.
537#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
538pub struct Harness {
539    pub system_prompt: String,
540    pub skills: Vec<Skill>,
541    pub rules: Vec<Rule>,
542}
543
544impl Harness {
545    /// Baseline harness equivalent to the legacy hardcoded system prompt.
546    pub fn baseline(agent_name: &str) -> Self {
547        Self {
548            system_prompt: format!("You are {}.", agent_name),
549            skills: Vec::new(),
550            rules: Vec::new(),
551        }
552    }
553
554    /// True when this harness is byte-for-byte the generated baseline.
555    pub fn is_baseline(&self, agent_name: &str) -> bool {
556        *self == Harness::baseline(agent_name)
557    }
558
559    /// Compose the system prompt sent to the model from the harness parts.
560    pub fn system_text(&self) -> String {
561        let mut s = self.system_prompt.clone();
562        if !self.skills.is_empty() {
563            s.push_str("\n\n## Skills");
564            for sk in &self.skills {
565                s.push_str(&format!("\n- {}: {}", sk.name, sk.body));
566            }
567        }
568        if !self.rules.is_empty() {
569            s.push_str("\n\n## Rules");
570            for r in &self.rules {
571                s.push_str(&format!("\n- {}: {}", r.name, r.body));
572            }
573        }
574        s
575    }
576}
577
578/// Hot-swappable handle to the currently-served harness.
579///
580/// Read-heavy, write-rare: [`get`](ActiveHarness::get) only clones an `Arc`
581/// (O(1), no async, no serialization), while [`set`](ActiveHarness::set)
582/// atomically replaces the served harness so subsequent turns pick it up
583/// without restarting the process.
584pub struct ActiveHarness {
585    current: Arc<RwLock<Arc<Harness>>>,
586}
587
588impl ActiveHarness {
589    pub fn new(initial: Harness) -> Self {
590        Self {
591            current: Arc::new(RwLock::new(Arc::new(initial))),
592        }
593    }
594
595    /// Baseline harness equivalent to the legacy system prompt.
596    pub fn baseline(agent_name: &str) -> Self {
597        Self::new(Harness::baseline(agent_name))
598    }
599
600    /// Clone the inner `Arc` (O(1)); no serialization, no async.
601    pub fn get(&self) -> Arc<Harness> {
602        self.current
603            .read()
604            .expect("active harness lock poisoned")
605            .clone()
606    }
607
608    /// Atomically replace the served harness with a new version.
609    pub fn set(&self, h: Harness) {
610        let mut g = self.current.write().expect("active harness lock poisoned");
611        *g = Arc::new(h);
612    }
613}
614
615/// The unified agent. Holds the model client, memo store, sandbox, and a
616/// shared handle to the currently-served [`ActiveHarness`] (which may be
617/// hot-swapped by a self-improvement loop).
618pub struct Agent {
619    config: AgentConfig,
620    model: Arc<dyn ModelClient>,
621    memo: Arc<dyn MemoStore>,
622    sandbox: Arc<dyn Sandbox>,
623    harness: Arc<ActiveHarness>,
624}
625
626impl Agent {
627    /// Build with an explicit model client (e.g. [`StubModel`] or [`OpenAiModel`])
628    /// and a shared, hot-swappable harness handle.
629    pub fn with_harness(
630        config: AgentConfig,
631        model: Box<dyn ModelClient>,
632        memo: Arc<dyn MemoStore>,
633        harness: Arc<ActiveHarness>,
634    ) -> Result<Self, CoreError> {
635        let provider = SandboxProvider::parse(&config.sandbox_provider).ok_or_else(|| {
636            CoreError::Config(format!("unknown sandbox: {}", config.sandbox_provider))
637        })?;
638        // The `codex` backend is constructed by the `aria-agent-cloud` runtime
639        // (publish = false) and injected via `with_sandbox`; the published SDK
640        // returns `NotConfigured` for it here.
641        let sandbox = Arc::from(
642            agent_sandbox::from_provider(provider)
643                .map_err(|e| CoreError::Config(format!("sandbox {}: {}", provider.as_str(), e)))?,
644        );
645        Ok(Self {
646            config,
647            model: Arc::from(model),
648            memo,
649            sandbox,
650            harness,
651        })
652    }
653
654    /// Build with an explicit sandbox backend (e.g. a local test sandbox or a
655    /// codex-backed [`Sandbox`](agent_sandbox::Sandbox)). Useful when the
656    /// provider string alone does not describe the execution environment.
657    pub fn with_sandbox(
658        config: AgentConfig,
659        model: Box<dyn ModelClient>,
660        memo: Arc<dyn MemoStore>,
661        harness: Arc<ActiveHarness>,
662        sandbox: Arc<dyn Sandbox>,
663    ) -> Result<Self, CoreError> {
664        Ok(Self {
665            config,
666            model: Arc::from(model),
667            memo,
668            sandbox,
669            harness,
670        })
671    }
672
673    /// Build with an explicit model client (e.g. [`StubModel`] or [`OpenAiModel`]).
674    /// A baseline harness is generated from the config's agent name.
675    pub fn with_model(
676        config: AgentConfig,
677        model: Box<dyn ModelClient>,
678        memo: Arc<dyn MemoStore>,
679    ) -> Result<Self, CoreError> {
680        let harness = Arc::new(ActiveHarness::baseline(&config.agent_name));
681        Self::with_harness(config, model, memo, harness)
682    }
683
684    /// Build using the default model (stub unless `openai` feature is on).
685    pub fn new(config: AgentConfig, memo: Arc<dyn MemoStore>) -> Result<Self, CoreError> {
686        let model: Box<dyn ModelClient> = {
687            #[cfg(feature = "openai")]
688            {
689                Box::new(OpenAiModel::new(&config.model))
690            }
691            #[cfg(not(feature = "openai"))]
692            {
693                let _ = &config.model;
694                Box::new(StubModel::new(&config.agent_name))
695            }
696        };
697        Self::with_model(config, model, memo)
698    }
699
700    pub fn session(&self) -> &str {
701        &self.config.session
702    }
703
704    /// Shared harness handle — hot-swappable by a self-improvement loop.
705    pub fn harness(&self) -> Arc<ActiveHarness> {
706        self.harness.clone()
707    }
708
709    /// Shared memo store (used by the SDK to expose `Session`).
710    pub fn memo(&self) -> Arc<dyn MemoStore> {
711        self.memo.clone()
712    }
713
714    /// Run one turn. Injects memo context, calls the model, persists both turns.
715    pub async fn run(&self, input: &str) -> Result<String, CoreError> {
716        // 1) recall context from memo (the ONLY context source)
717        let fragments = self
718            .memo
719            .recall(&RecallQuery::new(&self.config.session, input))
720            .await?;
721        let context = fragments
722            .iter()
723            .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
724            .collect::<Vec<_>>()
725            .join("\n");
726
727        // 2) persist the user turn
728        self.memo
729            .memorize(ContextFragment::new(
730                &self.config.session,
731                FragmentKind::Message,
732                input,
733            ))
734            .await?;
735
736        // 3) call the model
737        let system = self.harness.get().system_text();
738        let req = ModelRequest {
739            system,
740            context,
741            input: input.to_string(),
742        };
743        let resp = self.model.complete(&req).await?;
744
745        // 4) persist the assistant reply
746        self.memo
747            .memorize(ContextFragment::new(
748                &self.config.session,
749                FragmentKind::Message,
750                resp.text.clone(),
751            ))
752            .await?;
753
754        Ok(resp.text)
755    }
756
757    /// Run a shell command inside the sandbox and remember the result.
758    pub async fn exec_tool(&self, command: &[String]) -> Result<String, CoreError> {
759        let spec = agent_sandbox::ExecSpec::command(command.to_vec());
760        let handle = self.sandbox.spawn(&spec).await?;
761        let out = self.sandbox.exec(&handle, command).await?;
762        self.sandbox.destroy(handle).await?;
763        let captured = format!(
764            "exit={} stdout={} stderr={}",
765            out.exit_code, out.stdout, out.stderr
766        );
767        self.memo
768            .memorize(ContextFragment::new(
769                &self.config.session,
770                FragmentKind::ToolResult,
771                captured.clone(),
772            ))
773            .await?;
774        Ok(captured)
775    }
776
777    /// Stream one turn token-by-token. Mirrors [`Agent::run`] for the memo
778    /// contract (recall before / persist both turns after) but emits model
779    /// tokens as they arrive. The returned stream is `'static` and owns its
780    /// memo handle and model client, so the [`Agent`] may be dropped.
781    pub async fn run_stream(
782        &self,
783        input: &str,
784    ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
785        // 1) recall context from memo (the ONLY context source)
786        let fragments = self
787            .memo
788            .recall(&RecallQuery::new(&self.config.session, input))
789            .await?;
790        let context = fragments
791            .iter()
792            .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
793            .collect::<Vec<_>>()
794            .join("\n");
795
796        // 2) persist the user turn
797        self.memo
798            .memorize(ContextFragment::new(
799                &self.config.session,
800                FragmentKind::Message,
801                input,
802            ))
803            .await?;
804
805        // 3) call the model (streaming). The returned stream is owned / 'static.
806        let system = self.harness.get().system_text();
807        let req = ModelRequest {
808            system,
809            context,
810            input: input.to_string(),
811        };
812        let upstream = self.model.stream(&req).await?;
813
814        // 4) wrap so we can collect + persist the assistant reply at the end.
815        let memo = self.memo.clone();
816        let session = self.config.session.clone();
817        let wrapped = async_stream::stream! {
818            let mut collected = String::new();
819            let mut upstream = upstream;
820            while let Some(item) = upstream.next().await {
821                match item {
822                    Ok(tok) => {
823                        collected.push_str(&tok);
824                        yield Ok(tok);
825                    }
826                    Err(e) => {
827                        yield Err(e);
828                        return;
829                    }
830                }
831            }
832            // persist the assistant reply as a single memo fragment
833            let _ = memo
834                .memorize(ContextFragment::new(&session, FragmentKind::Message, collected))
835                .await;
836        };
837        Ok(Box::pin(wrapped))
838    }
839
840    /// Upper bound on model↔tool iterations within one turn (loop-guard).
841    pub const MAX_AGENTIC_STEPS: usize = 8;
842
843    /// Execute a single tool call inside the sandbox and return its captured
844    /// output. The command is taken from the `command` argument (a JSON array
845    /// of strings); the result is persisted to memo as a `ToolResult` fragment
846    /// so subsequent turns can recall it.
847    pub async fn exec_tool_call(&self, call: &ToolCall) -> Result<ToolResult, CoreError> {
848        sandbox_exec(&self.sandbox, &self.memo, &self.config.session, call).await
849    }
850
851    /// Run an agentic turn as an event stream: recall memo context → call the
852    /// model (with `tools`) → execute any requested tool calls in the sandbox →
853    /// refill memo → repeat until the model emits a final reply. The returned
854    /// stream is `'static` and owns every dependency, so the [`Agent`] may be
855    /// dropped while it is still consumed.
856    ///
857    /// Tool execution is delegated to codex's real `SandboxManager` via the
858    /// `CodexSandbox` backend (ADR-0005 §Decision) — never a bespoke
859    /// in-process executor. Tests inject a local [`Sandbox`](agent_sandbox::Sandbox)
860    /// through [`Agent::with_sandbox`].
861    pub async fn run_event_stream(
862        &self,
863        input: &str,
864        tools: &[Tool],
865    ) -> Result<BoxStream<'static, Result<AgentEvent, CoreError>>, CoreError> {
866        let memo = self.memo.clone();
867        let model = self.model.clone();
868        let sandbox = self.sandbox.clone();
869        let harness = self.harness.clone();
870        let session = self.config.session.clone();
871        let tools: Vec<Tool> = tools.to_vec();
872        let input = input.to_string();
873
874        // 1) recall context from memo (the ONLY context source).
875        let fragments = memo.recall(&RecallQuery::new(&session, &input)).await?;
876        let initial_context = fragments
877            .iter()
878            .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
879            .collect::<Vec<_>>()
880            .join("\n");
881        // 2) persist the user turn.
882        memo.memorize(ContextFragment::new(
883            &session,
884            FragmentKind::Message,
885            input.clone(),
886        ))
887        .await?;
888
889        let wrapped = async_stream::stream! {
890            yield Ok(AgentEvent::Step { phase: "recall".into(), label: None });
891            let mut tool_log = String::new();
892            let mut step = 0usize;
893
894            loop {
895                step += 1;
896                if step > Agent::MAX_AGENTIC_STEPS {
897                    yield Ok(AgentEvent::Step {
898                        phase: "loop_guard".into(),
899                        label: Some("max agentic steps exceeded".into()),
900                    });
901                    yield Ok(AgentEvent::Done { text: String::new() });
902                    break;
903                }
904
905                yield Ok(AgentEvent::Step { phase: "model".into(), label: None });
906                let system = harness.get().system_text();
907                let context = if tool_log.is_empty() {
908                    initial_context.clone()
909                } else {
910                    format!("{}\n{}", initial_context, tool_log)
911                };
912                let req = ModelRequest {
913                    system,
914                    context,
915                    input: input.clone(),
916                };
917                let turn = match model.complete_with_tools(&req, &tools).await {
918                    Ok(t) => t,
919                    Err(e) => {
920                        yield Err(e);
921                        return;
922                    }
923                };
924
925                if turn.tool_calls.is_empty() {
926                    // 3) final reply: stream the token then terminate.
927                    yield Ok(AgentEvent::Token { text: turn.text.clone() });
928                    let _ = memo
929                        .memorize(ContextFragment::new(
930                            &session,
931                            FragmentKind::Message,
932                            turn.text.clone(),
933                        ))
934                        .await;
935                    yield Ok(AgentEvent::Done { text: turn.text });
936                    break;
937                }
938
939                // 4) execute each requested tool call in the sandbox.
940                for call in &turn.tool_calls {
941                    yield Ok(AgentEvent::Step {
942                        phase: "tool_exec".into(),
943                        label: Some(call.name.clone()),
944                    });
945                    let result = match sandbox_exec(&sandbox, &memo, &session, call).await {
946                        Ok(r) => r,
947                        Err(e) => ToolResult {
948                            call_id: call.id.clone(),
949                            content: e.to_string(),
950                            is_error: true,
951                        },
952                    };
953                    tool_log.push_str(&format!(
954                        "\n\nTOOL_RESULT[{}]: {}",
955                        call.name, result.content
956                    ));
957                    yield Ok(AgentEvent::ToolCall {
958                        id: call.id.clone(),
959                        name: call.name.clone(),
960                        arguments: call.arguments.clone(),
961                        result,
962                    });
963                }
964            }
965        };
966        Ok(Box::pin(wrapped))
967    }
968
969    /// Run an agentic turn and fold the event stream into the final text.
970    pub async fn run_agentic(&self, input: &str, tools: &[Tool]) -> Result<String, CoreError> {
971        let stream = self.run_event_stream(input, tools).await?;
972        let mut out = String::new();
973        let mut stream = stream;
974        while let Some(ev) = stream.next().await {
975            if let AgentEvent::Done { text } = ev? {
976                out = text;
977                break;
978            }
979        }
980        Ok(out)
981    }
982}
983
984/// Execute a tool call in the sandbox and persist the result to memo. Shared by
985/// the agentic loop so it can be awaited without borrowing the [`Agent`].
986async fn sandbox_exec(
987    sandbox: &Arc<dyn Sandbox>,
988    memo: &Arc<dyn MemoStore>,
989    session: &str,
990    call: &ToolCall,
991) -> Result<ToolResult, CoreError> {
992    let command: Vec<String> = call
993        .arguments
994        .get("command")
995        .and_then(|v| v.as_array())
996        .map(|a| {
997            a.iter()
998                .filter_map(|x| x.as_str().map(String::from))
999                .collect()
1000        })
1001        .ok_or_else(|| {
1002            CoreError::Model(format!("tool `{}` missing `command` array arg", call.name))
1003        })?;
1004    if command.is_empty() {
1005        return Err(CoreError::Model(format!(
1006            "tool `{}` command is empty",
1007            call.name
1008        )));
1009    }
1010    let spec = agent_sandbox::ExecSpec::command(command.clone());
1011    let handle = sandbox.spawn(&spec).await?;
1012    let out = sandbox.exec(&handle, &command).await?;
1013    sandbox.destroy(handle).await?;
1014    let content = format!(
1015        "exit={} stdout={} stderr={}",
1016        out.exit_code, out.stdout, out.stderr
1017    );
1018    memo.memorize(ContextFragment::new(
1019        session,
1020        FragmentKind::ToolResult,
1021        content.clone(),
1022    ))
1023    .await?;
1024    Ok(ToolResult {
1025        call_id: call.id.clone(),
1026        content,
1027        is_error: false,
1028    })
1029}
1030
1031/// Convenience: a memory-backed memo store for quick local use.
1032pub fn in_memory_memo() -> Arc<dyn MemoStore> {
1033    SledMemoStore::memory().expect("sled temp store")
1034}
1035
1036/// Re-export the default sandbox constructor for callers that don't need config.
1037pub fn default_sandbox_box() -> Box<dyn Sandbox> {
1038    default_sandbox()
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044
1045    #[tokio::test]
1046    async fn run_injects_memo_and_persists() {
1047        let memo = in_memory_memo();
1048        let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
1049        let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
1050        let r1 = agent.run("hello").await.unwrap();
1051        assert!(r1.contains("hello"));
1052        // second turn should recall the first
1053        let _ = agent.run("recap").await.unwrap();
1054        let frags = memo
1055            .recall(&RecallQuery::new("default", "hello"))
1056            .await
1057            .unwrap();
1058        assert!(frags.iter().any(|f| f.content == "hello"));
1059    }
1060
1061    #[tokio::test]
1062    async fn exec_tool_runs_in_sandbox() {
1063        let memo = in_memory_memo();
1064        let agent = Agent::new(AgentConfig::default(), memo).unwrap();
1065        // Works only if `docker` is available; otherwise it errors gracefully.
1066        match agent.exec_tool(&["echo".into(), "hi".into()]).await {
1067            Ok(out) => assert!(out.contains("hi")),
1068            Err(_) => { /* docker / sandbox / memo not available in this environment */ }
1069        }
1070    }
1071
1072    #[tokio::test]
1073    async fn run_stream_emits_tokens_and_persists() {
1074        let memo = in_memory_memo();
1075        let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
1076        let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
1077        let stream = agent.run_stream("hello").await.unwrap();
1078        let mut collected = String::new();
1079        let mut s = stream;
1080        while let Some(tok) = s.next().await {
1081            collected.push_str(&tok.unwrap());
1082        }
1083        assert!(collected.contains("hello"));
1084        // Both the user turn and the assistant reply are persisted to memo.
1085        let frags = memo
1086            .recall(&RecallQuery::new("default", "hello"))
1087            .await
1088            .unwrap();
1089        assert!(frags.iter().any(|f| f.content == "hello"));
1090    }
1091
1092    #[tokio::test]
1093    async fn run_second_turn_injects_prior_context() {
1094        let memo = in_memory_memo();
1095        let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
1096        let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
1097        let _ = agent.run("remember the secret code 1234").await.unwrap();
1098        let second = agent.run("what was the code?").await.unwrap();
1099        // The stub emits `<injected>` only when recalled context is non-empty.
1100        assert!(second.contains("<injected>"));
1101    }
1102
1103    #[tokio::test]
1104    async fn unknown_sandbox_provider_is_config_error() {
1105        let cfg = AgentConfig {
1106            sandbox_provider: "bogus".into(),
1107            ..AgentConfig::default()
1108        };
1109        let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
1110        let res = Agent::with_model(cfg, model, in_memory_memo());
1111        assert!(matches!(res, Err(CoreError::Config(_))));
1112    }
1113
1114    #[test]
1115    fn core_error_converts_from_memo() {
1116        let e: CoreError = agent_memo::MemoError::NotFound("x".into()).into();
1117        assert!(matches!(e, CoreError::Memo(_)));
1118    }
1119
1120    // --- Harness / ActiveHarness unit tests ---
1121
1122    #[test]
1123    fn harness_baseline_equals_legacy_system() {
1124        let h = Harness::baseline("helper");
1125        assert_eq!(h.system_text(), "You are helper.");
1126        assert!(h.skills.is_empty());
1127        assert!(h.rules.is_empty());
1128    }
1129
1130    #[test]
1131    fn harness_system_text_assembles_skills_and_rules() {
1132        let h = Harness {
1133            system_prompt: "You are a bot.".into(),
1134            skills: vec![Skill {
1135                name: "summarize".into(),
1136                body: "condense text".into(),
1137            }],
1138            rules: vec![Rule {
1139                name: "no_pii".into(),
1140                body: "never echo secrets".into(),
1141            }],
1142        };
1143        let s = h.system_text();
1144        assert!(s.contains("You are a bot."));
1145        assert!(s.contains("## Skills"));
1146        assert!(s.contains("summarize: condense text"));
1147        assert!(s.contains("## Rules"));
1148        assert!(s.contains("no_pii: never echo secrets"));
1149    }
1150
1151    #[test]
1152    fn harness_serialization_roundtrip() {
1153        let h = Harness {
1154            system_prompt: "sys".into(),
1155            skills: vec![Skill {
1156                name: "s".into(),
1157                body: "b".into(),
1158            }],
1159            rules: vec![],
1160        };
1161        let json = serde_json::to_string(&h).unwrap();
1162        let back: Harness = serde_json::from_str(&json).unwrap();
1163        assert_eq!(h, back);
1164    }
1165
1166    #[test]
1167    fn active_harness_hot_swap_is_atomic() {
1168        let ah = ActiveHarness::baseline("agent");
1169        assert!(ah.get().is_baseline("agent"));
1170        // Cloning the Arc is O(1) and shares the same harness.
1171        let snap = ah.get();
1172        ah.set(Harness::baseline("renamed"));
1173        // The old snapshot is unaffected, but a fresh get() sees the new value.
1174        assert!(snap.is_baseline("agent"));
1175        assert!(ah.get().is_baseline("renamed"));
1176    }
1177
1178    /// Test model that echoes the system prompt so we can assert which harness
1179    /// was served on each turn.
1180    struct EchoModel;
1181
1182    #[async_trait]
1183    impl ModelClient for EchoModel {
1184        async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1185            Ok(ModelResponse {
1186                text: format!("SYSTEM[{}]", req.system),
1187            })
1188        }
1189    }
1190
1191    #[tokio::test]
1192    async fn run_uses_active_harness_and_hot_swaps() {
1193        let memo = in_memory_memo();
1194        let model: Box<dyn ModelClient> = Box::new(EchoModel);
1195        let harness = Arc::new(ActiveHarness::baseline("agent"));
1196        let agent =
1197            Agent::with_harness(AgentConfig::default(), model, memo, harness.clone()).unwrap();
1198
1199        let r1 = agent.run("hi").await.unwrap();
1200        assert!(
1201            r1.contains("You are agent."),
1202            "baseline system served: {r1}"
1203        );
1204
1205        // Hot-swap the harness; the next turn must use the new system prompt.
1206        harness.set(Harness {
1207            system_prompt: "Be terse.".into(),
1208            skills: vec![Skill {
1209                name: "short".into(),
1210                body: "reply in one line".into(),
1211            }],
1212            rules: vec![],
1213        });
1214        let r2 = agent.run("hi").await.unwrap();
1215        assert!(r2.contains("Be terse."), "swapped system served: {r2}");
1216        assert!(
1217            r2.contains("reply in one line"),
1218            "swapped skill served: {r2}"
1219        );
1220    }
1221
1222    #[tokio::test]
1223    async fn with_model_builds_baseline_harness() {
1224        let memo = in_memory_memo();
1225        let model: Box<dyn ModelClient> = Box::new(EchoModel);
1226        let agent = Agent::with_model(AgentConfig::default(), model, memo).unwrap();
1227        let r = agent.run("hi").await.unwrap();
1228        assert!(r.contains("You are agent."));
1229    }
1230
1231    // --- AgentEvent / agentic loop tests ---
1232
1233    use agent_sandbox::{ExecOutput, ExecSpec, SandboxError, SandboxHandle};
1234    use std::sync::atomic::{AtomicUsize, Ordering};
1235
1236    /// A `Sandbox` that runs commands locally (no Docker) so tool-execution
1237    /// tests are hermetic and fast.
1238    struct LocalSandbox;
1239
1240    #[async_trait]
1241    impl Sandbox for LocalSandbox {
1242        async fn spawn(&self, _spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
1243            Ok(SandboxHandle { id: "local".into() })
1244        }
1245        async fn exec(
1246            &self,
1247            _handle: &SandboxHandle,
1248            cmd: &[String],
1249        ) -> Result<ExecOutput, SandboxError> {
1250            let joined = cmd.join(" ");
1251            let out = tokio::process::Command::new("sh")
1252                .args(["-c", &joined])
1253                .output()
1254                .await
1255                .map_err(SandboxError::Io)?;
1256            Ok(ExecOutput {
1257                exit_code: out.status.code().unwrap_or(-1),
1258                stdout: String::from_utf8_lossy(&out.stdout).to_string(),
1259                stderr: String::from_utf8_lossy(&out.stderr).to_string(),
1260            })
1261        }
1262        async fn destroy(&self, _handle: SandboxHandle) -> Result<(), SandboxError> {
1263            Ok(())
1264        }
1265    }
1266
1267    fn local_agent(model: Box<dyn ModelClient>) -> Agent {
1268        let harness = Arc::new(ActiveHarness::baseline("agent"));
1269        Agent::with_sandbox(
1270            AgentConfig::default(),
1271            model,
1272            in_memory_memo(),
1273            harness,
1274            Arc::new(LocalSandbox),
1275        )
1276        .unwrap()
1277    }
1278
1279    #[test]
1280    fn agent_event_serde_uses_type_tag() {
1281        let tok = AgentEvent::Token { text: "hi".into() };
1282        let j = serde_json::to_string(&tok).unwrap();
1283        assert!(j.contains("\"type\":\"token\""));
1284        assert_eq!(serde_json::from_str::<AgentEvent>(&j).unwrap(), tok);
1285
1286        let tc = AgentEvent::ToolCall {
1287            id: "c".into(),
1288            name: "shell".into(),
1289            arguments: serde_json::json!({}),
1290            result: ToolResult {
1291                call_id: "c".into(),
1292                content: "x".into(),
1293                is_error: false,
1294            },
1295        };
1296        let j2 = serde_json::to_string(&tc).unwrap();
1297        assert!(j2.contains("\"type\":\"tool_call\""));
1298        assert_eq!(serde_json::from_str::<AgentEvent>(&j2).unwrap(), tc);
1299
1300        let step = AgentEvent::Step {
1301            phase: "recall".into(),
1302            label: None,
1303        };
1304        assert!(serde_json::to_string(&step)
1305            .unwrap()
1306            .contains("\"type\":\"step\""));
1307        assert!(
1308            serde_json::to_string(&AgentEvent::Done { text: "x".into() })
1309                .unwrap()
1310                .contains("\"type\":\"done\"")
1311        );
1312    }
1313
1314    #[tokio::test]
1315    async fn stub_model_complete_with_tools_has_no_calls() {
1316        let model = StubModel::new("agent");
1317        let req = ModelRequest {
1318            system: "s".into(),
1319            context: String::new(),
1320            input: "hi".into(),
1321        };
1322        let turn = model.complete_with_tools(&req, &[]).await.unwrap();
1323        assert!(turn.tool_calls.is_empty());
1324        assert!(turn.text.contains("hi"));
1325    }
1326
1327    #[tokio::test]
1328    async fn run_event_stream_single_shot_emits_events() {
1329        let memo = in_memory_memo();
1330        let agent = Agent::with_model(
1331            AgentConfig::default(),
1332            Box::new(StubModel::new("agent")),
1333            memo.clone(),
1334        )
1335        .unwrap();
1336        let stream = agent.run_event_stream("hello", &[]).await.unwrap();
1337        let mut events = Vec::new();
1338        let mut stream = stream;
1339        while let Some(ev) = stream.next().await {
1340            events.push(ev.unwrap());
1341        }
1342        assert!(
1343            matches!(events.first(), Some(AgentEvent::Step { phase, .. }) if phase == "recall"),
1344            "first event must be the recall step"
1345        );
1346        assert!(events.iter().any(|e| matches!(e, AgentEvent::Token { .. })));
1347        assert!(
1348            matches!(events.last(), Some(AgentEvent::Done { .. })),
1349            "stream must terminate with Done"
1350        );
1351        // Both turns persisted to memo.
1352        let frags = memo
1353            .recall(&RecallQuery::new("default", "hello"))
1354            .await
1355            .unwrap();
1356        assert!(frags.iter().any(|f| f.content == "hello"));
1357    }
1358
1359    #[tokio::test]
1360    async fn run_agentic_executes_tool_and_refills_memo() {
1361        let agent = local_agent(Box::new(ToolLoopModel {
1362            calls: Arc::new(AtomicUsize::new(0)),
1363        }));
1364        let tools = vec![Tool {
1365            name: "shell".into(),
1366            description: "run a shell command".into(),
1367            parameters: serde_json::json!({}),
1368        }];
1369        let stream = agent.run_event_stream("do it", &tools).await.unwrap();
1370        let mut stream = stream;
1371        let mut results = Vec::new();
1372        while let Some(ev) = stream.next().await {
1373            if let AgentEvent::ToolCall { result, .. } = ev.unwrap() {
1374                results.push(result);
1375            }
1376        }
1377        assert_eq!(results.len(), 1, "exactly one tool call executed");
1378        assert!(results[0].content.contains("hello"));
1379        assert!(!results[0].is_error);
1380        // The tool result was persisted to memo and can be recalled.
1381        let frags = agent
1382            .memo()
1383            .recall(&RecallQuery::new("default", "hello"))
1384            .await
1385            .unwrap();
1386        assert!(frags.iter().any(|f| f.content.contains("hello")));
1387    }
1388
1389    #[tokio::test]
1390    async fn run_agentic_records_tool_failure() {
1391        let agent = local_agent(Box::new(MissingCommandModel {
1392            calls: Arc::new(AtomicUsize::new(0)),
1393        }));
1394        let tools = vec![Tool {
1395            name: "shell".into(),
1396            description: "x".into(),
1397            parameters: serde_json::json!({}),
1398        }];
1399        let stream = agent.run_event_stream("fail", &tools).await.unwrap();
1400        let mut stream = stream;
1401        let mut saw_error = false;
1402        let mut done = false;
1403        while let Some(ev) = stream.next().await {
1404            match ev.unwrap() {
1405                AgentEvent::ToolCall { result, .. } => saw_error = saw_error || result.is_error,
1406                AgentEvent::Done { .. } => done = true,
1407                _ => {}
1408            }
1409        }
1410        assert!(
1411            saw_error,
1412            "missing command must surface as an error tool result"
1413        );
1414        assert!(done);
1415    }
1416
1417    #[tokio::test]
1418    async fn run_agentic_respects_loop_cap() {
1419        let agent = local_agent(Box::new(LoopForeverModel));
1420        let tools = vec![Tool {
1421            name: "shell".into(),
1422            description: "x".into(),
1423            parameters: serde_json::json!({}),
1424        }];
1425        let stream = agent.run_event_stream("loop", &tools).await.unwrap();
1426        let mut stream = stream;
1427        let mut model_steps = 0usize;
1428        let mut done = false;
1429        while let Some(ev) = stream.next().await {
1430            match ev.unwrap() {
1431                AgentEvent::Step { phase, .. } if phase == "model" => model_steps += 1,
1432                AgentEvent::Done { .. } => done = true,
1433                _ => {}
1434            }
1435        }
1436        assert!(done, "must terminate with a Done event even when looping");
1437        assert!(
1438            model_steps <= Agent::MAX_AGENTIC_STEPS,
1439            "model steps bounded by cap, got {model_steps}"
1440        );
1441    }
1442
1443    #[tokio::test]
1444    async fn exec_tool_call_runs_in_sandbox_and_persists() {
1445        let memo = in_memory_memo();
1446        let agent = Agent::with_sandbox(
1447            AgentConfig::default(),
1448            Box::new(StubModel::new("agent")),
1449            memo.clone(),
1450            Arc::new(ActiveHarness::baseline("agent")),
1451            Arc::new(LocalSandbox),
1452        )
1453        .unwrap();
1454        let call = ToolCall {
1455            id: "c1".into(),
1456            name: "shell".into(),
1457            arguments: serde_json::json!({ "command": ["echo", "hi"] }),
1458        };
1459        let res = agent.exec_tool_call(&call).await.unwrap();
1460        assert!(res.content.contains("hi"));
1461        assert!(!res.is_error);
1462        let frags = memo
1463            .recall(&RecallQuery::new("default", "hi"))
1464            .await
1465            .unwrap();
1466        assert!(frags.iter().any(|f| f.content.contains("hi")));
1467    }
1468
1469    #[tokio::test]
1470    async fn exec_tool_call_missing_command_errors() {
1471        let agent = local_agent(Box::new(StubModel::new("agent")));
1472        let call = ToolCall {
1473            id: "c1".into(),
1474            name: "shell".into(),
1475            arguments: serde_json::json!({}),
1476        };
1477        let res = agent.exec_tool_call(&call).await;
1478        assert!(matches!(res, Err(CoreError::Model(_))));
1479    }
1480
1481    /// Returns a tool call on the first turn, then a final reply afterwards.
1482    struct ToolLoopModel {
1483        calls: Arc<AtomicUsize>,
1484    }
1485
1486    #[async_trait]
1487    impl ModelClient for ToolLoopModel {
1488        async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1489            Ok(ModelResponse {
1490                text: format!("[stub] {}", req.input),
1491            })
1492        }
1493        async fn complete_with_tools(
1494            &self,
1495            req: &ModelRequest,
1496            _tools: &[Tool],
1497        ) -> Result<ModelTurn, CoreError> {
1498            let n = self.calls.fetch_add(1, Ordering::SeqCst);
1499            if n == 0 {
1500                Ok(ModelTurn {
1501                    text: String::new(),
1502                    tool_calls: vec![ToolCall {
1503                        id: "call_1".into(),
1504                        name: "shell".into(),
1505                        arguments: serde_json::json!({ "command": ["echo", "hello"] }),
1506                    }],
1507                })
1508            } else {
1509                Ok(ModelTurn {
1510                    text: format!("final reply for: {}", req.input),
1511                    tool_calls: vec![],
1512                })
1513            }
1514        }
1515    }
1516
1517    /// Returns a tool call with no `command` arg first, then a final reply.
1518    struct MissingCommandModel {
1519        calls: Arc<AtomicUsize>,
1520    }
1521
1522    #[async_trait]
1523    impl ModelClient for MissingCommandModel {
1524        async fn complete(&self, _req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1525            Ok(ModelResponse {
1526                text: String::new(),
1527            })
1528        }
1529        async fn complete_with_tools(
1530            &self,
1531            _req: &ModelRequest,
1532            _tools: &[Tool],
1533        ) -> Result<ModelTurn, CoreError> {
1534            let n = self.calls.fetch_add(1, Ordering::SeqCst);
1535            if n == 0 {
1536                Ok(ModelTurn {
1537                    text: String::new(),
1538                    tool_calls: vec![ToolCall {
1539                        id: "bad".into(),
1540                        name: "shell".into(),
1541                        arguments: serde_json::json!({}),
1542                    }],
1543                })
1544            } else {
1545                Ok(ModelTurn {
1546                    text: "recovered".into(),
1547                    tool_calls: vec![],
1548                })
1549            }
1550        }
1551    }
1552
1553    /// Always asks for the same tool call, to exercise the loop cap.
1554    struct LoopForeverModel;
1555
1556    #[async_trait]
1557    impl ModelClient for LoopForeverModel {
1558        async fn complete(&self, _req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1559            Ok(ModelResponse {
1560                text: String::new(),
1561            })
1562        }
1563        async fn complete_with_tools(
1564            &self,
1565            _req: &ModelRequest,
1566            _tools: &[Tool],
1567        ) -> Result<ModelTurn, CoreError> {
1568            Ok(ModelTurn {
1569                text: String::new(),
1570                tool_calls: vec![ToolCall {
1571                    id: "c".into(),
1572                    name: "shell".into(),
1573                    arguments: serde_json::json!({ "command": ["echo", "x"] }),
1574                }],
1575            })
1576        }
1577    }
1578}