Skip to main content

agent_core/
lib.rs

1//! `agent-core` — the unified agent runtime.
2//!
3//! It wraps the codex harness-style agent loop and guarantees the **memo
4//! contract**: every [`Agent::run`] first [`recall`](agent_memo::MemoStore::recall)s
5//! context from memo (now vector/semantic recall — see `agent_memo::embed`), then
6//! after producing a reply [`memorize`](agent_memo::MemoStore::memorize)s
7//! both the user turn and the assistant reply. Tool execution is isolated in a
8//! [`Sandbox`](agent_sandbox::Sandbox).
9//!
10//! The LLM is accessed through [`ModelClient`]; a deterministic [`StubModel`]
11//! is the default so the runtime is runnable without an API key. Enable the
12//! `openai` feature to use the real OpenAI Responses/Chat API.
13
14use agent_memo::{ContextFragment, FragmentKind, MemoStore, RecallQuery, SledMemoStore};
15use agent_sandbox::{default_sandbox, Sandbox, SandboxProvider};
16use async_trait::async_trait;
17use futures::stream::{BoxStream, StreamExt};
18use serde::{Deserialize, Serialize};
19use std::sync::{Arc, RwLock};
20use thiserror::Error;
21
22#[derive(Debug, Error)]
23pub enum CoreError {
24    #[error("memo error: {0}")]
25    Memo(#[from] agent_memo::MemoError),
26    #[error("sandbox error: {0}")]
27    Sandbox(#[from] agent_sandbox::SandboxError),
28    #[error("model error: {0}")]
29    Model(String),
30    #[error("config error: {0}")]
31    Config(String),
32}
33
34/// A single model request.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ModelRequest {
37    pub system: String,
38    pub context: String,
39    pub input: String,
40}
41
42/// A single model response.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ModelResponse {
45    pub text: String,
46}
47
48/// LLM access seam. Implement this to plug in any backend.
49#[async_trait]
50pub trait ModelClient: Send + Sync {
51    /// Produce a complete reply in one shot.
52    async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError>;
53
54    /// Stream the reply as a sequence of tokens. The default implementation
55    /// yields the full [`ModelResponse::text`] as a single chunk, so backends
56    /// that only support non-streaming calls work unchanged.
57    async fn stream(
58        &self,
59        req: &ModelRequest,
60    ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
61        let resp = self.complete(req).await?;
62        Ok(Box::pin(futures::stream::once(
63            async move { Ok(resp.text) },
64        )))
65    }
66}
67
68/// Deterministic stand-in used when no API key / `openai` feature is present.
69pub struct StubModel {
70    agent_name: String,
71}
72
73impl StubModel {
74    pub fn new(agent_name: &str) -> Self {
75        Self {
76            agent_name: agent_name.to_string(),
77        }
78    }
79}
80
81#[async_trait]
82impl ModelClient for StubModel {
83    async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
84        let text = format!(
85            "[{}] (stub) context={} | input={}",
86            self.agent_name,
87            if req.context.is_empty() {
88                "<none>"
89            } else {
90                "<injected>"
91            },
92            req.input
93        );
94        Ok(ModelResponse { text })
95    }
96}
97
98#[cfg(feature = "openai")]
99pub use openai_impl::OpenAiModel;
100
101#[cfg(feature = "openai")]
102mod openai_impl {
103    use super::*;
104    use async_openai::types::{
105        ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage,
106        ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent,
107    };
108    use async_openai::{config::OpenAIConfig, Client};
109
110    /// Calls the OpenAI Chat Completions API as the agent's model backend.
111    pub struct OpenAiModel {
112        client: Client<OpenAIConfig>,
113        model: String,
114    }
115
116    impl OpenAiModel {
117        pub fn new(model: &str) -> Self {
118            let api_key = std::env::var("OPENAI_API_KEY").unwrap_or_default();
119            let config = OpenAIConfig::new().with_api_key(api_key);
120            Self {
121                client: Client::with_config(config),
122                model: model.to_string(),
123            }
124        }
125    }
126
127    #[async_trait]
128    impl ModelClient for OpenAiModel {
129        async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
130            use async_openai::types::CreateChatCompletionRequestArgs;
131            let messages = vec![
132                ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
133                    content: req.system.clone().into(),
134                    ..Default::default()
135                }),
136                ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
137                    content: ChatCompletionRequestUserMessageContent::Text(format!(
138                        "{}\n\nUSER: {}",
139                        req.context, req.input
140                    )),
141                    ..Default::default()
142                }),
143            ];
144            let request = CreateChatCompletionRequestArgs::default()
145                .model(self.model.clone())
146                .messages(messages)
147                .build()
148                .map_err(|e| CoreError::Model(e.to_string()))?;
149            let resp = self
150                .client
151                .chat()
152                .create(request)
153                .await
154                .map_err(|e| CoreError::Model(e.to_string()))?;
155            let text = resp
156                .choices
157                .first()
158                .and_then(|c| c.message.content.clone())
159                .unwrap_or_default();
160            Ok(ModelResponse { text })
161        }
162
163        async fn stream(
164            &self,
165            req: &ModelRequest,
166        ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
167            use async_openai::types::CreateChatCompletionRequestArgs;
168            use futures::StreamExt as _;
169            let messages = vec![
170                ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
171                    content: req.system.clone().into(),
172                    ..Default::default()
173                }),
174                ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
175                    content: ChatCompletionRequestUserMessageContent::Text(format!(
176                        "{}\n\nUSER: {}",
177                        req.context, req.input
178                    )),
179                    ..Default::default()
180                }),
181            ];
182            let request = CreateChatCompletionRequestArgs::default()
183                .model(self.model.clone())
184                .messages(messages)
185                .stream(true)
186                .build()
187                .map_err(|e| CoreError::Model(e.to_string()))?;
188            let client = self.client.clone();
189            let s = async_stream::stream! {
190                let mut stream = match client.chat().create_stream(request).await {
191                    Ok(s) => s,
192                    Err(e) => {
193                        yield Err(CoreError::Model(e.to_string()));
194                        return;
195                    }
196                };
197                while let Some(chunk) = stream.next().await {
198                    match chunk {
199                        Ok(resp) => {
200                            if let Some(tok) = resp
201                                .choices
202                                .into_iter()
203                                .next()
204                                .and_then(|c| c.delta.content)
205                            {
206                                yield Ok(tok);
207                            }
208                        }
209                        Err(e) => yield Err(CoreError::Model(e.to_string())),
210                    }
211                }
212            };
213            Ok(Box::pin(s))
214        }
215    }
216}
217
218/// Configuration for constructing an [`Agent`]. Serializable for SDK/Ffi.
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct AgentConfig {
221    pub session: String,
222    pub agent_name: String,
223    pub sandbox_provider: String,
224    pub model: String,
225}
226
227impl Default for AgentConfig {
228    fn default() -> Self {
229        Self {
230            session: "default".to_string(),
231            agent_name: "agent".to_string(),
232            sandbox_provider: "docker".to_string(),
233            model: "gpt-4o-mini".to_string(),
234        }
235    }
236}
237
238/// A single named skill — a reusable capability the agent may draw on.
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
240pub struct Skill {
241    pub name: String,
242    pub body: String,
243}
244
245/// A single named rule — a constraint the agent must obey.
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
247pub struct Rule {
248    pub name: String,
249    pub body: String,
250}
251
252/// The evolvable harness of an agent: system prompt plus its skills and rules.
253/// This is the unit Reef-style self-improvement evolves, versions in Git, and
254/// hot-serves back to the running agent.
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
256pub struct Harness {
257    pub system_prompt: String,
258    pub skills: Vec<Skill>,
259    pub rules: Vec<Rule>,
260}
261
262impl Harness {
263    /// Baseline harness equivalent to the legacy hardcoded system prompt.
264    pub fn baseline(agent_name: &str) -> Self {
265        Self {
266            system_prompt: format!("You are {}.", agent_name),
267            skills: Vec::new(),
268            rules: Vec::new(),
269        }
270    }
271
272    /// True when this harness is byte-for-byte the generated baseline.
273    pub fn is_baseline(&self, agent_name: &str) -> bool {
274        *self == Harness::baseline(agent_name)
275    }
276
277    /// Compose the system prompt sent to the model from the harness parts.
278    pub fn system_text(&self) -> String {
279        let mut s = self.system_prompt.clone();
280        if !self.skills.is_empty() {
281            s.push_str("\n\n## Skills");
282            for sk in &self.skills {
283                s.push_str(&format!("\n- {}: {}", sk.name, sk.body));
284            }
285        }
286        if !self.rules.is_empty() {
287            s.push_str("\n\n## Rules");
288            for r in &self.rules {
289                s.push_str(&format!("\n- {}: {}", r.name, r.body));
290            }
291        }
292        s
293    }
294}
295
296/// Hot-swappable handle to the currently-served harness.
297///
298/// Read-heavy, write-rare: [`get`](ActiveHarness::get) only clones an `Arc`
299/// (O(1), no async, no serialization), while [`set`](ActiveHarness::set)
300/// atomically replaces the served harness so subsequent turns pick it up
301/// without restarting the process.
302pub struct ActiveHarness {
303    current: Arc<RwLock<Arc<Harness>>>,
304}
305
306impl ActiveHarness {
307    pub fn new(initial: Harness) -> Self {
308        Self {
309            current: Arc::new(RwLock::new(Arc::new(initial))),
310        }
311    }
312
313    /// Baseline harness equivalent to the legacy system prompt.
314    pub fn baseline(agent_name: &str) -> Self {
315        Self::new(Harness::baseline(agent_name))
316    }
317
318    /// Clone the inner `Arc` (O(1)); no serialization, no async.
319    pub fn get(&self) -> Arc<Harness> {
320        self.current
321            .read()
322            .expect("active harness lock poisoned")
323            .clone()
324    }
325
326    /// Atomically replace the served harness with a new version.
327    pub fn set(&self, h: Harness) {
328        let mut g = self.current.write().expect("active harness lock poisoned");
329        *g = Arc::new(h);
330    }
331}
332
333/// The unified agent. Holds the model client, memo store, sandbox, and a
334/// shared handle to the currently-served [`ActiveHarness`] (which may be
335/// hot-swapped by a self-improvement loop).
336pub struct Agent {
337    config: AgentConfig,
338    model: Box<dyn ModelClient>,
339    memo: Arc<dyn MemoStore>,
340    sandbox: Box<dyn Sandbox>,
341    harness: Arc<ActiveHarness>,
342}
343
344impl Agent {
345    /// Build with an explicit model client (e.g. [`StubModel`] or [`OpenAiModel`])
346    /// and a shared, hot-swappable harness handle.
347    pub fn with_harness(
348        config: AgentConfig,
349        model: Box<dyn ModelClient>,
350        memo: Arc<dyn MemoStore>,
351        harness: Arc<ActiveHarness>,
352    ) -> Result<Self, CoreError> {
353        let provider = SandboxProvider::parse(&config.sandbox_provider).ok_or_else(|| {
354            CoreError::Config(format!("unknown sandbox: {}", config.sandbox_provider))
355        })?;
356        let sandbox = agent_sandbox::from_provider(provider);
357        Ok(Self {
358            config,
359            model,
360            memo,
361            sandbox,
362            harness,
363        })
364    }
365
366    /// Build with an explicit model client (e.g. [`StubModel`] or [`OpenAiModel`]).
367    /// A baseline harness is generated from the config's agent name.
368    pub fn with_model(
369        config: AgentConfig,
370        model: Box<dyn ModelClient>,
371        memo: Arc<dyn MemoStore>,
372    ) -> Result<Self, CoreError> {
373        let harness = Arc::new(ActiveHarness::baseline(&config.agent_name));
374        Self::with_harness(config, model, memo, harness)
375    }
376
377    /// Build using the default model (stub unless `openai` feature is on).
378    pub fn new(config: AgentConfig, memo: Arc<dyn MemoStore>) -> Result<Self, CoreError> {
379        let model: Box<dyn ModelClient> = {
380            #[cfg(feature = "openai")]
381            {
382                Box::new(OpenAiModel::new(&config.model))
383            }
384            #[cfg(not(feature = "openai"))]
385            {
386                let _ = &config.model;
387                Box::new(StubModel::new(&config.agent_name))
388            }
389        };
390        Self::with_model(config, model, memo)
391    }
392
393    pub fn session(&self) -> &str {
394        &self.config.session
395    }
396
397    /// Shared harness handle — hot-swappable by a self-improvement loop.
398    pub fn harness(&self) -> Arc<ActiveHarness> {
399        self.harness.clone()
400    }
401
402    /// Shared memo store (used by the SDK to expose `Session`).
403    pub fn memo(&self) -> Arc<dyn MemoStore> {
404        self.memo.clone()
405    }
406
407    /// Run one turn. Injects memo context, calls the model, persists both turns.
408    pub async fn run(&self, input: &str) -> Result<String, CoreError> {
409        // 1) recall context from memo (the ONLY context source)
410        let fragments = self
411            .memo
412            .recall(&RecallQuery::new(&self.config.session, input))
413            .await?;
414        let context = fragments
415            .iter()
416            .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
417            .collect::<Vec<_>>()
418            .join("\n");
419
420        // 2) persist the user turn
421        self.memo
422            .memorize(ContextFragment::new(
423                &self.config.session,
424                FragmentKind::Message,
425                input,
426            ))
427            .await?;
428
429        // 3) call the model
430        let system = self.harness.get().system_text();
431        let req = ModelRequest {
432            system,
433            context,
434            input: input.to_string(),
435        };
436        let resp = self.model.complete(&req).await?;
437
438        // 4) persist the assistant reply
439        self.memo
440            .memorize(ContextFragment::new(
441                &self.config.session,
442                FragmentKind::Message,
443                resp.text.clone(),
444            ))
445            .await?;
446
447        Ok(resp.text)
448    }
449
450    /// Run a shell command inside the sandbox and remember the result.
451    pub async fn exec_tool(&self, command: &[String]) -> Result<String, CoreError> {
452        let spec = agent_sandbox::ExecSpec::command(command.to_vec());
453        let handle = self.sandbox.spawn(&spec).await?;
454        let out = self.sandbox.exec(&handle, command).await?;
455        self.sandbox.destroy(handle).await?;
456        let captured = format!(
457            "exit={} stdout={} stderr={}",
458            out.exit_code, out.stdout, out.stderr
459        );
460        self.memo
461            .memorize(ContextFragment::new(
462                &self.config.session,
463                FragmentKind::ToolResult,
464                captured.clone(),
465            ))
466            .await?;
467        Ok(captured)
468    }
469
470    /// Stream one turn token-by-token. Mirrors [`Agent::run`] for the memo
471    /// contract (recall before / persist both turns after) but emits model
472    /// tokens as they arrive. The returned stream is `'static` and owns its
473    /// memo handle and model client, so the [`Agent`] may be dropped.
474    pub async fn run_stream(
475        &self,
476        input: &str,
477    ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
478        // 1) recall context from memo (the ONLY context source)
479        let fragments = self
480            .memo
481            .recall(&RecallQuery::new(&self.config.session, input))
482            .await?;
483        let context = fragments
484            .iter()
485            .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
486            .collect::<Vec<_>>()
487            .join("\n");
488
489        // 2) persist the user turn
490        self.memo
491            .memorize(ContextFragment::new(
492                &self.config.session,
493                FragmentKind::Message,
494                input,
495            ))
496            .await?;
497
498        // 3) call the model (streaming). The returned stream is owned / 'static.
499        let system = self.harness.get().system_text();
500        let req = ModelRequest {
501            system,
502            context,
503            input: input.to_string(),
504        };
505        let upstream = self.model.stream(&req).await?;
506
507        // 4) wrap so we can collect + persist the assistant reply at the end.
508        let memo = self.memo.clone();
509        let session = self.config.session.clone();
510        let wrapped = async_stream::stream! {
511            let mut collected = String::new();
512            let mut upstream = upstream;
513            while let Some(item) = upstream.next().await {
514                match item {
515                    Ok(tok) => {
516                        collected.push_str(&tok);
517                        yield Ok(tok);
518                    }
519                    Err(e) => {
520                        yield Err(e);
521                        return;
522                    }
523                }
524            }
525            // persist the assistant reply as a single memo fragment
526            let _ = memo
527                .memorize(ContextFragment::new(&session, FragmentKind::Message, collected))
528                .await;
529        };
530        Ok(Box::pin(wrapped))
531    }
532}
533
534/// Convenience: a memory-backed memo store for quick local use.
535pub fn in_memory_memo() -> Arc<dyn MemoStore> {
536    SledMemoStore::memory().expect("sled temp store")
537}
538
539/// Re-export the default sandbox constructor for callers that don't need config.
540pub fn default_sandbox_box() -> Box<dyn Sandbox> {
541    default_sandbox()
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[tokio::test]
549    async fn run_injects_memo_and_persists() {
550        let memo = in_memory_memo();
551        let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
552        let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
553        let r1 = agent.run("hello").await.unwrap();
554        assert!(r1.contains("hello"));
555        // second turn should recall the first
556        let _ = agent.run("recap").await.unwrap();
557        let frags = memo
558            .recall(&RecallQuery::new("default", "hello"))
559            .await
560            .unwrap();
561        assert!(frags.iter().any(|f| f.content == "hello"));
562    }
563
564    #[tokio::test]
565    async fn exec_tool_runs_in_sandbox() {
566        let memo = in_memory_memo();
567        let agent = Agent::new(AgentConfig::default(), memo).unwrap();
568        // Works only if `docker` is available; otherwise it errors gracefully.
569        match agent.exec_tool(&["echo".into(), "hi".into()]).await {
570            Ok(out) => assert!(out.contains("hi")),
571            Err(_) => { /* docker / sandbox / memo not available in this environment */ }
572        }
573    }
574
575    #[tokio::test]
576    async fn run_stream_emits_tokens_and_persists() {
577        let memo = in_memory_memo();
578        let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
579        let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
580        let stream = agent.run_stream("hello").await.unwrap();
581        let mut collected = String::new();
582        let mut s = stream;
583        while let Some(tok) = s.next().await {
584            collected.push_str(&tok.unwrap());
585        }
586        assert!(collected.contains("hello"));
587        // Both the user turn and the assistant reply are persisted to memo.
588        let frags = memo
589            .recall(&RecallQuery::new("default", "hello"))
590            .await
591            .unwrap();
592        assert!(frags.iter().any(|f| f.content == "hello"));
593    }
594
595    #[tokio::test]
596    async fn run_second_turn_injects_prior_context() {
597        let memo = in_memory_memo();
598        let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
599        let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
600        let _ = agent.run("remember the secret code 1234").await.unwrap();
601        let second = agent.run("what was the code?").await.unwrap();
602        // The stub emits `<injected>` only when recalled context is non-empty.
603        assert!(second.contains("<injected>"));
604    }
605
606    #[tokio::test]
607    async fn unknown_sandbox_provider_is_config_error() {
608        let cfg = AgentConfig {
609            sandbox_provider: "bogus".into(),
610            ..AgentConfig::default()
611        };
612        let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
613        let res = Agent::with_model(cfg, model, in_memory_memo());
614        assert!(matches!(res, Err(CoreError::Config(_))));
615    }
616
617    #[test]
618    fn core_error_converts_from_memo() {
619        let e: CoreError = agent_memo::MemoError::NotFound("x".into()).into();
620        assert!(matches!(e, CoreError::Memo(_)));
621    }
622
623    // --- Harness / ActiveHarness unit tests ---
624
625    #[test]
626    fn harness_baseline_equals_legacy_system() {
627        let h = Harness::baseline("helper");
628        assert_eq!(h.system_text(), "You are helper.");
629        assert!(h.skills.is_empty());
630        assert!(h.rules.is_empty());
631    }
632
633    #[test]
634    fn harness_system_text_assembles_skills_and_rules() {
635        let h = Harness {
636            system_prompt: "You are a bot.".into(),
637            skills: vec![Skill {
638                name: "summarize".into(),
639                body: "condense text".into(),
640            }],
641            rules: vec![Rule {
642                name: "no_pii".into(),
643                body: "never echo secrets".into(),
644            }],
645        };
646        let s = h.system_text();
647        assert!(s.contains("You are a bot."));
648        assert!(s.contains("## Skills"));
649        assert!(s.contains("summarize: condense text"));
650        assert!(s.contains("## Rules"));
651        assert!(s.contains("no_pii: never echo secrets"));
652    }
653
654    #[test]
655    fn harness_serialization_roundtrip() {
656        let h = Harness {
657            system_prompt: "sys".into(),
658            skills: vec![Skill {
659                name: "s".into(),
660                body: "b".into(),
661            }],
662            rules: vec![],
663        };
664        let json = serde_json::to_string(&h).unwrap();
665        let back: Harness = serde_json::from_str(&json).unwrap();
666        assert_eq!(h, back);
667    }
668
669    #[test]
670    fn active_harness_hot_swap_is_atomic() {
671        let ah = ActiveHarness::baseline("agent");
672        assert!(ah.get().is_baseline("agent"));
673        // Cloning the Arc is O(1) and shares the same harness.
674        let snap = ah.get();
675        ah.set(Harness::baseline("renamed"));
676        // The old snapshot is unaffected, but a fresh get() sees the new value.
677        assert!(snap.is_baseline("agent"));
678        assert!(ah.get().is_baseline("renamed"));
679    }
680
681    /// Test model that echoes the system prompt so we can assert which harness
682    /// was served on each turn.
683    struct EchoModel;
684
685    #[async_trait]
686    impl ModelClient for EchoModel {
687        async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
688            Ok(ModelResponse {
689                text: format!("SYSTEM[{}]", req.system),
690            })
691        }
692    }
693
694    #[tokio::test]
695    async fn run_uses_active_harness_and_hot_swaps() {
696        let memo = in_memory_memo();
697        let model: Box<dyn ModelClient> = Box::new(EchoModel);
698        let harness = Arc::new(ActiveHarness::baseline("agent"));
699        let agent =
700            Agent::with_harness(AgentConfig::default(), model, memo, harness.clone()).unwrap();
701
702        let r1 = agent.run("hi").await.unwrap();
703        assert!(
704            r1.contains("You are agent."),
705            "baseline system served: {r1}"
706        );
707
708        // Hot-swap the harness; the next turn must use the new system prompt.
709        harness.set(Harness {
710            system_prompt: "Be terse.".into(),
711            skills: vec![Skill {
712                name: "short".into(),
713                body: "reply in one line".into(),
714            }],
715            rules: vec![],
716        });
717        let r2 = agent.run("hi").await.unwrap();
718        assert!(r2.contains("Be terse."), "swapped system served: {r2}");
719        assert!(
720            r2.contains("reply in one line"),
721            "swapped skill served: {r2}"
722        );
723    }
724
725    #[tokio::test]
726    async fn with_model_builds_baseline_harness() {
727        let memo = in_memory_memo();
728        let model: Box<dyn ModelClient> = Box::new(EchoModel);
729        let agent = Agent::with_model(AgentConfig::default(), model, memo).unwrap();
730        let r = agent.run("hi").await.unwrap();
731        assert!(r.contains("You are agent."));
732    }
733}