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