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