1pub mod context;
19
20use agent_sandbox::{default_sandbox, Sandbox, SandboxProvider};
21use async_trait::async_trait;
22use context::{ContextFragment, ContextStore, FragmentKind, RecallQuery};
23use futures::stream::{BoxStream, StreamExt};
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26use std::sync::Arc;
27use thiserror::Error;
28
29#[derive(Debug, Error)]
30pub enum CoreError {
31 #[error("context error: {0}")]
32 Context(#[from] context::ContextError),
33 #[error("sandbox error: {0}")]
34 Sandbox(#[from] agent_sandbox::SandboxError),
35 #[error("model error: {0}")]
36 Model(String),
37 #[error("config error: {0}")]
38 Config(String),
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ModelRequest {
44 pub system: String,
45 pub context: String,
46 pub input: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ModelResponse {
52 pub text: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct Tool {
58 pub name: String,
59 pub description: String,
60 pub parameters: Value,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66pub struct ToolCall {
67 pub id: String,
68 pub name: String,
69 pub arguments: Value,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct ToolResult {
76 pub call_id: String,
77 pub content: String,
78 pub is_error: bool,
79}
80
81#[derive(Debug, Clone, Default)]
83pub struct ModelTurn {
84 pub text: String,
85 pub tool_calls: Vec<ToolCall>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
92#[serde(tag = "type", rename_all = "snake_case")]
93pub enum AgentEvent {
94 Step {
96 phase: String,
97 label: Option<String>,
98 },
99 ToolCall {
101 id: String,
102 name: String,
103 arguments: Value,
104 result: ToolResult,
105 },
106 Token { text: String },
108 Done { text: String },
110}
111
112#[async_trait]
114pub trait ModelClient: Send + Sync {
115 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError>;
117
118 async fn stream(
122 &self,
123 req: &ModelRequest,
124 ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
125 let resp = self.complete(req).await?;
126 Ok(Box::pin(futures::stream::once(
127 async move { Ok(resp.text) },
128 )))
129 }
130
131 async fn complete_with_tools(
136 &self,
137 req: &ModelRequest,
138 _tools: &[Tool],
139 ) -> Result<ModelTurn, CoreError> {
140 let resp = self.complete(req).await?;
141 Ok(ModelTurn {
142 text: resp.text,
143 tool_calls: Vec::new(),
144 })
145 }
146}
147
148pub struct StubModel {
150 agent_name: String,
151}
152
153impl StubModel {
154 pub fn new(agent_name: &str) -> Self {
155 Self {
156 agent_name: agent_name.to_string(),
157 }
158 }
159}
160
161#[async_trait]
162impl ModelClient for StubModel {
163 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
164 let text = format!(
165 "[{}] (stub) context={} | input={}",
166 self.agent_name,
167 if req.context.is_empty() {
168 "<none>"
169 } else {
170 "<injected>"
171 },
172 req.input
173 );
174 Ok(ModelResponse { text })
175 }
176}
177
178#[cfg(feature = "openai")]
179pub use openai_impl::OpenAiModel;
180
181#[cfg(feature = "openai")]
182mod openai_impl {
183 use super::*;
184 use async_openai::types::{
185 ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage,
186 ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent,
187 ChatCompletionTool, ChatCompletionToolChoiceOption, ChatCompletionToolType,
188 CreateChatCompletionRequestArgs, FunctionObject,
189 };
190 use async_openai::{config::OpenAIConfig, Client};
191
192 pub struct OpenAiModel {
194 client: Client<OpenAIConfig>,
195 model: String,
196 }
197
198 impl OpenAiModel {
199 pub fn new(model: &str) -> Self {
200 let api_key = std::env::var("OPENAI_API_KEY").unwrap_or_default();
201 let mut config = OpenAIConfig::new().with_api_key(api_key);
202 if let Ok(base) = std::env::var("OPENAI_BASE_URL") {
206 let base = base.trim().trim_end_matches('/').to_string();
207 if !base.is_empty() {
208 config = config.with_api_base(base);
209 }
210 }
211 Self {
212 client: Client::with_config(config),
213 model: model.to_string(),
214 }
215 }
216 }
217
218 fn build_codex_tools(tools: &[Tool]) -> Vec<ChatCompletionTool> {
222 tools
223 .iter()
224 .map(|t| ChatCompletionTool {
225 r#type: ChatCompletionToolType::Function,
226 function: FunctionObject {
227 name: t.name.clone(),
228 description: Some(t.description.clone()),
229 parameters: Some(t.parameters.clone()),
230 strict: None,
231 },
232 })
233 .collect()
234 }
235
236 #[allow(deprecated)]
241 fn parse_response_calls(
242 message: &async_openai::types::ChatCompletionResponseMessage,
243 ) -> Vec<ToolCall> {
244 if let Some(calls) = &message.tool_calls {
245 return calls
246 .iter()
247 .map(|c| {
248 let arguments = serde_json::from_str(&c.function.arguments)
249 .unwrap_or(serde_json::Value::Null);
250 ToolCall {
251 id: c.id.clone(),
252 name: c.function.name.clone(),
253 arguments,
254 }
255 })
256 .collect();
257 }
258 if let Some(fc) = &message.function_call {
259 let arguments = serde_json::from_str(&fc.arguments).unwrap_or(serde_json::Value::Null);
260 return vec![ToolCall {
261 id: "fn_0".into(),
262 name: fc.name.clone(),
263 arguments,
264 }];
265 }
266 Vec::new()
267 }
268
269 #[async_trait]
270 impl ModelClient for OpenAiModel {
271 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
272 use async_openai::types::CreateChatCompletionRequestArgs;
273 let messages = vec![
274 ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
275 content: req.system.clone().into(),
276 ..Default::default()
277 }),
278 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
279 content: ChatCompletionRequestUserMessageContent::Text(format!(
280 "{}\n\nUSER: {}",
281 req.context, req.input
282 )),
283 ..Default::default()
284 }),
285 ];
286 let request = CreateChatCompletionRequestArgs::default()
287 .model(self.model.clone())
288 .messages(messages)
289 .build()
290 .map_err(|e| CoreError::Model(e.to_string()))?;
291 let resp = self
292 .client
293 .chat()
294 .create(request)
295 .await
296 .map_err(|e| CoreError::Model(e.to_string()))?;
297 let text = resp
298 .choices
299 .first()
300 .and_then(|c| c.message.content.clone())
301 .unwrap_or_default();
302 Ok(ModelResponse { text })
303 }
304
305 async fn stream(
306 &self,
307 req: &ModelRequest,
308 ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
309 use async_openai::types::CreateChatCompletionRequestArgs;
310 use futures::StreamExt as _;
311 let messages = vec![
312 ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
313 content: req.system.clone().into(),
314 ..Default::default()
315 }),
316 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
317 content: ChatCompletionRequestUserMessageContent::Text(format!(
318 "{}\n\nUSER: {}",
319 req.context, req.input
320 )),
321 ..Default::default()
322 }),
323 ];
324 let request = CreateChatCompletionRequestArgs::default()
325 .model(self.model.clone())
326 .messages(messages)
327 .stream(true)
328 .build()
329 .map_err(|e| CoreError::Model(e.to_string()))?;
330 let client = self.client.clone();
331 let s = async_stream::stream! {
332 let mut stream = match client.chat().create_stream(request).await {
333 Ok(s) => s,
334 Err(e) => {
335 yield Err(CoreError::Model(e.to_string()));
336 return;
337 }
338 };
339 while let Some(chunk) = stream.next().await {
340 match chunk {
341 Ok(resp) => {
342 if let Some(tok) = resp
343 .choices
344 .into_iter()
345 .next()
346 .and_then(|c| c.delta.content)
347 {
348 yield Ok(tok);
349 }
350 }
351 Err(e) => yield Err(CoreError::Model(e.to_string())),
352 }
353 }
354 };
355 Ok(Box::pin(s))
356 }
357
358 async fn complete_with_tools(
359 &self,
360 req: &ModelRequest,
361 tools: &[Tool],
362 ) -> Result<ModelTurn, CoreError> {
363 let messages = vec![
364 ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
365 content: req.system.clone().into(),
366 ..Default::default()
367 }),
368 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
369 content: ChatCompletionRequestUserMessageContent::Text(format!(
370 "{}\n\nUSER: {}",
371 req.context, req.input
372 )),
373 ..Default::default()
374 }),
375 ];
376 let tools = build_codex_tools(tools);
377 let mut args = CreateChatCompletionRequestArgs::default();
378 let mut b = args.model(self.model.clone()).messages(messages);
379 if !tools.is_empty() {
380 b = b
381 .tools(tools)
382 .tool_choice(ChatCompletionToolChoiceOption::Auto);
383 }
384 let request = b.build().map_err(|e| CoreError::Model(e.to_string()))?;
385 let resp = self
386 .client
387 .chat()
388 .create(request)
389 .await
390 .map_err(|e| CoreError::Model(e.to_string()))?;
391 let choice = resp
392 .choices
393 .first()
394 .ok_or_else(|| CoreError::Model("empty choices from model".into()))?;
395 let text = choice.message.content.clone().unwrap_or_default();
396 let tool_calls = parse_response_calls(&choice.message);
397 Ok(ModelTurn { text, tool_calls })
398 }
399 }
400
401 #[cfg(test)]
402 mod tests {
403 use super::*;
404 use async_openai::types::ChatCompletionMessageToolCall;
405 use async_openai::types::ChatCompletionResponseMessage;
406 use async_openai::types::ChatCompletionToolType;
407 use async_openai::types::FunctionCall;
408 use async_openai::types::Role;
409
410 #[test]
411 fn build_codex_tools_maps_schema() {
412 let tools = vec![Tool {
413 name: "shell".into(),
414 description: "run a command".into(),
415 parameters: serde_json::json!({
416 "type": "object",
417 "properties": { "command": { "type": "string" } }
418 }),
419 }];
420 let out = build_codex_tools(&tools);
421 assert_eq!(out.len(), 1);
422 assert_eq!(out[0].r#type, ChatCompletionToolType::Function);
423 assert_eq!(out[0].function.name, "shell");
424 assert_eq!(
425 out[0].function.description.as_deref(),
426 Some("run a command")
427 );
428 assert!(out[0].function.parameters.as_ref().unwrap().is_object());
429 }
430
431 #[test]
432 fn parse_response_calls_reads_modern_tool_calls() {
433 let message = ChatCompletionResponseMessage {
434 content: Some("thinking".into()),
435 refusal: None,
436 tool_calls: Some(vec![ChatCompletionMessageToolCall {
437 id: "call_1".into(),
438 r#type: ChatCompletionToolType::Function,
439 function: FunctionCall {
440 name: "shell".into(),
441 arguments: "{\"command\":[\"echo\",\"hi\"]}".into(),
442 },
443 }]),
444 role: Role::Assistant,
445 #[allow(deprecated)]
446 function_call: None,
447 };
448 let parsed = parse_response_calls(&message);
449 assert_eq!(parsed.len(), 1);
450 assert_eq!(parsed[0].id, "call_1");
451 assert_eq!(parsed[0].name, "shell");
452 assert_eq!(
453 parsed[0].arguments,
454 serde_json::json!({"command": ["echo", "hi"]})
455 );
456 }
457
458 #[test]
459 fn parse_response_calls_reads_legacy_function_call() {
460 let message = ChatCompletionResponseMessage {
461 content: Some("thinking".into()),
462 refusal: None,
463 tool_calls: None,
464 role: Role::Assistant,
465 #[allow(deprecated)]
466 function_call: Some(FunctionCall {
467 name: "shell".into(),
468 arguments: "{\"command\":[\"echo\",\"hi\"]}".into(),
469 }),
470 };
471 let parsed = parse_response_calls(&message);
472 assert_eq!(parsed.len(), 1);
473 assert_eq!(parsed[0].id, "fn_0");
474 assert_eq!(parsed[0].name, "shell");
475 }
476
477 #[test]
478 fn parse_response_calls_handles_invalid_json() {
479 let message = ChatCompletionResponseMessage {
480 content: None,
481 refusal: None,
482 tool_calls: Some(vec![ChatCompletionMessageToolCall {
483 id: "bad".into(),
484 r#type: ChatCompletionToolType::Function,
485 function: FunctionCall {
486 name: "shell".into(),
487 arguments: "not-json".into(),
488 },
489 }]),
490 role: Role::Assistant,
491 #[allow(deprecated)]
492 function_call: None,
493 };
494 let parsed = parse_response_calls(&message);
495 assert_eq!(parsed.len(), 1);
497 assert_eq!(parsed[0].arguments, serde_json::Value::Null);
498 }
499 }
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize)]
504pub struct AgentConfig {
505 pub session: String,
506 pub agent_name: String,
507 pub sandbox_provider: String,
508 pub model: String,
509 #[serde(default)]
511 pub instructions: String,
512}
513
514impl Default for AgentConfig {
515 fn default() -> Self {
516 Self {
517 session: "default".to_string(),
518 agent_name: "agent".to_string(),
519 instructions: String::new(),
520 sandbox_provider: "docker".to_string(),
525 model: "gpt-4o-mini".to_string(),
526 }
527 }
528}
529
530pub struct Agent {
534 config: AgentConfig,
535 model: Arc<dyn ModelClient>,
536 context: Arc<dyn ContextStore>,
537 sandbox: Arc<dyn Sandbox>,
538}
539
540impl Agent {
541 pub fn with_sandbox(
545 config: AgentConfig,
546 model: Box<dyn ModelClient>,
547 context: Arc<dyn ContextStore>,
548 sandbox: Arc<dyn Sandbox>,
549 ) -> Result<Self, CoreError> {
550 Ok(Self {
551 config,
552 model: Arc::from(model),
553 context,
554 sandbox,
555 })
556 }
557
558 pub fn with_model(
561 config: AgentConfig,
562 model: Box<dyn ModelClient>,
563 context: Arc<dyn ContextStore>,
564 ) -> Result<Self, CoreError> {
565 let provider = SandboxProvider::parse(&config.sandbox_provider).ok_or_else(|| {
566 CoreError::Config(format!("unknown sandbox: {}", config.sandbox_provider))
567 })?;
568 let sandbox = Arc::from(
572 agent_sandbox::from_provider(provider)
573 .map_err(|e| CoreError::Config(format!("sandbox {}: {}", provider.as_str(), e)))?,
574 );
575 Self::with_sandbox(config, model, context, sandbox)
576 }
577
578 pub fn new(config: AgentConfig, context: Arc<dyn ContextStore>) -> Result<Self, CoreError> {
580 let model: Box<dyn ModelClient> = {
581 #[cfg(feature = "openai")]
582 {
583 Box::new(OpenAiModel::new(&config.model))
584 }
585 #[cfg(not(feature = "openai"))]
586 {
587 let _ = &config.model;
588 Box::new(StubModel::new(&config.agent_name))
589 }
590 };
591 Self::with_model(config, model, context)
592 }
593
594 pub fn session(&self) -> &str {
595 &self.config.session
596 }
597
598 pub fn system_text(&self) -> String {
601 let base = format!("You are {}.", self.config.agent_name);
602 if self.config.instructions.trim().is_empty() {
603 base
604 } else {
605 format!("{}\n\n{}", base, self.config.instructions.trim())
606 }
607 }
608
609 pub fn context(&self) -> Arc<dyn ContextStore> {
611 self.context.clone()
612 }
613
614 pub async fn run(&self, input: &str) -> Result<String, CoreError> {
616 let fragments = self
618 .context
619 .recall(&RecallQuery::new(&self.config.session, input))
620 .await?;
621 let context = fragments
622 .iter()
623 .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
624 .collect::<Vec<_>>()
625 .join("\n");
626
627 self.context
629 .memorize(ContextFragment::new(
630 &self.config.session,
631 FragmentKind::Message,
632 input,
633 ))
634 .await?;
635
636 let system = self.system_text();
638 let req = ModelRequest {
639 system,
640 context,
641 input: input.to_string(),
642 };
643 let resp = self.model.complete(&req).await?;
644
645 self.context
647 .memorize(ContextFragment::new(
648 &self.config.session,
649 FragmentKind::Message,
650 resp.text.clone(),
651 ))
652 .await?;
653
654 Ok(resp.text)
655 }
656
657 pub async fn exec_tool(&self, command: &[String]) -> Result<String, CoreError> {
659 let spec = agent_sandbox::ExecSpec::command(command.to_vec());
660 let handle = self.sandbox.spawn(&spec).await?;
661 let out = self.sandbox.exec(&handle, command).await?;
662 self.sandbox.destroy(handle).await?;
663 let captured = format!(
664 "exit={} stdout={} stderr={}",
665 out.exit_code, out.stdout, out.stderr
666 );
667 self.context
668 .memorize(ContextFragment::new(
669 &self.config.session,
670 FragmentKind::ToolResult,
671 captured.clone(),
672 ))
673 .await?;
674 Ok(captured)
675 }
676
677 pub async fn run_stream(
682 &self,
683 input: &str,
684 ) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
685 let fragments = self
687 .context
688 .recall(&RecallQuery::new(&self.config.session, input))
689 .await?;
690 let context = fragments
691 .iter()
692 .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
693 .collect::<Vec<_>>()
694 .join("\n");
695
696 self.context
698 .memorize(ContextFragment::new(
699 &self.config.session,
700 FragmentKind::Message,
701 input,
702 ))
703 .await?;
704
705 let system = self.system_text();
707 let req = ModelRequest {
708 system,
709 context,
710 input: input.to_string(),
711 };
712 let upstream = self.model.stream(&req).await?;
713
714 let memo = self.context.clone();
716 let session = self.config.session.clone();
717 let wrapped = async_stream::stream! {
718 let mut collected = String::new();
719 let mut upstream = upstream;
720 while let Some(item) = upstream.next().await {
721 match item {
722 Ok(tok) => {
723 collected.push_str(&tok);
724 yield Ok(tok);
725 }
726 Err(e) => {
727 yield Err(e);
728 return;
729 }
730 }
731 }
732 let _ = memo
734 .memorize(ContextFragment::new(&session, FragmentKind::Message, collected))
735 .await;
736 };
737 Ok(Box::pin(wrapped))
738 }
739
740 pub const MAX_AGENTIC_STEPS: usize = 8;
742
743 pub async fn exec_tool_call(&self, call: &ToolCall) -> Result<ToolResult, CoreError> {
748 sandbox_exec(&self.sandbox, &self.context, &self.config.session, call).await
749 }
750
751 pub async fn run_event_stream(
762 &self,
763 input: &str,
764 tools: &[Tool],
765 ) -> Result<BoxStream<'static, Result<AgentEvent, CoreError>>, CoreError> {
766 let memo = self.context.clone();
767 let model = self.model.clone();
768 let sandbox = self.sandbox.clone();
769 let system = self.system_text();
770 let session = self.config.session.clone();
771 let tools: Vec<Tool> = tools.to_vec();
772 let input = input.to_string();
773
774 let fragments = memo.recall(&RecallQuery::new(&session, &input)).await?;
776 let initial_context = fragments
777 .iter()
778 .map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
779 .collect::<Vec<_>>()
780 .join("\n");
781 memo.memorize(ContextFragment::new(
783 &session,
784 FragmentKind::Message,
785 input.clone(),
786 ))
787 .await?;
788
789 let wrapped = async_stream::stream! {
790 yield Ok(AgentEvent::Step { phase: "recall".into(), label: None });
791 let mut tool_log = String::new();
792 let mut step = 0usize;
793
794 loop {
795 step += 1;
796 if step > Agent::MAX_AGENTIC_STEPS {
797 yield Ok(AgentEvent::Step {
798 phase: "loop_guard".into(),
799 label: Some("max agentic steps exceeded".into()),
800 });
801 yield Ok(AgentEvent::Done { text: String::new() });
802 break;
803 }
804
805 yield Ok(AgentEvent::Step { phase: "model".into(), label: None });
806 let system = system.clone();
807 let context = if tool_log.is_empty() {
808 initial_context.clone()
809 } else {
810 format!("{}\n{}", initial_context, tool_log)
811 };
812 let req = ModelRequest {
813 system,
814 context,
815 input: input.clone(),
816 };
817 let turn = match model.complete_with_tools(&req, &tools).await {
818 Ok(t) => t,
819 Err(e) => {
820 yield Err(e);
821 return;
822 }
823 };
824
825 if turn.tool_calls.is_empty() {
826 yield Ok(AgentEvent::Token { text: turn.text.clone() });
828 let _ = memo
829 .memorize(ContextFragment::new(
830 &session,
831 FragmentKind::Message,
832 turn.text.clone(),
833 ))
834 .await;
835 yield Ok(AgentEvent::Done { text: turn.text });
836 break;
837 }
838
839 for call in &turn.tool_calls {
841 yield Ok(AgentEvent::Step {
842 phase: "tool_exec".into(),
843 label: Some(call.name.clone()),
844 });
845 let result = match sandbox_exec(&sandbox, &memo, &session, call).await {
846 Ok(r) => r,
847 Err(e) => ToolResult {
848 call_id: call.id.clone(),
849 content: e.to_string(),
850 is_error: true,
851 },
852 };
853 tool_log.push_str(&format!(
854 "\n\nTOOL_RESULT[{}]: {}",
855 call.name, result.content
856 ));
857 yield Ok(AgentEvent::ToolCall {
858 id: call.id.clone(),
859 name: call.name.clone(),
860 arguments: call.arguments.clone(),
861 result,
862 });
863 }
864 }
865 };
866 Ok(Box::pin(wrapped))
867 }
868
869 pub async fn run_agentic(&self, input: &str, tools: &[Tool]) -> Result<String, CoreError> {
871 let stream = self.run_event_stream(input, tools).await?;
872 let mut out = String::new();
873 let mut stream = stream;
874 while let Some(ev) = stream.next().await {
875 if let AgentEvent::Done { text } = ev? {
876 out = text;
877 break;
878 }
879 }
880 Ok(out)
881 }
882}
883
884async fn sandbox_exec(
888 sandbox: &Arc<dyn Sandbox>,
889 context: &Arc<dyn ContextStore>,
890 session: &str,
891 call: &ToolCall,
892) -> Result<ToolResult, CoreError> {
893 let command: Vec<String> = call
894 .arguments
895 .get("command")
896 .and_then(|v| v.as_array())
897 .map(|a| {
898 a.iter()
899 .filter_map(|x| x.as_str().map(String::from))
900 .collect()
901 })
902 .ok_or_else(|| {
903 CoreError::Model(format!("tool `{}` missing `command` array arg", call.name))
904 })?;
905 if command.is_empty() {
906 return Err(CoreError::Model(format!(
907 "tool `{}` command is empty",
908 call.name
909 )));
910 }
911 let spec = agent_sandbox::ExecSpec::command(command.clone());
912 let handle = sandbox.spawn(&spec).await?;
913 let out = sandbox.exec(&handle, &command).await?;
914 sandbox.destroy(handle).await?;
915 let content = format!(
916 "exit={} stdout={} stderr={}",
917 out.exit_code, out.stdout, out.stderr
918 );
919 context
920 .memorize(ContextFragment::new(
921 session,
922 FragmentKind::ToolResult,
923 content.clone(),
924 ))
925 .await?;
926 Ok(ToolResult {
927 call_id: call.id.clone(),
928 content,
929 is_error: false,
930 })
931}
932
933pub fn in_memory_context() -> Arc<dyn ContextStore> {
935 context::MemoryContextStore::new()
936}
937
938pub fn default_sandbox_box() -> Box<dyn Sandbox> {
940 default_sandbox()
941}
942
943#[cfg(test)]
944mod tests {
945 use super::*;
946
947 #[tokio::test]
948 async fn run_injects_memo_and_persists() {
949 let memo = in_memory_context();
950 let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
951 let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
952 let r1 = agent.run("hello").await.unwrap();
953 assert!(r1.contains("hello"));
954 let _ = agent.run("recap").await.unwrap();
956 let frags = memo
957 .recall(&RecallQuery::new("default", "hello"))
958 .await
959 .unwrap();
960 assert!(frags.iter().any(|f| f.content == "hello"));
961 }
962
963 #[tokio::test]
964 async fn exec_tool_runs_in_sandbox() {
965 let memo = in_memory_context();
966 let agent = Agent::new(AgentConfig::default(), memo).unwrap();
967 match agent.exec_tool(&["echo".into(), "hi".into()]).await {
969 Ok(out) => assert!(out.contains("hi")),
970 Err(_) => { }
971 }
972 }
973
974 #[tokio::test]
975 async fn run_stream_emits_tokens_and_persists() {
976 let memo = in_memory_context();
977 let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
978 let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
979 let stream = agent.run_stream("hello").await.unwrap();
980 let mut collected = String::new();
981 let mut s = stream;
982 while let Some(tok) = s.next().await {
983 collected.push_str(&tok.unwrap());
984 }
985 assert!(collected.contains("hello"));
986 let frags = memo
988 .recall(&RecallQuery::new("default", "hello"))
989 .await
990 .unwrap();
991 assert!(frags.iter().any(|f| f.content == "hello"));
992 }
993
994 #[tokio::test]
995 async fn run_second_turn_injects_prior_context() {
996 let memo = in_memory_context();
997 let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
998 let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
999 let _ = agent.run("remember the secret code 1234").await.unwrap();
1000 let second = agent.run("what was the code?").await.unwrap();
1001 assert!(second.contains("<injected>"));
1003 }
1004
1005 #[tokio::test]
1006 async fn unknown_sandbox_provider_is_config_error() {
1007 let cfg = AgentConfig {
1008 sandbox_provider: "bogus".into(),
1009 ..AgentConfig::default()
1010 };
1011 let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
1012 let res = Agent::with_model(cfg, model, in_memory_context());
1013 assert!(matches!(res, Err(CoreError::Config(_))));
1014 }
1015
1016 #[test]
1017 fn core_error_converts_from_context() {
1018 let e: CoreError = context::ContextError::NotFound("x".into()).into();
1019 assert!(matches!(e, CoreError::Context(_)));
1020 }
1021
1022 struct EchoModel;
1026
1027 #[async_trait]
1028 impl ModelClient for EchoModel {
1029 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1030 Ok(ModelResponse {
1031 text: format!("SYSTEM[{}]", req.system),
1032 })
1033 }
1034 }
1035
1036 #[test]
1037 fn system_text_is_static_from_agent_name() {
1038 let agent = Agent::with_model(
1039 AgentConfig::default(),
1040 Box::new(EchoModel),
1041 in_memory_context(),
1042 )
1043 .unwrap();
1044 assert_eq!(agent.system_text(), "You are agent.");
1045 }
1046
1047 #[test]
1048 fn system_text_appends_instructions() {
1049 let cfg = AgentConfig {
1050 instructions: "Be terse.".into(),
1051 ..AgentConfig::default()
1052 };
1053 let agent = Agent::with_model(cfg, Box::new(EchoModel), in_memory_context()).unwrap();
1054 assert_eq!(agent.system_text(), "You are agent.\n\nBe terse.");
1055 }
1056
1057 #[tokio::test]
1058 async fn run_serves_the_static_system_prompt() {
1059 let model: Box<dyn ModelClient> = Box::new(EchoModel);
1060 let agent = Agent::with_model(AgentConfig::default(), model, in_memory_context()).unwrap();
1061 let r = agent.run("hi").await.unwrap();
1062 assert!(r.contains("You are agent."), "static system served: {r}");
1063 }
1064
1065 use agent_sandbox::{ExecOutput, ExecSpec, SandboxError, SandboxHandle};
1068 use std::sync::atomic::{AtomicUsize, Ordering};
1069
1070 struct LocalSandbox;
1073
1074 #[async_trait]
1075 impl Sandbox for LocalSandbox {
1076 async fn spawn(&self, _spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
1077 Ok(SandboxHandle { id: "local".into() })
1078 }
1079 async fn exec(
1080 &self,
1081 _handle: &SandboxHandle,
1082 cmd: &[String],
1083 ) -> Result<ExecOutput, SandboxError> {
1084 let joined = cmd.join(" ");
1085 let out = tokio::process::Command::new("sh")
1086 .args(["-c", &joined])
1087 .output()
1088 .await
1089 .map_err(SandboxError::Io)?;
1090 Ok(ExecOutput {
1091 exit_code: out.status.code().unwrap_or(-1),
1092 stdout: String::from_utf8_lossy(&out.stdout).to_string(),
1093 stderr: String::from_utf8_lossy(&out.stderr).to_string(),
1094 })
1095 }
1096 async fn destroy(&self, _handle: SandboxHandle) -> Result<(), SandboxError> {
1097 Ok(())
1098 }
1099 }
1100
1101 fn local_agent(model: Box<dyn ModelClient>) -> Agent {
1102 Agent::with_sandbox(
1103 AgentConfig::default(),
1104 model,
1105 in_memory_context(),
1106 Arc::new(LocalSandbox),
1107 )
1108 .unwrap()
1109 }
1110
1111 #[test]
1112 fn agent_event_serde_uses_type_tag() {
1113 let tok = AgentEvent::Token { text: "hi".into() };
1114 let j = serde_json::to_string(&tok).unwrap();
1115 assert!(j.contains("\"type\":\"token\""));
1116 assert_eq!(serde_json::from_str::<AgentEvent>(&j).unwrap(), tok);
1117
1118 let tc = AgentEvent::ToolCall {
1119 id: "c".into(),
1120 name: "shell".into(),
1121 arguments: serde_json::json!({}),
1122 result: ToolResult {
1123 call_id: "c".into(),
1124 content: "x".into(),
1125 is_error: false,
1126 },
1127 };
1128 let j2 = serde_json::to_string(&tc).unwrap();
1129 assert!(j2.contains("\"type\":\"tool_call\""));
1130 assert_eq!(serde_json::from_str::<AgentEvent>(&j2).unwrap(), tc);
1131
1132 let step = AgentEvent::Step {
1133 phase: "recall".into(),
1134 label: None,
1135 };
1136 assert!(serde_json::to_string(&step)
1137 .unwrap()
1138 .contains("\"type\":\"step\""));
1139 assert!(
1140 serde_json::to_string(&AgentEvent::Done { text: "x".into() })
1141 .unwrap()
1142 .contains("\"type\":\"done\"")
1143 );
1144 }
1145
1146 #[tokio::test]
1147 async fn stub_model_complete_with_tools_has_no_calls() {
1148 let model = StubModel::new("agent");
1149 let req = ModelRequest {
1150 system: "s".into(),
1151 context: String::new(),
1152 input: "hi".into(),
1153 };
1154 let turn = model.complete_with_tools(&req, &[]).await.unwrap();
1155 assert!(turn.tool_calls.is_empty());
1156 assert!(turn.text.contains("hi"));
1157 }
1158
1159 #[tokio::test]
1160 async fn run_event_stream_single_shot_emits_events() {
1161 let memo = in_memory_context();
1162 let agent = Agent::with_model(
1163 AgentConfig::default(),
1164 Box::new(StubModel::new("agent")),
1165 memo.clone(),
1166 )
1167 .unwrap();
1168 let stream = agent.run_event_stream("hello", &[]).await.unwrap();
1169 let mut events = Vec::new();
1170 let mut stream = stream;
1171 while let Some(ev) = stream.next().await {
1172 events.push(ev.unwrap());
1173 }
1174 assert!(
1175 matches!(events.first(), Some(AgentEvent::Step { phase, .. }) if phase == "recall"),
1176 "first event must be the recall step"
1177 );
1178 assert!(events.iter().any(|e| matches!(e, AgentEvent::Token { .. })));
1179 assert!(
1180 matches!(events.last(), Some(AgentEvent::Done { .. })),
1181 "stream must terminate with Done"
1182 );
1183 let frags = memo
1185 .recall(&RecallQuery::new("default", "hello"))
1186 .await
1187 .unwrap();
1188 assert!(frags.iter().any(|f| f.content == "hello"));
1189 }
1190
1191 #[tokio::test]
1192 async fn run_agentic_executes_tool_and_refills_memo() {
1193 let agent = local_agent(Box::new(ToolLoopModel {
1194 calls: Arc::new(AtomicUsize::new(0)),
1195 }));
1196 let tools = vec![Tool {
1197 name: "shell".into(),
1198 description: "run a shell command".into(),
1199 parameters: serde_json::json!({}),
1200 }];
1201 let stream = agent.run_event_stream("do it", &tools).await.unwrap();
1202 let mut stream = stream;
1203 let mut results = Vec::new();
1204 while let Some(ev) = stream.next().await {
1205 if let AgentEvent::ToolCall { result, .. } = ev.unwrap() {
1206 results.push(result);
1207 }
1208 }
1209 assert_eq!(results.len(), 1, "exactly one tool call executed");
1210 assert!(results[0].content.contains("hello"));
1211 assert!(!results[0].is_error);
1212 let frags = agent
1214 .context()
1215 .recall(&RecallQuery::new("default", "hello"))
1216 .await
1217 .unwrap();
1218 assert!(frags.iter().any(|f| f.content.contains("hello")));
1219 }
1220
1221 #[tokio::test]
1222 async fn run_agentic_records_tool_failure() {
1223 let agent = local_agent(Box::new(MissingCommandModel {
1224 calls: Arc::new(AtomicUsize::new(0)),
1225 }));
1226 let tools = vec![Tool {
1227 name: "shell".into(),
1228 description: "x".into(),
1229 parameters: serde_json::json!({}),
1230 }];
1231 let stream = agent.run_event_stream("fail", &tools).await.unwrap();
1232 let mut stream = stream;
1233 let mut saw_error = false;
1234 let mut done = false;
1235 while let Some(ev) = stream.next().await {
1236 match ev.unwrap() {
1237 AgentEvent::ToolCall { result, .. } => saw_error = saw_error || result.is_error,
1238 AgentEvent::Done { .. } => done = true,
1239 _ => {}
1240 }
1241 }
1242 assert!(
1243 saw_error,
1244 "missing command must surface as an error tool result"
1245 );
1246 assert!(done);
1247 }
1248
1249 #[tokio::test]
1250 async fn run_agentic_respects_loop_cap() {
1251 let agent = local_agent(Box::new(LoopForeverModel));
1252 let tools = vec![Tool {
1253 name: "shell".into(),
1254 description: "x".into(),
1255 parameters: serde_json::json!({}),
1256 }];
1257 let stream = agent.run_event_stream("loop", &tools).await.unwrap();
1258 let mut stream = stream;
1259 let mut model_steps = 0usize;
1260 let mut done = false;
1261 while let Some(ev) = stream.next().await {
1262 match ev.unwrap() {
1263 AgentEvent::Step { phase, .. } if phase == "model" => model_steps += 1,
1264 AgentEvent::Done { .. } => done = true,
1265 _ => {}
1266 }
1267 }
1268 assert!(done, "must terminate with a Done event even when looping");
1269 assert!(
1270 model_steps <= Agent::MAX_AGENTIC_STEPS,
1271 "model steps bounded by cap, got {model_steps}"
1272 );
1273 }
1274
1275 #[tokio::test]
1276 async fn exec_tool_call_runs_in_sandbox_and_persists() {
1277 let memo = in_memory_context();
1278 let agent = Agent::with_sandbox(
1279 AgentConfig::default(),
1280 Box::new(StubModel::new("agent")),
1281 memo.clone(),
1282 Arc::new(LocalSandbox),
1283 )
1284 .unwrap();
1285 let call = ToolCall {
1286 id: "c1".into(),
1287 name: "shell".into(),
1288 arguments: serde_json::json!({ "command": ["echo", "hi"] }),
1289 };
1290 let res = agent.exec_tool_call(&call).await.unwrap();
1291 assert!(res.content.contains("hi"));
1292 assert!(!res.is_error);
1293 let frags = memo
1294 .recall(&RecallQuery::new("default", "hi"))
1295 .await
1296 .unwrap();
1297 assert!(frags.iter().any(|f| f.content.contains("hi")));
1298 }
1299
1300 #[tokio::test]
1301 async fn exec_tool_call_missing_command_errors() {
1302 let agent = local_agent(Box::new(StubModel::new("agent")));
1303 let call = ToolCall {
1304 id: "c1".into(),
1305 name: "shell".into(),
1306 arguments: serde_json::json!({}),
1307 };
1308 let res = agent.exec_tool_call(&call).await;
1309 assert!(matches!(res, Err(CoreError::Model(_))));
1310 }
1311
1312 struct ToolLoopModel {
1314 calls: Arc<AtomicUsize>,
1315 }
1316
1317 #[async_trait]
1318 impl ModelClient for ToolLoopModel {
1319 async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1320 Ok(ModelResponse {
1321 text: format!("[stub] {}", req.input),
1322 })
1323 }
1324 async fn complete_with_tools(
1325 &self,
1326 req: &ModelRequest,
1327 _tools: &[Tool],
1328 ) -> Result<ModelTurn, CoreError> {
1329 let n = self.calls.fetch_add(1, Ordering::SeqCst);
1330 if n == 0 {
1331 Ok(ModelTurn {
1332 text: String::new(),
1333 tool_calls: vec![ToolCall {
1334 id: "call_1".into(),
1335 name: "shell".into(),
1336 arguments: serde_json::json!({ "command": ["echo", "hello"] }),
1337 }],
1338 })
1339 } else {
1340 Ok(ModelTurn {
1341 text: format!("final reply for: {}", req.input),
1342 tool_calls: vec![],
1343 })
1344 }
1345 }
1346 }
1347
1348 struct MissingCommandModel {
1350 calls: Arc<AtomicUsize>,
1351 }
1352
1353 #[async_trait]
1354 impl ModelClient for MissingCommandModel {
1355 async fn complete(&self, _req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1356 Ok(ModelResponse {
1357 text: String::new(),
1358 })
1359 }
1360 async fn complete_with_tools(
1361 &self,
1362 _req: &ModelRequest,
1363 _tools: &[Tool],
1364 ) -> Result<ModelTurn, CoreError> {
1365 let n = self.calls.fetch_add(1, Ordering::SeqCst);
1366 if n == 0 {
1367 Ok(ModelTurn {
1368 text: String::new(),
1369 tool_calls: vec![ToolCall {
1370 id: "bad".into(),
1371 name: "shell".into(),
1372 arguments: serde_json::json!({}),
1373 }],
1374 })
1375 } else {
1376 Ok(ModelTurn {
1377 text: "recovered".into(),
1378 tool_calls: vec![],
1379 })
1380 }
1381 }
1382 }
1383
1384 struct LoopForeverModel;
1386
1387 #[async_trait]
1388 impl ModelClient for LoopForeverModel {
1389 async fn complete(&self, _req: &ModelRequest) -> Result<ModelResponse, CoreError> {
1390 Ok(ModelResponse {
1391 text: String::new(),
1392 })
1393 }
1394 async fn complete_with_tools(
1395 &self,
1396 _req: &ModelRequest,
1397 _tools: &[Tool],
1398 ) -> Result<ModelTurn, CoreError> {
1399 Ok(ModelTurn {
1400 text: String::new(),
1401 tool_calls: vec![ToolCall {
1402 id: "c".into(),
1403 name: "shell".into(),
1404 arguments: serde_json::json!({ "command": ["echo", "x"] }),
1405 }],
1406 })
1407 }
1408 }
1409}