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