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 serde_json::Value;
20use std::sync::{Arc, RwLock};
21use thiserror::Error;
22
23#[derive(Debug, Error)]
24pub enum CoreError {
25 #[error("memo error: {0}")]
26 Memo(#[from] agent_memo::MemoError),
27 #[error("sandbox error: {0}")]
28 Sandbox(#[from] agent_sandbox::SandboxError),
29 #[error("model error: {0}")]
30 Model(String),
31 #[error("config error: {0}")]
32 Config(String),
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ModelRequest {
38 pub system: String,
39 pub context: String,
40 pub input: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ModelResponse {
46 pub text: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct Tool {
52 pub name: String,
53 pub description: String,
54 pub parameters: Value,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct ToolCall {
61 pub id: String,
62 pub name: String,
63 pub arguments: Value,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub struct ToolResult {
70 pub call_id: String,
71 pub content: String,
72 pub is_error: bool,
73}
74
75#[derive(Debug, Clone, Default)]
77pub struct ModelTurn {
78 pub text: String,
79 pub tool_calls: Vec<ToolCall>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum AgentEvent {
88 Step {
90 phase: String,
91 label: Option<String>,
92 },
93 ToolCall {
95 id: String,
96 name: String,
97 arguments: Value,
98 result: ToolResult,
99 },
100 Token { text: String },
102 Done { text: String },
104}
105
106#[async_trait]
108pub trait ModelClient: Send + Sync {
109 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError>;
111
112 async fn stream(
116 &self,
117 req: &ModelRequest,
118 ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
119 let resp = self.complete(req).await?;
120 Ok(Box::pin(futures::stream::once(
121 async move { Ok(resp.text) },
122 )))
123 }
124
125 async fn complete_with_tools(
130 &self,
131 req: &ModelRequest,
132 _tools: &[Tool],
133 ) -> Result<ModelTurn, CoreError> {
134 let resp = self.complete(req).await?;
135 Ok(ModelTurn {
136 text: resp.text,
137 tool_calls: Vec::new(),
138 })
139 }
140}
141
142pub struct StubModel {
144 agent_name: String,
145}
146
147impl StubModel {
148 pub fn new(agent_name: &str) -> Self {
149 Self {
150 agent_name: agent_name.to_string(),
151 }
152 }
153}
154
155#[async_trait]
156impl ModelClient for StubModel {
157 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
158 let text = format!(
159 "[{}] (stub) context={} | input={}",
160 self.agent_name,
161 if req.context.is_empty() {
162 "<none>"
163 } else {
164 "<injected>"
165 },
166 req.input
167 );
168 Ok(ModelResponse { text })
169 }
170}
171
172#[cfg(feature = "openai")]
173pub use openai_impl::OpenAiModel;
174
175#[cfg(feature = "openai")]
176mod openai_impl {
177 use super::*;
178 use async_openai::types::{
179 ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage,
180 ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent,
181 ChatCompletionTool, ChatCompletionToolChoiceOption, ChatCompletionToolType,
182 CreateChatCompletionRequestArgs, FunctionObject,
183 };
184 use async_openai::{config::OpenAIConfig, Client};
185
186 pub struct OpenAiModel {
188 client: Client<OpenAIConfig>,
189 model: String,
190 }
191
192 impl OpenAiModel {
193 pub fn new(model: &str) -> Self {
194 let api_key = std::env::var("OPENAI_API_KEY").unwrap_or_default();
195 let mut config = OpenAIConfig::new().with_api_key(api_key);
196 if let Ok(base) = std::env::var("OPENAI_BASE_URL") {
200 let base = base.trim().trim_end_matches('/').to_string();
201 if !base.is_empty() {
202 config = config.with_api_base(base);
203 }
204 }
205 Self {
206 client: Client::with_config(config),
207 model: model.to_string(),
208 }
209 }
210 }
211
212 fn build_codex_tools(tools: &[Tool]) -> Vec<ChatCompletionTool> {
216 tools
217 .iter()
218 .map(|t| ChatCompletionTool {
219 r#type: ChatCompletionToolType::Function,
220 function: FunctionObject {
221 name: t.name.clone(),
222 description: Some(t.description.clone()),
223 parameters: Some(t.parameters.clone()),
224 strict: None,
225 },
226 })
227 .collect()
228 }
229
230 #[allow(deprecated)]
235 fn parse_response_calls(
236 message: &async_openai::types::ChatCompletionResponseMessage,
237 ) -> Vec<ToolCall> {
238 if let Some(calls) = &message.tool_calls {
239 return calls
240 .iter()
241 .map(|c| {
242 let arguments = serde_json::from_str(&c.function.arguments)
243 .unwrap_or(serde_json::Value::Null);
244 ToolCall {
245 id: c.id.clone(),
246 name: c.function.name.clone(),
247 arguments,
248 }
249 })
250 .collect();
251 }
252 if let Some(fc) = &message.function_call {
253 let arguments = serde_json::from_str(&fc.arguments).unwrap_or(serde_json::Value::Null);
254 return vec![ToolCall {
255 id: "fn_0".into(),
256 name: fc.name.clone(),
257 arguments,
258 }];
259 }
260 Vec::new()
261 }
262
263 #[async_trait]
264 impl ModelClient for OpenAiModel {
265 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
266 use async_openai::types::CreateChatCompletionRequestArgs;
267 let messages = vec![
268 ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
269 content: req.system.clone().into(),
270 ..Default::default()
271 }),
272 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
273 content: ChatCompletionRequestUserMessageContent::Text(format!(
274 "{}\n\nUSER: {}",
275 req.context, req.input
276 )),
277 ..Default::default()
278 }),
279 ];
280 let request = CreateChatCompletionRequestArgs::default()
281 .model(self.model.clone())
282 .messages(messages)
283 .build()
284 .map_err(|e| CoreError::Model(e.to_string()))?;
285 let resp = self
286 .client
287 .chat()
288 .create(request)
289 .await
290 .map_err(|e| CoreError::Model(e.to_string()))?;
291 let text = resp
292 .choices
293 .first()
294 .and_then(|c| c.message.content.clone())
295 .unwrap_or_default();
296 Ok(ModelResponse { text })
297 }
298
299 async fn stream(
300 &self,
301 req: &ModelRequest,
302 ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
303 use async_openai::types::CreateChatCompletionRequestArgs;
304 use futures::StreamExt as _;
305 let messages = vec![
306 ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
307 content: req.system.clone().into(),
308 ..Default::default()
309 }),
310 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
311 content: ChatCompletionRequestUserMessageContent::Text(format!(
312 "{}\n\nUSER: {}",
313 req.context, req.input
314 )),
315 ..Default::default()
316 }),
317 ];
318 let request = CreateChatCompletionRequestArgs::default()
319 .model(self.model.clone())
320 .messages(messages)
321 .stream(true)
322 .build()
323 .map_err(|e| CoreError::Model(e.to_string()))?;
324 let client = self.client.clone();
325 let s = async_stream::stream! {
326 let mut stream = match client.chat().create_stream(request).await {
327 Ok(s) => s,
328 Err(e) => {
329 yield Err(CoreError::Model(e.to_string()));
330 return;
331 }
332 };
333 while let Some(chunk) = stream.next().await {
334 match chunk {
335 Ok(resp) => {
336 if let Some(tok) = resp
337 .choices
338 .into_iter()
339 .next()
340 .and_then(|c| c.delta.content)
341 {
342 yield Ok(tok);
343 }
344 }
345 Err(e) => yield Err(CoreError::Model(e.to_string())),
346 }
347 }
348 };
349 Ok(Box::pin(s))
350 }
351
352 async fn complete_with_tools(
353 &self,
354 req: &ModelRequest,
355 tools: &[Tool],
356 ) -> Result<ModelTurn, CoreError> {
357 let messages = vec![
358 ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
359 content: req.system.clone().into(),
360 ..Default::default()
361 }),
362 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
363 content: ChatCompletionRequestUserMessageContent::Text(format!(
364 "{}\n\nUSER: {}",
365 req.context, req.input
366 )),
367 ..Default::default()
368 }),
369 ];
370 let tools = build_codex_tools(tools);
371 let mut args = CreateChatCompletionRequestArgs::default();
372 let mut b = args.model(self.model.clone()).messages(messages);
373 if !tools.is_empty() {
374 b = b
375 .tools(tools)
376 .tool_choice(ChatCompletionToolChoiceOption::Auto);
377 }
378 let request = b.build().map_err(|e| CoreError::Model(e.to_string()))?;
379 let resp = self
380 .client
381 .chat()
382 .create(request)
383 .await
384 .map_err(|e| CoreError::Model(e.to_string()))?;
385 let choice = resp
386 .choices
387 .first()
388 .ok_or_else(|| CoreError::Model("empty choices from model".into()))?;
389 let text = choice.message.content.clone().unwrap_or_default();
390 let tool_calls = parse_response_calls(&choice.message);
391 Ok(ModelTurn { text, tool_calls })
392 }
393 }
394
395 #[cfg(test)]
396 mod tests {
397 use super::*;
398 use async_openai::types::ChatCompletionMessageToolCall;
399 use async_openai::types::ChatCompletionResponseMessage;
400 use async_openai::types::ChatCompletionToolType;
401 use async_openai::types::FunctionCall;
402 use async_openai::types::Role;
403
404 #[test]
405 fn build_codex_tools_maps_schema() {
406 let tools = vec![Tool {
407 name: "shell".into(),
408 description: "run a command".into(),
409 parameters: serde_json::json!({
410 "type": "object",
411 "properties": { "command": { "type": "string" } }
412 }),
413 }];
414 let out = build_codex_tools(&tools);
415 assert_eq!(out.len(), 1);
416 assert_eq!(out[0].r#type, ChatCompletionToolType::Function);
417 assert_eq!(out[0].function.name, "shell");
418 assert_eq!(
419 out[0].function.description.as_deref(),
420 Some("run a command")
421 );
422 assert!(out[0].function.parameters.as_ref().unwrap().is_object());
423 }
424
425 #[test]
426 fn parse_response_calls_reads_modern_tool_calls() {
427 let message = ChatCompletionResponseMessage {
428 content: Some("thinking".into()),
429 refusal: None,
430 tool_calls: Some(vec![ChatCompletionMessageToolCall {
431 id: "call_1".into(),
432 r#type: ChatCompletionToolType::Function,
433 function: FunctionCall {
434 name: "shell".into(),
435 arguments: "{\"command\":[\"echo\",\"hi\"]}".into(),
436 },
437 }]),
438 role: Role::Assistant,
439 #[allow(deprecated)]
440 function_call: None,
441 };
442 let parsed = parse_response_calls(&message);
443 assert_eq!(parsed.len(), 1);
444 assert_eq!(parsed[0].id, "call_1");
445 assert_eq!(parsed[0].name, "shell");
446 assert_eq!(
447 parsed[0].arguments,
448 serde_json::json!({"command": ["echo", "hi"]})
449 );
450 }
451
452 #[test]
453 fn parse_response_calls_reads_legacy_function_call() {
454 let message = ChatCompletionResponseMessage {
455 content: Some("thinking".into()),
456 refusal: None,
457 tool_calls: None,
458 role: Role::Assistant,
459 #[allow(deprecated)]
460 function_call: Some(FunctionCall {
461 name: "shell".into(),
462 arguments: "{\"command\":[\"echo\",\"hi\"]}".into(),
463 }),
464 };
465 let parsed = parse_response_calls(&message);
466 assert_eq!(parsed.len(), 1);
467 assert_eq!(parsed[0].id, "fn_0");
468 assert_eq!(parsed[0].name, "shell");
469 }
470
471 #[test]
472 fn parse_response_calls_handles_invalid_json() {
473 let message = ChatCompletionResponseMessage {
474 content: None,
475 refusal: None,
476 tool_calls: Some(vec![ChatCompletionMessageToolCall {
477 id: "bad".into(),
478 r#type: ChatCompletionToolType::Function,
479 function: FunctionCall {
480 name: "shell".into(),
481 arguments: "not-json".into(),
482 },
483 }]),
484 role: Role::Assistant,
485 #[allow(deprecated)]
486 function_call: None,
487 };
488 let parsed = parse_response_calls(&message);
489 assert_eq!(parsed.len(), 1);
491 assert_eq!(parsed[0].arguments, serde_json::Value::Null);
492 }
493 }
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct AgentConfig {
499 pub session: String,
500 pub agent_name: String,
501 pub sandbox_provider: String,
502 pub model: String,
503}
504
505impl Default for AgentConfig {
506 fn default() -> Self {
507 Self {
508 session: "default".to_string(),
509 agent_name: "agent".to_string(),
510 sandbox_provider: "docker".to_string(),
515 model: "gpt-4o-mini".to_string(),
516 }
517 }
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
522pub struct Skill {
523 pub name: String,
524 pub body: String,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
529pub struct Rule {
530 pub name: String,
531 pub body: String,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
538pub struct Harness {
539 pub system_prompt: String,
540 pub skills: Vec<Skill>,
541 pub rules: Vec<Rule>,
542}
543
544impl Harness {
545 pub fn baseline(agent_name: &str) -> Self {
547 Self {
548 system_prompt: format!("You are {}.", agent_name),
549 skills: Vec::new(),
550 rules: Vec::new(),
551 }
552 }
553
554 pub fn is_baseline(&self, agent_name: &str) -> bool {
556 *self == Harness::baseline(agent_name)
557 }
558
559 pub fn system_text(&self) -> String {
561 let mut s = self.system_prompt.clone();
562 if !self.skills.is_empty() {
563 s.push_str("\n\n## Skills");
564 for sk in &self.skills {
565 s.push_str(&format!("\n- {}: {}", sk.name, sk.body));
566 }
567 }
568 if !self.rules.is_empty() {
569 s.push_str("\n\n## Rules");
570 for r in &self.rules {
571 s.push_str(&format!("\n- {}: {}", r.name, r.body));
572 }
573 }
574 s
575 }
576}
577
578pub struct ActiveHarness {
585 current: Arc<RwLock<Arc<Harness>>>,
586}
587
588impl ActiveHarness {
589 pub fn new(initial: Harness) -> Self {
590 Self {
591 current: Arc::new(RwLock::new(Arc::new(initial))),
592 }
593 }
594
595 pub fn baseline(agent_name: &str) -> Self {
597 Self::new(Harness::baseline(agent_name))
598 }
599
600 pub fn get(&self) -> Arc<Harness> {
602 self.current
603 .read()
604 .expect("active harness lock poisoned")
605 .clone()
606 }
607
608 pub fn set(&self, h: Harness) {
610 let mut g = self.current.write().expect("active harness lock poisoned");
611 *g = Arc::new(h);
612 }
613}
614
615pub struct Agent {
619 config: AgentConfig,
620 model: Arc<dyn ModelClient>,
621 memo: Arc<dyn MemoStore>,
622 sandbox: Arc<dyn Sandbox>,
623 harness: Arc<ActiveHarness>,
624}
625
626impl Agent {
627 pub fn with_harness(
630 config: AgentConfig,
631 model: Box<dyn ModelClient>,
632 memo: Arc<dyn MemoStore>,
633 harness: Arc<ActiveHarness>,
634 ) -> Result<Self, CoreError> {
635 let provider = SandboxProvider::parse(&config.sandbox_provider).ok_or_else(|| {
636 CoreError::Config(format!("unknown sandbox: {}", config.sandbox_provider))
637 })?;
638 let sandbox = Arc::from(
642 agent_sandbox::from_provider(provider)
643 .map_err(|e| CoreError::Config(format!("sandbox {}: {}", provider.as_str(), e)))?,
644 );
645 Ok(Self {
646 config,
647 model: Arc::from(model),
648 memo,
649 sandbox,
650 harness,
651 })
652 }
653
654 pub fn with_sandbox(
658 config: AgentConfig,
659 model: Box<dyn ModelClient>,
660 memo: Arc<dyn MemoStore>,
661 harness: Arc<ActiveHarness>,
662 sandbox: Arc<dyn Sandbox>,
663 ) -> Result<Self, CoreError> {
664 Ok(Self {
665 config,
666 model: Arc::from(model),
667 memo,
668 sandbox,
669 harness,
670 })
671 }
672
673 pub fn with_model(
676 config: AgentConfig,
677 model: Box<dyn ModelClient>,
678 memo: Arc<dyn MemoStore>,
679 ) -> Result<Self, CoreError> {
680 let harness = Arc::new(ActiveHarness::baseline(&config.agent_name));
681 Self::with_harness(config, model, memo, harness)
682 }
683
684 pub fn new(config: AgentConfig, memo: Arc<dyn MemoStore>) -> Result<Self, CoreError> {
686 let model: Box<dyn ModelClient> = {
687 #[cfg(feature = "openai")]
688 {
689 Box::new(OpenAiModel::new(&config.model))
690 }
691 #[cfg(not(feature = "openai"))]
692 {
693 let _ = &config.model;
694 Box::new(StubModel::new(&config.agent_name))
695 }
696 };
697 Self::with_model(config, model, memo)
698 }
699
700 pub fn session(&self) -> &str {
701 &self.config.session
702 }
703
704 pub fn harness(&self) -> Arc<ActiveHarness> {
706 self.harness.clone()
707 }
708
709 pub fn memo(&self) -> Arc<dyn MemoStore> {
711 self.memo.clone()
712 }
713
714 pub async fn run(&self, input: &str) -> Result<String, CoreError> {
716 let fragments = self
718 .memo
719 .recall(&RecallQuery::new(&self.config.session, input))
720 .await?;
721 let context = fragments
722 .iter()
723 .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
724 .collect::<Vec<_>>()
725 .join("\n");
726
727 self.memo
729 .memorize(ContextFragment::new(
730 &self.config.session,
731 FragmentKind::Message,
732 input,
733 ))
734 .await?;
735
736 let system = self.harness.get().system_text();
738 let req = ModelRequest {
739 system,
740 context,
741 input: input.to_string(),
742 };
743 let resp = self.model.complete(&req).await?;
744
745 self.memo
747 .memorize(ContextFragment::new(
748 &self.config.session,
749 FragmentKind::Message,
750 resp.text.clone(),
751 ))
752 .await?;
753
754 Ok(resp.text)
755 }
756
757 pub async fn exec_tool(&self, command: &[String]) -> Result<String, CoreError> {
759 let spec = agent_sandbox::ExecSpec::command(command.to_vec());
760 let handle = self.sandbox.spawn(&spec).await?;
761 let out = self.sandbox.exec(&handle, command).await?;
762 self.sandbox.destroy(handle).await?;
763 let captured = format!(
764 "exit={} stdout={} stderr={}",
765 out.exit_code, out.stdout, out.stderr
766 );
767 self.memo
768 .memorize(ContextFragment::new(
769 &self.config.session,
770 FragmentKind::ToolResult,
771 captured.clone(),
772 ))
773 .await?;
774 Ok(captured)
775 }
776
777 pub async fn run_stream(
782 &self,
783 input: &str,
784 ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
785 let fragments = self
787 .memo
788 .recall(&RecallQuery::new(&self.config.session, input))
789 .await?;
790 let context = fragments
791 .iter()
792 .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
793 .collect::<Vec<_>>()
794 .join("\n");
795
796 self.memo
798 .memorize(ContextFragment::new(
799 &self.config.session,
800 FragmentKind::Message,
801 input,
802 ))
803 .await?;
804
805 let system = self.harness.get().system_text();
807 let req = ModelRequest {
808 system,
809 context,
810 input: input.to_string(),
811 };
812 let upstream = self.model.stream(&req).await?;
813
814 let memo = self.memo.clone();
816 let session = self.config.session.clone();
817 let wrapped = async_stream::stream! {
818 let mut collected = String::new();
819 let mut upstream = upstream;
820 while let Some(item) = upstream.next().await {
821 match item {
822 Ok(tok) => {
823 collected.push_str(&tok);
824 yield Ok(tok);
825 }
826 Err(e) => {
827 yield Err(e);
828 return;
829 }
830 }
831 }
832 let _ = memo
834 .memorize(ContextFragment::new(&session, FragmentKind::Message, collected))
835 .await;
836 };
837 Ok(Box::pin(wrapped))
838 }
839
840 pub const MAX_AGENTIC_STEPS: usize = 8;
842
843 pub async fn exec_tool_call(&self, call: &ToolCall) -> Result<ToolResult, CoreError> {
848 sandbox_exec(&self.sandbox, &self.memo, &self.config.session, call).await
849 }
850
851 pub async fn run_event_stream(
862 &self,
863 input: &str,
864 tools: &[Tool],
865 ) -> Result<BoxStream<'static, Result<AgentEvent, CoreError>>, CoreError> {
866 let memo = self.memo.clone();
867 let model = self.model.clone();
868 let sandbox = self.sandbox.clone();
869 let harness = self.harness.clone();
870 let session = self.config.session.clone();
871 let tools: Vec<Tool> = tools.to_vec();
872 let input = input.to_string();
873
874 let fragments = memo.recall(&RecallQuery::new(&session, &input)).await?;
876 let initial_context = fragments
877 .iter()
878 .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
879 .collect::<Vec<_>>()
880 .join("\n");
881 memo.memorize(ContextFragment::new(
883 &session,
884 FragmentKind::Message,
885 input.clone(),
886 ))
887 .await?;
888
889 let wrapped = async_stream::stream! {
890 yield Ok(AgentEvent::Step { phase: "recall".into(), label: None });
891 let mut tool_log = String::new();
892 let mut step = 0usize;
893
894 loop {
895 step += 1;
896 if step > Agent::MAX_AGENTIC_STEPS {
897 yield Ok(AgentEvent::Step {
898 phase: "loop_guard".into(),
899 label: Some("max agentic steps exceeded".into()),
900 });
901 yield Ok(AgentEvent::Done { text: String::new() });
902 break;
903 }
904
905 yield Ok(AgentEvent::Step { phase: "model".into(), label: None });
906 let system = harness.get().system_text();
907 let context = if tool_log.is_empty() {
908 initial_context.clone()
909 } else {
910 format!("{}\n{}", initial_context, tool_log)
911 };
912 let req = ModelRequest {
913 system,
914 context,
915 input: input.clone(),
916 };
917 let turn = match model.complete_with_tools(&req, &tools).await {
918 Ok(t) => t,
919 Err(e) => {
920 yield Err(e);
921 return;
922 }
923 };
924
925 if turn.tool_calls.is_empty() {
926 yield Ok(AgentEvent::Token { text: turn.text.clone() });
928 let _ = memo
929 .memorize(ContextFragment::new(
930 &session,
931 FragmentKind::Message,
932 turn.text.clone(),
933 ))
934 .await;
935 yield Ok(AgentEvent::Done { text: turn.text });
936 break;
937 }
938
939 for call in &turn.tool_calls {
941 yield Ok(AgentEvent::Step {
942 phase: "tool_exec".into(),
943 label: Some(call.name.clone()),
944 });
945 let result = match sandbox_exec(&sandbox, &memo, &session, call).await {
946 Ok(r) => r,
947 Err(e) => ToolResult {
948 call_id: call.id.clone(),
949 content: e.to_string(),
950 is_error: true,
951 },
952 };
953 tool_log.push_str(&format!(
954 "\n\nTOOL_RESULT[{}]: {}",
955 call.name, result.content
956 ));
957 yield Ok(AgentEvent::ToolCall {
958 id: call.id.clone(),
959 name: call.name.clone(),
960 arguments: call.arguments.clone(),
961 result,
962 });
963 }
964 }
965 };
966 Ok(Box::pin(wrapped))
967 }
968
969 pub async fn run_agentic(&self, input: &str, tools: &[Tool]) -> Result<String, CoreError> {
971 let stream = self.run_event_stream(input, tools).await?;
972 let mut out = String::new();
973 let mut stream = stream;
974 while let Some(ev) = stream.next().await {
975 if let AgentEvent::Done { text } = ev? {
976 out = text;
977 break;
978 }
979 }
980 Ok(out)
981 }
982}
983
984async fn sandbox_exec(
987 sandbox: &Arc<dyn Sandbox>,
988 memo: &Arc<dyn MemoStore>,
989 session: &str,
990 call: &ToolCall,
991) -> Result<ToolResult, CoreError> {
992 let command: Vec<String> = call
993 .arguments
994 .get("command")
995 .and_then(|v| v.as_array())
996 .map(|a| {
997 a.iter()
998 .filter_map(|x| x.as_str().map(String::from))
999 .collect()
1000 })
1001 .ok_or_else(|| {
1002 CoreError::Model(format!("tool `{}` missing `command` array arg", call.name))
1003 })?;
1004 if command.is_empty() {
1005 return Err(CoreError::Model(format!(
1006 "tool `{}` command is empty",
1007 call.name
1008 )));
1009 }
1010 let spec = agent_sandbox::ExecSpec::command(command.clone());
1011 let handle = sandbox.spawn(&spec).await?;
1012 let out = sandbox.exec(&handle, &command).await?;
1013 sandbox.destroy(handle).await?;
1014 let content = format!(
1015 "exit={} stdout={} stderr={}",
1016 out.exit_code, out.stdout, out.stderr
1017 );
1018 memo.memorize(ContextFragment::new(
1019 session,
1020 FragmentKind::ToolResult,
1021 content.clone(),
1022 ))
1023 .await?;
1024 Ok(ToolResult {
1025 call_id: call.id.clone(),
1026 content,
1027 is_error: false,
1028 })
1029}
1030
1031pub fn in_memory_memo() -> Arc<dyn MemoStore> {
1033 SledMemoStore::memory().expect("sled temp store")
1034}
1035
1036pub fn default_sandbox_box() -> Box<dyn Sandbox> {
1038 default_sandbox()
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044
1045 #[tokio::test]
1046 async fn run_injects_memo_and_persists() {
1047 let memo = in_memory_memo();
1048 let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
1049 let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
1050 let r1 = agent.run("hello").await.unwrap();
1051 assert!(r1.contains("hello"));
1052 let _ = agent.run("recap").await.unwrap();
1054 let frags = memo
1055 .recall(&RecallQuery::new("default", "hello"))
1056 .await
1057 .unwrap();
1058 assert!(frags.iter().any(|f| f.content == "hello"));
1059 }
1060
1061 #[tokio::test]
1062 async fn exec_tool_runs_in_sandbox() {
1063 let memo = in_memory_memo();
1064 let agent = Agent::new(AgentConfig::default(), memo).unwrap();
1065 match agent.exec_tool(&["echo".into(), "hi".into()]).await {
1067 Ok(out) => assert!(out.contains("hi")),
1068 Err(_) => { }
1069 }
1070 }
1071
1072 #[tokio::test]
1073 async fn run_stream_emits_tokens_and_persists() {
1074 let memo = in_memory_memo();
1075 let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
1076 let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
1077 let stream = agent.run_stream("hello").await.unwrap();
1078 let mut collected = String::new();
1079 let mut s = stream;
1080 while let Some(tok) = s.next().await {
1081 collected.push_str(&tok.unwrap());
1082 }
1083 assert!(collected.contains("hello"));
1084 let frags = memo
1086 .recall(&RecallQuery::new("default", "hello"))
1087 .await
1088 .unwrap();
1089 assert!(frags.iter().any(|f| f.content == "hello"));
1090 }
1091
1092 #[tokio::test]
1093 async fn run_second_turn_injects_prior_context() {
1094 let memo = in_memory_memo();
1095 let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
1096 let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
1097 let _ = agent.run("remember the secret code 1234").await.unwrap();
1098 let second = agent.run("what was the code?").await.unwrap();
1099 assert!(second.contains("<injected>"));
1101 }
1102
1103 #[tokio::test]
1104 async fn unknown_sandbox_provider_is_config_error() {
1105 let cfg = AgentConfig {
1106 sandbox_provider: "bogus".into(),
1107 ..AgentConfig::default()
1108 };
1109 let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
1110 let res = Agent::with_model(cfg, model, in_memory_memo());
1111 assert!(matches!(res, Err(CoreError::Config(_))));
1112 }
1113
1114 #[test]
1115 fn core_error_converts_from_memo() {
1116 let e: CoreError = agent_memo::MemoError::NotFound("x".into()).into();
1117 assert!(matches!(e, CoreError::Memo(_)));
1118 }
1119
1120 #[test]
1123 fn harness_baseline_equals_legacy_system() {
1124 let h = Harness::baseline("helper");
1125 assert_eq!(h.system_text(), "You are helper.");
1126 assert!(h.skills.is_empty());
1127 assert!(h.rules.is_empty());
1128 }
1129
1130 #[test]
1131 fn harness_system_text_assembles_skills_and_rules() {
1132 let h = Harness {
1133 system_prompt: "You are a bot.".into(),
1134 skills: vec![Skill {
1135 name: "summarize".into(),
1136 body: "condense text".into(),
1137 }],
1138 rules: vec![Rule {
1139 name: "no_pii".into(),
1140 body: "never echo secrets".into(),
1141 }],
1142 };
1143 let s = h.system_text();
1144 assert!(s.contains("You are a bot."));
1145 assert!(s.contains("## Skills"));
1146 assert!(s.contains("summarize: condense text"));
1147 assert!(s.contains("## Rules"));
1148 assert!(s.contains("no_pii: never echo secrets"));
1149 }
1150
1151 #[test]
1152 fn harness_serialization_roundtrip() {
1153 let h = Harness {
1154 system_prompt: "sys".into(),
1155 skills: vec![Skill {
1156 name: "s".into(),
1157 body: "b".into(),
1158 }],
1159 rules: vec![],
1160 };
1161 let json = serde_json::to_string(&h).unwrap();
1162 let back: Harness = serde_json::from_str(&json).unwrap();
1163 assert_eq!(h, back);
1164 }
1165
1166 #[test]
1167 fn active_harness_hot_swap_is_atomic() {
1168 let ah = ActiveHarness::baseline("agent");
1169 assert!(ah.get().is_baseline("agent"));
1170 let snap = ah.get();
1172 ah.set(Harness::baseline("renamed"));
1173 assert!(snap.is_baseline("agent"));
1175 assert!(ah.get().is_baseline("renamed"));
1176 }
1177
1178 struct EchoModel;
1181
1182 #[async_trait]
1183 impl ModelClient for EchoModel {
1184 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1185 Ok(ModelResponse {
1186 text: format!("SYSTEM[{}]", req.system),
1187 })
1188 }
1189 }
1190
1191 #[tokio::test]
1192 async fn run_uses_active_harness_and_hot_swaps() {
1193 let memo = in_memory_memo();
1194 let model: Box<dyn ModelClient> = Box::new(EchoModel);
1195 let harness = Arc::new(ActiveHarness::baseline("agent"));
1196 let agent =
1197 Agent::with_harness(AgentConfig::default(), model, memo, harness.clone()).unwrap();
1198
1199 let r1 = agent.run("hi").await.unwrap();
1200 assert!(
1201 r1.contains("You are agent."),
1202 "baseline system served: {r1}"
1203 );
1204
1205 harness.set(Harness {
1207 system_prompt: "Be terse.".into(),
1208 skills: vec![Skill {
1209 name: "short".into(),
1210 body: "reply in one line".into(),
1211 }],
1212 rules: vec![],
1213 });
1214 let r2 = agent.run("hi").await.unwrap();
1215 assert!(r2.contains("Be terse."), "swapped system served: {r2}");
1216 assert!(
1217 r2.contains("reply in one line"),
1218 "swapped skill served: {r2}"
1219 );
1220 }
1221
1222 #[tokio::test]
1223 async fn with_model_builds_baseline_harness() {
1224 let memo = in_memory_memo();
1225 let model: Box<dyn ModelClient> = Box::new(EchoModel);
1226 let agent = Agent::with_model(AgentConfig::default(), model, memo).unwrap();
1227 let r = agent.run("hi").await.unwrap();
1228 assert!(r.contains("You are agent."));
1229 }
1230
1231 use agent_sandbox::{ExecOutput, ExecSpec, SandboxError, SandboxHandle};
1234 use std::sync::atomic::{AtomicUsize, Ordering};
1235
1236 struct LocalSandbox;
1239
1240 #[async_trait]
1241 impl Sandbox for LocalSandbox {
1242 async fn spawn(&self, _spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
1243 Ok(SandboxHandle { id: "local".into() })
1244 }
1245 async fn exec(
1246 &self,
1247 _handle: &SandboxHandle,
1248 cmd: &[String],
1249 ) -> Result<ExecOutput, SandboxError> {
1250 let joined = cmd.join(" ");
1251 let out = tokio::process::Command::new("sh")
1252 .args(["-c", &joined])
1253 .output()
1254 .await
1255 .map_err(SandboxError::Io)?;
1256 Ok(ExecOutput {
1257 exit_code: out.status.code().unwrap_or(-1),
1258 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
1259 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
1260 })
1261 }
1262 async fn destroy(&self, _handle: SandboxHandle) -> Result<(), SandboxError> {
1263 Ok(())
1264 }
1265 }
1266
1267 fn local_agent(model: Box<dyn ModelClient>) -> Agent {
1268 let harness = Arc::new(ActiveHarness::baseline("agent"));
1269 Agent::with_sandbox(
1270 AgentConfig::default(),
1271 model,
1272 in_memory_memo(),
1273 harness,
1274 Arc::new(LocalSandbox),
1275 )
1276 .unwrap()
1277 }
1278
1279 #[test]
1280 fn agent_event_serde_uses_type_tag() {
1281 let tok = AgentEvent::Token { text: "hi".into() };
1282 let j = serde_json::to_string(&tok).unwrap();
1283 assert!(j.contains("\"type\":\"token\""));
1284 assert_eq!(serde_json::from_str::<AgentEvent>(&j).unwrap(), tok);
1285
1286 let tc = AgentEvent::ToolCall {
1287 id: "c".into(),
1288 name: "shell".into(),
1289 arguments: serde_json::json!({}),
1290 result: ToolResult {
1291 call_id: "c".into(),
1292 content: "x".into(),
1293 is_error: false,
1294 },
1295 };
1296 let j2 = serde_json::to_string(&tc).unwrap();
1297 assert!(j2.contains("\"type\":\"tool_call\""));
1298 assert_eq!(serde_json::from_str::<AgentEvent>(&j2).unwrap(), tc);
1299
1300 let step = AgentEvent::Step {
1301 phase: "recall".into(),
1302 label: None,
1303 };
1304 assert!(serde_json::to_string(&step)
1305 .unwrap()
1306 .contains("\"type\":\"step\""));
1307 assert!(
1308 serde_json::to_string(&AgentEvent::Done { text: "x".into() })
1309 .unwrap()
1310 .contains("\"type\":\"done\"")
1311 );
1312 }
1313
1314 #[tokio::test]
1315 async fn stub_model_complete_with_tools_has_no_calls() {
1316 let model = StubModel::new("agent");
1317 let req = ModelRequest {
1318 system: "s".into(),
1319 context: String::new(),
1320 input: "hi".into(),
1321 };
1322 let turn = model.complete_with_tools(&req, &[]).await.unwrap();
1323 assert!(turn.tool_calls.is_empty());
1324 assert!(turn.text.contains("hi"));
1325 }
1326
1327 #[tokio::test]
1328 async fn run_event_stream_single_shot_emits_events() {
1329 let memo = in_memory_memo();
1330 let agent = Agent::with_model(
1331 AgentConfig::default(),
1332 Box::new(StubModel::new("agent")),
1333 memo.clone(),
1334 )
1335 .unwrap();
1336 let stream = agent.run_event_stream("hello", &[]).await.unwrap();
1337 let mut events = Vec::new();
1338 let mut stream = stream;
1339 while let Some(ev) = stream.next().await {
1340 events.push(ev.unwrap());
1341 }
1342 assert!(
1343 matches!(events.first(), Some(AgentEvent::Step { phase, .. }) if phase == "recall"),
1344 "first event must be the recall step"
1345 );
1346 assert!(events.iter().any(|e| matches!(e, AgentEvent::Token { .. })));
1347 assert!(
1348 matches!(events.last(), Some(AgentEvent::Done { .. })),
1349 "stream must terminate with Done"
1350 );
1351 let frags = memo
1353 .recall(&RecallQuery::new("default", "hello"))
1354 .await
1355 .unwrap();
1356 assert!(frags.iter().any(|f| f.content == "hello"));
1357 }
1358
1359 #[tokio::test]
1360 async fn run_agentic_executes_tool_and_refills_memo() {
1361 let agent = local_agent(Box::new(ToolLoopModel {
1362 calls: Arc::new(AtomicUsize::new(0)),
1363 }));
1364 let tools = vec![Tool {
1365 name: "shell".into(),
1366 description: "run a shell command".into(),
1367 parameters: serde_json::json!({}),
1368 }];
1369 let stream = agent.run_event_stream("do it", &tools).await.unwrap();
1370 let mut stream = stream;
1371 let mut results = Vec::new();
1372 while let Some(ev) = stream.next().await {
1373 if let AgentEvent::ToolCall { result, .. } = ev.unwrap() {
1374 results.push(result);
1375 }
1376 }
1377 assert_eq!(results.len(), 1, "exactly one tool call executed");
1378 assert!(results[0].content.contains("hello"));
1379 assert!(!results[0].is_error);
1380 let frags = agent
1382 .memo()
1383 .recall(&RecallQuery::new("default", "hello"))
1384 .await
1385 .unwrap();
1386 assert!(frags.iter().any(|f| f.content.contains("hello")));
1387 }
1388
1389 #[tokio::test]
1390 async fn run_agentic_records_tool_failure() {
1391 let agent = local_agent(Box::new(MissingCommandModel {
1392 calls: Arc::new(AtomicUsize::new(0)),
1393 }));
1394 let tools = vec![Tool {
1395 name: "shell".into(),
1396 description: "x".into(),
1397 parameters: serde_json::json!({}),
1398 }];
1399 let stream = agent.run_event_stream("fail", &tools).await.unwrap();
1400 let mut stream = stream;
1401 let mut saw_error = false;
1402 let mut done = false;
1403 while let Some(ev) = stream.next().await {
1404 match ev.unwrap() {
1405 AgentEvent::ToolCall { result, .. } => saw_error = saw_error || result.is_error,
1406 AgentEvent::Done { .. } => done = true,
1407 _ => {}
1408 }
1409 }
1410 assert!(
1411 saw_error,
1412 "missing command must surface as an error tool result"
1413 );
1414 assert!(done);
1415 }
1416
1417 #[tokio::test]
1418 async fn run_agentic_respects_loop_cap() {
1419 let agent = local_agent(Box::new(LoopForeverModel));
1420 let tools = vec![Tool {
1421 name: "shell".into(),
1422 description: "x".into(),
1423 parameters: serde_json::json!({}),
1424 }];
1425 let stream = agent.run_event_stream("loop", &tools).await.unwrap();
1426 let mut stream = stream;
1427 let mut model_steps = 0usize;
1428 let mut done = false;
1429 while let Some(ev) = stream.next().await {
1430 match ev.unwrap() {
1431 AgentEvent::Step { phase, .. } if phase == "model" => model_steps += 1,
1432 AgentEvent::Done { .. } => done = true,
1433 _ => {}
1434 }
1435 }
1436 assert!(done, "must terminate with a Done event even when looping");
1437 assert!(
1438 model_steps <= Agent::MAX_AGENTIC_STEPS,
1439 "model steps bounded by cap, got {model_steps}"
1440 );
1441 }
1442
1443 #[tokio::test]
1444 async fn exec_tool_call_runs_in_sandbox_and_persists() {
1445 let memo = in_memory_memo();
1446 let agent = Agent::with_sandbox(
1447 AgentConfig::default(),
1448 Box::new(StubModel::new("agent")),
1449 memo.clone(),
1450 Arc::new(ActiveHarness::baseline("agent")),
1451 Arc::new(LocalSandbox),
1452 )
1453 .unwrap();
1454 let call = ToolCall {
1455 id: "c1".into(),
1456 name: "shell".into(),
1457 arguments: serde_json::json!({ "command": ["echo", "hi"] }),
1458 };
1459 let res = agent.exec_tool_call(&call).await.unwrap();
1460 assert!(res.content.contains("hi"));
1461 assert!(!res.is_error);
1462 let frags = memo
1463 .recall(&RecallQuery::new("default", "hi"))
1464 .await
1465 .unwrap();
1466 assert!(frags.iter().any(|f| f.content.contains("hi")));
1467 }
1468
1469 #[tokio::test]
1470 async fn exec_tool_call_missing_command_errors() {
1471 let agent = local_agent(Box::new(StubModel::new("agent")));
1472 let call = ToolCall {
1473 id: "c1".into(),
1474 name: "shell".into(),
1475 arguments: serde_json::json!({}),
1476 };
1477 let res = agent.exec_tool_call(&call).await;
1478 assert!(matches!(res, Err(CoreError::Model(_))));
1479 }
1480
1481 struct ToolLoopModel {
1483 calls: Arc<AtomicUsize>,
1484 }
1485
1486 #[async_trait]
1487 impl ModelClient for ToolLoopModel {
1488 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1489 Ok(ModelResponse {
1490 text: format!("[stub] {}", req.input),
1491 })
1492 }
1493 async fn complete_with_tools(
1494 &self,
1495 req: &ModelRequest,
1496 _tools: &[Tool],
1497 ) -> Result<ModelTurn, CoreError> {
1498 let n = self.calls.fetch_add(1, Ordering::SeqCst);
1499 if n == 0 {
1500 Ok(ModelTurn {
1501 text: String::new(),
1502 tool_calls: vec![ToolCall {
1503 id: "call_1".into(),
1504 name: "shell".into(),
1505 arguments: serde_json::json!({ "command": ["echo", "hello"] }),
1506 }],
1507 })
1508 } else {
1509 Ok(ModelTurn {
1510 text: format!("final reply for: {}", req.input),
1511 tool_calls: vec![],
1512 })
1513 }
1514 }
1515 }
1516
1517 struct MissingCommandModel {
1519 calls: Arc<AtomicUsize>,
1520 }
1521
1522 #[async_trait]
1523 impl ModelClient for MissingCommandModel {
1524 async fn complete(&self, _req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1525 Ok(ModelResponse {
1526 text: String::new(),
1527 })
1528 }
1529 async fn complete_with_tools(
1530 &self,
1531 _req: &ModelRequest,
1532 _tools: &[Tool],
1533 ) -> Result<ModelTurn, CoreError> {
1534 let n = self.calls.fetch_add(1, Ordering::SeqCst);
1535 if n == 0 {
1536 Ok(ModelTurn {
1537 text: String::new(),
1538 tool_calls: vec![ToolCall {
1539 id: "bad".into(),
1540 name: "shell".into(),
1541 arguments: serde_json::json!({}),
1542 }],
1543 })
1544 } else {
1545 Ok(ModelTurn {
1546 text: "recovered".into(),
1547 tool_calls: vec![],
1548 })
1549 }
1550 }
1551 }
1552
1553 struct LoopForeverModel;
1555
1556 #[async_trait]
1557 impl ModelClient for LoopForeverModel {
1558 async fn complete(&self, _req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1559 Ok(ModelResponse {
1560 text: String::new(),
1561 })
1562 }
1563 async fn complete_with_tools(
1564 &self,
1565 _req: &ModelRequest,
1566 _tools: &[Tool],
1567 ) -> Result<ModelTurn, CoreError> {
1568 Ok(ModelTurn {
1569 text: String::new(),
1570 tool_calls: vec![ToolCall {
1571 id: "c".into(),
1572 name: "shell".into(),
1573 arguments: serde_json::json!({ "command": ["echo", "x"] }),
1574 }],
1575 })
1576 }
1577 }
1578}