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