1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ModelRequest {
37 pub system: String,
38 pub context: String,
39 pub input: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ModelResponse {
45 pub text: String,
46}
47
48#[async_trait]
50pub trait ModelClient: Send + Sync {
51 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError>;
53
54 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
68pub 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 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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
240pub struct Skill {
241 pub name: String,
242 pub body: String,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
247pub struct Rule {
248 pub name: String,
249 pub body: String,
250}
251
252#[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 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 pub fn is_baseline(&self, agent_name: &str) -> bool {
274 *self == Harness::baseline(agent_name)
275 }
276
277 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
296pub 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 pub fn baseline(agent_name: &str) -> Self {
315 Self::new(Harness::baseline(agent_name))
316 }
317
318 pub fn get(&self) -> Arc<Harness> {
320 self.current
321 .read()
322 .expect("active harness lock poisoned")
323 .clone()
324 }
325
326 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
333pub 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 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 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 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 pub fn harness(&self) -> Arc<ActiveHarness> {
399 self.harness.clone()
400 }
401
402 pub fn memo(&self) -> Arc<dyn MemoStore> {
404 self.memo.clone()
405 }
406
407 pub async fn run(&self, input: &str) -> Result<String, CoreError> {
409 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 self.memo
422 .memorize(ContextFragment::new(
423 &self.config.session,
424 FragmentKind::Message,
425 input,
426 ))
427 .await?;
428
429 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 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 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 pub async fn run_stream(
475 &self,
476 input: &str,
477 ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
478 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 self.memo
491 .memorize(ContextFragment::new(
492 &self.config.session,
493 FragmentKind::Message,
494 input,
495 ))
496 .await?;
497
498 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 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 let _ = memo
527 .memorize(ContextFragment::new(&session, FragmentKind::Message, collected))
528 .await;
529 };
530 Ok(Box::pin(wrapped))
531 }
532}
533
534pub fn in_memory_memo() -> Arc<dyn MemoStore> {
536 SledMemoStore::memory().expect("sled temp store")
537}
538
539pub 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 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 match agent.exec_tool(&["echo".into(), "hi".into()]).await {
570 Ok(out) => assert!(out.contains("hi")),
571 Err(_) => { }
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 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 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 #[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 let snap = ah.get();
675 ah.set(Harness::baseline("renamed"));
676 assert!(snap.is_baseline("agent"));
678 assert!(ah.get().is_baseline("renamed"));
679 }
680
681 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 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}