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