use agent_memo::{ContextFragment, FragmentKind, MemoStore, RecallQuery, SledMemoStore};
use agent_sandbox::{default_sandbox, Sandbox, SandboxProvider};
use async_trait::async_trait;
use futures::stream::{BoxStream, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::{Arc, RwLock};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CoreError {
#[error("memo error: {0}")]
Memo(#[from] agent_memo::MemoError),
#[error("sandbox error: {0}")]
Sandbox(#[from] agent_sandbox::SandboxError),
#[error("model error: {0}")]
Model(String),
#[error("config error: {0}")]
Config(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRequest {
pub system: String,
pub context: String,
pub input: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelResponse {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Tool {
pub name: String,
pub description: String,
pub parameters: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolResult {
pub call_id: String,
pub content: String,
pub is_error: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ModelTurn {
pub text: String,
pub tool_calls: Vec<ToolCall>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
Step {
phase: String,
label: Option<String>,
},
ToolCall {
id: String,
name: String,
arguments: Value,
result: ToolResult,
},
Token { text: String },
Done { text: String },
}
#[async_trait]
pub trait ModelClient: Send + Sync {
async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError>;
async fn stream(
&self,
req: &ModelRequest,
) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
let resp = self.complete(req).await?;
Ok(Box::pin(futures::stream::once(
async move { Ok(resp.text) },
)))
}
async fn complete_with_tools(
&self,
req: &ModelRequest,
_tools: &[Tool],
) -> Result<ModelTurn, CoreError> {
let resp = self.complete(req).await?;
Ok(ModelTurn {
text: resp.text,
tool_calls: Vec::new(),
})
}
}
pub struct StubModel {
agent_name: String,
}
impl StubModel {
pub fn new(agent_name: &str) -> Self {
Self {
agent_name: agent_name.to_string(),
}
}
}
#[async_trait]
impl ModelClient for StubModel {
async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
let text = format!(
"[{}] (stub) context={} | input={}",
self.agent_name,
if req.context.is_empty() {
"<none>"
} else {
"<injected>"
},
req.input
);
Ok(ModelResponse { text })
}
}
#[cfg(feature = "openai")]
pub use openai_impl::OpenAiModel;
#[cfg(feature = "openai")]
mod openai_impl {
use super::*;
use async_openai::types::{
ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage,
ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent,
ChatCompletionTool, ChatCompletionToolChoiceOption, ChatCompletionToolType,
CreateChatCompletionRequestArgs, FunctionObject,
};
use async_openai::{config::OpenAIConfig, Client};
pub struct OpenAiModel {
client: Client<OpenAIConfig>,
model: String,
}
impl OpenAiModel {
pub fn new(model: &str) -> Self {
let api_key = std::env::var("OPENAI_API_KEY").unwrap_or_default();
let config = OpenAIConfig::new().with_api_key(api_key);
Self {
client: Client::with_config(config),
model: model.to_string(),
}
}
}
fn build_codex_tools(tools: &[Tool]) -> Vec<ChatCompletionTool> {
tools
.iter()
.map(|t| ChatCompletionTool {
r#type: ChatCompletionToolType::Function,
function: FunctionObject {
name: t.name.clone(),
description: Some(t.description.clone()),
parameters: Some(t.parameters.clone()),
strict: None,
},
})
.collect()
}
#[allow(deprecated)]
fn parse_response_calls(
message: &async_openai::types::ChatCompletionResponseMessage,
) -> Vec<ToolCall> {
if let Some(calls) = &message.tool_calls {
return calls
.iter()
.map(|c| {
let arguments = serde_json::from_str(&c.function.arguments)
.unwrap_or(serde_json::Value::Null);
ToolCall {
id: c.id.clone(),
name: c.function.name.clone(),
arguments,
}
})
.collect();
}
if let Some(fc) = &message.function_call {
let arguments = serde_json::from_str(&fc.arguments).unwrap_or(serde_json::Value::Null);
return vec![ToolCall {
id: "fn_0".into(),
name: fc.name.clone(),
arguments,
}];
}
Vec::new()
}
#[async_trait]
impl ModelClient for OpenAiModel {
async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
use async_openai::types::CreateChatCompletionRequestArgs;
let messages = vec![
ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
content: req.system.clone().into(),
..Default::default()
}),
ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(format!(
"{}\n\nUSER: {}",
req.context, req.input
)),
..Default::default()
}),
];
let request = CreateChatCompletionRequestArgs::default()
.model(self.model.clone())
.messages(messages)
.build()
.map_err(|e| CoreError::Model(e.to_string()))?;
let resp = self
.client
.chat()
.create(request)
.await
.map_err(|e| CoreError::Model(e.to_string()))?;
let text = resp
.choices
.first()
.and_then(|c| c.message.content.clone())
.unwrap_or_default();
Ok(ModelResponse { text })
}
async fn stream(
&self,
req: &ModelRequest,
) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
use async_openai::types::CreateChatCompletionRequestArgs;
use futures::StreamExt as _;
let messages = vec![
ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
content: req.system.clone().into(),
..Default::default()
}),
ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(format!(
"{}\n\nUSER: {}",
req.context, req.input
)),
..Default::default()
}),
];
let request = CreateChatCompletionRequestArgs::default()
.model(self.model.clone())
.messages(messages)
.stream(true)
.build()
.map_err(|e| CoreError::Model(e.to_string()))?;
let client = self.client.clone();
let s = async_stream::stream! {
let mut stream = match client.chat().create_stream(request).await {
Ok(s) => s,
Err(e) => {
yield Err(CoreError::Model(e.to_string()));
return;
}
};
while let Some(chunk) = stream.next().await {
match chunk {
Ok(resp) => {
if let Some(tok) = resp
.choices
.into_iter()
.next()
.and_then(|c| c.delta.content)
{
yield Ok(tok);
}
}
Err(e) => yield Err(CoreError::Model(e.to_string())),
}
}
};
Ok(Box::pin(s))
}
async fn complete_with_tools(
&self,
req: &ModelRequest,
tools: &[Tool],
) -> Result<ModelTurn, CoreError> {
let messages = vec![
ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
content: req.system.clone().into(),
..Default::default()
}),
ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(format!(
"{}\n\nUSER: {}",
req.context, req.input
)),
..Default::default()
}),
];
let tools = build_codex_tools(tools);
let mut args = CreateChatCompletionRequestArgs::default();
let mut b = args.model(self.model.clone()).messages(messages);
if !tools.is_empty() {
b = b
.tools(tools)
.tool_choice(ChatCompletionToolChoiceOption::Auto);
}
let request = b.build().map_err(|e| CoreError::Model(e.to_string()))?;
let resp = self
.client
.chat()
.create(request)
.await
.map_err(|e| CoreError::Model(e.to_string()))?;
let choice = resp
.choices
.first()
.ok_or_else(|| CoreError::Model("empty choices from model".into()))?;
let text = choice.message.content.clone().unwrap_or_default();
let tool_calls = parse_response_calls(&choice.message);
Ok(ModelTurn { text, tool_calls })
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_openai::types::ChatCompletionMessageToolCall;
use async_openai::types::ChatCompletionResponseMessage;
use async_openai::types::ChatCompletionToolType;
use async_openai::types::FunctionCall;
use async_openai::types::Role;
#[test]
fn build_codex_tools_maps_schema() {
let tools = vec![Tool {
name: "shell".into(),
description: "run a command".into(),
parameters: serde_json::json!({
"type": "object",
"properties": { "command": { "type": "string" } }
}),
}];
let out = build_codex_tools(&tools);
assert_eq!(out.len(), 1);
assert_eq!(out[0].r#type, ChatCompletionToolType::Function);
assert_eq!(out[0].function.name, "shell");
assert_eq!(
out[0].function.description.as_deref(),
Some("run a command")
);
assert!(out[0].function.parameters.as_ref().unwrap().is_object());
}
#[test]
fn parse_response_calls_reads_modern_tool_calls() {
let message = ChatCompletionResponseMessage {
content: Some("thinking".into()),
refusal: None,
tool_calls: Some(vec![ChatCompletionMessageToolCall {
id: "call_1".into(),
r#type: ChatCompletionToolType::Function,
function: FunctionCall {
name: "shell".into(),
arguments: "{\"command\":[\"echo\",\"hi\"]}".into(),
},
}]),
role: Role::Assistant,
#[allow(deprecated)]
function_call: None,
};
let parsed = parse_response_calls(&message);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].id, "call_1");
assert_eq!(parsed[0].name, "shell");
assert_eq!(
parsed[0].arguments,
serde_json::json!({"command": ["echo", "hi"]})
);
}
#[test]
fn parse_response_calls_reads_legacy_function_call() {
let message = ChatCompletionResponseMessage {
content: Some("thinking".into()),
refusal: None,
tool_calls: None,
role: Role::Assistant,
#[allow(deprecated)]
function_call: Some(FunctionCall {
name: "shell".into(),
arguments: "{\"command\":[\"echo\",\"hi\"]}".into(),
}),
};
let parsed = parse_response_calls(&message);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].id, "fn_0");
assert_eq!(parsed[0].name, "shell");
}
#[test]
fn parse_response_calls_handles_invalid_json() {
let message = ChatCompletionResponseMessage {
content: None,
refusal: None,
tool_calls: Some(vec![ChatCompletionMessageToolCall {
id: "bad".into(),
r#type: ChatCompletionToolType::Function,
function: FunctionCall {
name: "shell".into(),
arguments: "not-json".into(),
},
}]),
role: Role::Assistant,
#[allow(deprecated)]
function_call: None,
};
let parsed = parse_response_calls(&message);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].arguments, serde_json::Value::Null);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub session: String,
pub agent_name: String,
pub sandbox_provider: String,
pub model: String,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
session: "default".to_string(),
agent_name: "agent".to_string(),
sandbox_provider: "docker".to_string(),
model: "gpt-4o-mini".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Skill {
pub name: String,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Rule {
pub name: String,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Harness {
pub system_prompt: String,
pub skills: Vec<Skill>,
pub rules: Vec<Rule>,
}
impl Harness {
pub fn baseline(agent_name: &str) -> Self {
Self {
system_prompt: format!("You are {}.", agent_name),
skills: Vec::new(),
rules: Vec::new(),
}
}
pub fn is_baseline(&self, agent_name: &str) -> bool {
*self == Harness::baseline(agent_name)
}
pub fn system_text(&self) -> String {
let mut s = self.system_prompt.clone();
if !self.skills.is_empty() {
s.push_str("\n\n## Skills");
for sk in &self.skills {
s.push_str(&format!("\n- {}: {}", sk.name, sk.body));
}
}
if !self.rules.is_empty() {
s.push_str("\n\n## Rules");
for r in &self.rules {
s.push_str(&format!("\n- {}: {}", r.name, r.body));
}
}
s
}
}
pub struct ActiveHarness {
current: Arc<RwLock<Arc<Harness>>>,
}
impl ActiveHarness {
pub fn new(initial: Harness) -> Self {
Self {
current: Arc::new(RwLock::new(Arc::new(initial))),
}
}
pub fn baseline(agent_name: &str) -> Self {
Self::new(Harness::baseline(agent_name))
}
pub fn get(&self) -> Arc<Harness> {
self.current
.read()
.expect("active harness lock poisoned")
.clone()
}
pub fn set(&self, h: Harness) {
let mut g = self.current.write().expect("active harness lock poisoned");
*g = Arc::new(h);
}
}
pub struct Agent {
config: AgentConfig,
model: Arc<dyn ModelClient>,
memo: Arc<dyn MemoStore>,
sandbox: Arc<dyn Sandbox>,
harness: Arc<ActiveHarness>,
}
impl Agent {
pub fn with_harness(
config: AgentConfig,
model: Box<dyn ModelClient>,
memo: Arc<dyn MemoStore>,
harness: Arc<ActiveHarness>,
) -> Result<Self, CoreError> {
let provider = SandboxProvider::parse(&config.sandbox_provider).ok_or_else(|| {
CoreError::Config(format!("unknown sandbox: {}", config.sandbox_provider))
})?;
let sandbox = Arc::from(
agent_sandbox::from_provider(provider)
.map_err(|e| CoreError::Config(format!("sandbox {}: {}", provider.as_str(), e)))?,
);
Ok(Self {
config,
model: Arc::from(model),
memo,
sandbox,
harness,
})
}
pub fn with_sandbox(
config: AgentConfig,
model: Box<dyn ModelClient>,
memo: Arc<dyn MemoStore>,
harness: Arc<ActiveHarness>,
sandbox: Arc<dyn Sandbox>,
) -> Result<Self, CoreError> {
Ok(Self {
config,
model: Arc::from(model),
memo,
sandbox,
harness,
})
}
pub fn with_model(
config: AgentConfig,
model: Box<dyn ModelClient>,
memo: Arc<dyn MemoStore>,
) -> Result<Self, CoreError> {
let harness = Arc::new(ActiveHarness::baseline(&config.agent_name));
Self::with_harness(config, model, memo, harness)
}
pub fn new(config: AgentConfig, memo: Arc<dyn MemoStore>) -> Result<Self, CoreError> {
let model: Box<dyn ModelClient> = {
#[cfg(feature = "openai")]
{
Box::new(OpenAiModel::new(&config.model))
}
#[cfg(not(feature = "openai"))]
{
let _ = &config.model;
Box::new(StubModel::new(&config.agent_name))
}
};
Self::with_model(config, model, memo)
}
pub fn session(&self) -> &str {
&self.config.session
}
pub fn harness(&self) -> Arc<ActiveHarness> {
self.harness.clone()
}
pub fn memo(&self) -> Arc<dyn MemoStore> {
self.memo.clone()
}
pub async fn run(&self, input: &str) -> Result<String, CoreError> {
let fragments = self
.memo
.recall(&RecallQuery::new(&self.config.session, input))
.await?;
let context = fragments
.iter()
.map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
.collect::<Vec<_>>()
.join("\n");
self.memo
.memorize(ContextFragment::new(
&self.config.session,
FragmentKind::Message,
input,
))
.await?;
let system = self.harness.get().system_text();
let req = ModelRequest {
system,
context,
input: input.to_string(),
};
let resp = self.model.complete(&req).await?;
self.memo
.memorize(ContextFragment::new(
&self.config.session,
FragmentKind::Message,
resp.text.clone(),
))
.await?;
Ok(resp.text)
}
pub async fn exec_tool(&self, command: &[String]) -> Result<String, CoreError> {
let spec = agent_sandbox::ExecSpec::command(command.to_vec());
let handle = self.sandbox.spawn(&spec).await?;
let out = self.sandbox.exec(&handle, command).await?;
self.sandbox.destroy(handle).await?;
let captured = format!(
"exit={} stdout={} stderr={}",
out.exit_code, out.stdout, out.stderr
);
self.memo
.memorize(ContextFragment::new(
&self.config.session,
FragmentKind::ToolResult,
captured.clone(),
))
.await?;
Ok(captured)
}
pub async fn run_stream(
&self,
input: &str,
) -> Result<BoxStream<'static, Result<String, CoreError>>, CoreError> {
let fragments = self
.memo
.recall(&RecallQuery::new(&self.config.session, input))
.await?;
let context = fragments
.iter()
.map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
.collect::<Vec<_>>()
.join("\n");
self.memo
.memorize(ContextFragment::new(
&self.config.session,
FragmentKind::Message,
input,
))
.await?;
let system = self.harness.get().system_text();
let req = ModelRequest {
system,
context,
input: input.to_string(),
};
let upstream = self.model.stream(&req).await?;
let memo = self.memo.clone();
let session = self.config.session.clone();
let wrapped = async_stream::stream! {
let mut collected = String::new();
let mut upstream = upstream;
while let Some(item) = upstream.next().await {
match item {
Ok(tok) => {
collected.push_str(&tok);
yield Ok(tok);
}
Err(e) => {
yield Err(e);
return;
}
}
}
let _ = memo
.memorize(ContextFragment::new(&session, FragmentKind::Message, collected))
.await;
};
Ok(Box::pin(wrapped))
}
pub const MAX_AGENTIC_STEPS: usize = 8;
pub async fn exec_tool_call(&self, call: &ToolCall) -> Result<ToolResult, CoreError> {
sandbox_exec(&self.sandbox, &self.memo, &self.config.session, call).await
}
pub async fn run_event_stream(
&self,
input: &str,
tools: &[Tool],
) -> Result<BoxStream<'static, Result<AgentEvent, CoreError>>, CoreError> {
let memo = self.memo.clone();
let model = self.model.clone();
let sandbox = self.sandbox.clone();
let harness = self.harness.clone();
let session = self.config.session.clone();
let tools: Vec<Tool> = tools.to_vec();
let input = input.to_string();
let fragments = memo.recall(&RecallQuery::new(&session, &input)).await?;
let initial_context = fragments
.iter()
.map(|f| format!("[{}] {}", f.kind.as_str(), f.content))
.collect::<Vec<_>>()
.join("\n");
memo.memorize(ContextFragment::new(
&session,
FragmentKind::Message,
input.clone(),
))
.await?;
let wrapped = async_stream::stream! {
yield Ok(AgentEvent::Step { phase: "recall".into(), label: None });
let mut tool_log = String::new();
let mut step = 0usize;
loop {
step += 1;
if step > Agent::MAX_AGENTIC_STEPS {
yield Ok(AgentEvent::Step {
phase: "loop_guard".into(),
label: Some("max agentic steps exceeded".into()),
});
yield Ok(AgentEvent::Done { text: String::new() });
break;
}
yield Ok(AgentEvent::Step { phase: "model".into(), label: None });
let system = harness.get().system_text();
let context = if tool_log.is_empty() {
initial_context.clone()
} else {
format!("{}\n{}", initial_context, tool_log)
};
let req = ModelRequest {
system,
context,
input: input.clone(),
};
let turn = match model.complete_with_tools(&req, &tools).await {
Ok(t) => t,
Err(e) => {
yield Err(e);
return;
}
};
if turn.tool_calls.is_empty() {
yield Ok(AgentEvent::Token { text: turn.text.clone() });
let _ = memo
.memorize(ContextFragment::new(
&session,
FragmentKind::Message,
turn.text.clone(),
))
.await;
yield Ok(AgentEvent::Done { text: turn.text });
break;
}
for call in &turn.tool_calls {
yield Ok(AgentEvent::Step {
phase: "tool_exec".into(),
label: Some(call.name.clone()),
});
let result = match sandbox_exec(&sandbox, &memo, &session, call).await {
Ok(r) => r,
Err(e) => ToolResult {
call_id: call.id.clone(),
content: e.to_string(),
is_error: true,
},
};
tool_log.push_str(&format!(
"\n\nTOOL_RESULT[{}]: {}",
call.name, result.content
));
yield Ok(AgentEvent::ToolCall {
id: call.id.clone(),
name: call.name.clone(),
arguments: call.arguments.clone(),
result,
});
}
}
};
Ok(Box::pin(wrapped))
}
pub async fn run_agentic(&self, input: &str, tools: &[Tool]) -> Result<String, CoreError> {
let stream = self.run_event_stream(input, tools).await?;
let mut out = String::new();
let mut stream = stream;
while let Some(ev) = stream.next().await {
if let AgentEvent::Done { text } = ev? {
out = text;
break;
}
}
Ok(out)
}
}
async fn sandbox_exec(
sandbox: &Arc<dyn Sandbox>,
memo: &Arc<dyn MemoStore>,
session: &str,
call: &ToolCall,
) -> Result<ToolResult, CoreError> {
let command: Vec<String> = call
.arguments
.get("command")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.ok_or_else(|| {
CoreError::Model(format!("tool `{}` missing `command` array arg", call.name))
})?;
if command.is_empty() {
return Err(CoreError::Model(format!(
"tool `{}` command is empty",
call.name
)));
}
let spec = agent_sandbox::ExecSpec::command(command.clone());
let handle = sandbox.spawn(&spec).await?;
let out = sandbox.exec(&handle, &command).await?;
sandbox.destroy(handle).await?;
let content = format!(
"exit={} stdout={} stderr={}",
out.exit_code, out.stdout, out.stderr
);
memo.memorize(ContextFragment::new(
session,
FragmentKind::ToolResult,
content.clone(),
))
.await?;
Ok(ToolResult {
call_id: call.id.clone(),
content,
is_error: false,
})
}
pub fn in_memory_memo() -> Arc<dyn MemoStore> {
SledMemoStore::memory().expect("sled temp store")
}
pub fn default_sandbox_box() -> Box<dyn Sandbox> {
default_sandbox()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn run_injects_memo_and_persists() {
let memo = in_memory_memo();
let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
let r1 = agent.run("hello").await.unwrap();
assert!(r1.contains("hello"));
let _ = agent.run("recap").await.unwrap();
let frags = memo
.recall(&RecallQuery::new("default", "hello"))
.await
.unwrap();
assert!(frags.iter().any(|f| f.content == "hello"));
}
#[tokio::test]
async fn exec_tool_runs_in_sandbox() {
let memo = in_memory_memo();
let agent = Agent::new(AgentConfig::default(), memo).unwrap();
match agent.exec_tool(&["echo".into(), "hi".into()]).await {
Ok(out) => assert!(out.contains("hi")),
Err(_) => { }
}
}
#[tokio::test]
async fn run_stream_emits_tokens_and_persists() {
let memo = in_memory_memo();
let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
let stream = agent.run_stream("hello").await.unwrap();
let mut collected = String::new();
let mut s = stream;
while let Some(tok) = s.next().await {
collected.push_str(&tok.unwrap());
}
assert!(collected.contains("hello"));
let frags = memo
.recall(&RecallQuery::new("default", "hello"))
.await
.unwrap();
assert!(frags.iter().any(|f| f.content == "hello"));
}
#[tokio::test]
async fn run_second_turn_injects_prior_context() {
let memo = in_memory_memo();
let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
let agent = Agent::with_model(AgentConfig::default(), model, memo.clone()).unwrap();
let _ = agent.run("remember the secret code 1234").await.unwrap();
let second = agent.run("what was the code?").await.unwrap();
assert!(second.contains("<injected>"));
}
#[tokio::test]
async fn unknown_sandbox_provider_is_config_error() {
let cfg = AgentConfig {
sandbox_provider: "bogus".into(),
..AgentConfig::default()
};
let model: Box<dyn ModelClient> = Box::new(StubModel::new("agent"));
let res = Agent::with_model(cfg, model, in_memory_memo());
assert!(matches!(res, Err(CoreError::Config(_))));
}
#[test]
fn core_error_converts_from_memo() {
let e: CoreError = agent_memo::MemoError::NotFound("x".into()).into();
assert!(matches!(e, CoreError::Memo(_)));
}
#[test]
fn harness_baseline_equals_legacy_system() {
let h = Harness::baseline("helper");
assert_eq!(h.system_text(), "You are helper.");
assert!(h.skills.is_empty());
assert!(h.rules.is_empty());
}
#[test]
fn harness_system_text_assembles_skills_and_rules() {
let h = Harness {
system_prompt: "You are a bot.".into(),
skills: vec![Skill {
name: "summarize".into(),
body: "condense text".into(),
}],
rules: vec![Rule {
name: "no_pii".into(),
body: "never echo secrets".into(),
}],
};
let s = h.system_text();
assert!(s.contains("You are a bot."));
assert!(s.contains("## Skills"));
assert!(s.contains("summarize: condense text"));
assert!(s.contains("## Rules"));
assert!(s.contains("no_pii: never echo secrets"));
}
#[test]
fn harness_serialization_roundtrip() {
let h = Harness {
system_prompt: "sys".into(),
skills: vec![Skill {
name: "s".into(),
body: "b".into(),
}],
rules: vec![],
};
let json = serde_json::to_string(&h).unwrap();
let back: Harness = serde_json::from_str(&json).unwrap();
assert_eq!(h, back);
}
#[test]
fn active_harness_hot_swap_is_atomic() {
let ah = ActiveHarness::baseline("agent");
assert!(ah.get().is_baseline("agent"));
let snap = ah.get();
ah.set(Harness::baseline("renamed"));
assert!(snap.is_baseline("agent"));
assert!(ah.get().is_baseline("renamed"));
}
struct EchoModel;
#[async_trait]
impl ModelClient for EchoModel {
async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
Ok(ModelResponse {
text: format!("SYSTEM[{}]", req.system),
})
}
}
#[tokio::test]
async fn run_uses_active_harness_and_hot_swaps() {
let memo = in_memory_memo();
let model: Box<dyn ModelClient> = Box::new(EchoModel);
let harness = Arc::new(ActiveHarness::baseline("agent"));
let agent =
Agent::with_harness(AgentConfig::default(), model, memo, harness.clone()).unwrap();
let r1 = agent.run("hi").await.unwrap();
assert!(
r1.contains("You are agent."),
"baseline system served: {r1}"
);
harness.set(Harness {
system_prompt: "Be terse.".into(),
skills: vec![Skill {
name: "short".into(),
body: "reply in one line".into(),
}],
rules: vec![],
});
let r2 = agent.run("hi").await.unwrap();
assert!(r2.contains("Be terse."), "swapped system served: {r2}");
assert!(
r2.contains("reply in one line"),
"swapped skill served: {r2}"
);
}
#[tokio::test]
async fn with_model_builds_baseline_harness() {
let memo = in_memory_memo();
let model: Box<dyn ModelClient> = Box::new(EchoModel);
let agent = Agent::with_model(AgentConfig::default(), model, memo).unwrap();
let r = agent.run("hi").await.unwrap();
assert!(r.contains("You are agent."));
}
use agent_sandbox::{ExecOutput, ExecSpec, SandboxError, SandboxHandle};
use std::sync::atomic::{AtomicUsize, Ordering};
struct LocalSandbox;
#[async_trait]
impl Sandbox for LocalSandbox {
async fn spawn(&self, _spec: &ExecSpec) -> Result<SandboxHandle, SandboxError> {
Ok(SandboxHandle { id: "local".into() })
}
async fn exec(
&self,
_handle: &SandboxHandle,
cmd: &[String],
) -> Result<ExecOutput, SandboxError> {
let joined = cmd.join(" ");
let out = tokio::process::Command::new("sh")
.args(["-c", &joined])
.output()
.await
.map_err(SandboxError::Io)?;
Ok(ExecOutput {
exit_code: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).to_string(),
stderr: String::from_utf8_lossy(&out.stderr).to_string(),
})
}
async fn destroy(&self, _handle: SandboxHandle) -> Result<(), SandboxError> {
Ok(())
}
}
fn local_agent(model: Box<dyn ModelClient>) -> Agent {
let harness = Arc::new(ActiveHarness::baseline("agent"));
Agent::with_sandbox(
AgentConfig::default(),
model,
in_memory_memo(),
harness,
Arc::new(LocalSandbox),
)
.unwrap()
}
#[test]
fn agent_event_serde_uses_type_tag() {
let tok = AgentEvent::Token { text: "hi".into() };
let j = serde_json::to_string(&tok).unwrap();
assert!(j.contains("\"type\":\"token\""));
assert_eq!(serde_json::from_str::<AgentEvent>(&j).unwrap(), tok);
let tc = AgentEvent::ToolCall {
id: "c".into(),
name: "shell".into(),
arguments: serde_json::json!({}),
result: ToolResult {
call_id: "c".into(),
content: "x".into(),
is_error: false,
},
};
let j2 = serde_json::to_string(&tc).unwrap();
assert!(j2.contains("\"type\":\"tool_call\""));
assert_eq!(serde_json::from_str::<AgentEvent>(&j2).unwrap(), tc);
let step = AgentEvent::Step {
phase: "recall".into(),
label: None,
};
assert!(serde_json::to_string(&step)
.unwrap()
.contains("\"type\":\"step\""));
assert!(
serde_json::to_string(&AgentEvent::Done { text: "x".into() })
.unwrap()
.contains("\"type\":\"done\"")
);
}
#[tokio::test]
async fn stub_model_complete_with_tools_has_no_calls() {
let model = StubModel::new("agent");
let req = ModelRequest {
system: "s".into(),
context: String::new(),
input: "hi".into(),
};
let turn = model.complete_with_tools(&req, &[]).await.unwrap();
assert!(turn.tool_calls.is_empty());
assert!(turn.text.contains("hi"));
}
#[tokio::test]
async fn run_event_stream_single_shot_emits_events() {
let memo = in_memory_memo();
let agent = Agent::with_model(
AgentConfig::default(),
Box::new(StubModel::new("agent")),
memo.clone(),
)
.unwrap();
let stream = agent.run_event_stream("hello", &[]).await.unwrap();
let mut events = Vec::new();
let mut stream = stream;
while let Some(ev) = stream.next().await {
events.push(ev.unwrap());
}
assert!(
matches!(events.first(), Some(AgentEvent::Step { phase, .. }) if phase == "recall"),
"first event must be the recall step"
);
assert!(events.iter().any(|e| matches!(e, AgentEvent::Token { .. })));
assert!(
matches!(events.last(), Some(AgentEvent::Done { .. })),
"stream must terminate with Done"
);
let frags = memo
.recall(&RecallQuery::new("default", "hello"))
.await
.unwrap();
assert!(frags.iter().any(|f| f.content == "hello"));
}
#[tokio::test]
async fn run_agentic_executes_tool_and_refills_memo() {
let agent = local_agent(Box::new(ToolLoopModel {
calls: Arc::new(AtomicUsize::new(0)),
}));
let tools = vec![Tool {
name: "shell".into(),
description: "run a shell command".into(),
parameters: serde_json::json!({}),
}];
let stream = agent.run_event_stream("do it", &tools).await.unwrap();
let mut stream = stream;
let mut results = Vec::new();
while let Some(ev) = stream.next().await {
if let AgentEvent::ToolCall { result, .. } = ev.unwrap() {
results.push(result);
}
}
assert_eq!(results.len(), 1, "exactly one tool call executed");
assert!(results[0].content.contains("hello"));
assert!(!results[0].is_error);
let frags = agent
.memo()
.recall(&RecallQuery::new("default", "hello"))
.await
.unwrap();
assert!(frags.iter().any(|f| f.content.contains("hello")));
}
#[tokio::test]
async fn run_agentic_records_tool_failure() {
let agent = local_agent(Box::new(MissingCommandModel {
calls: Arc::new(AtomicUsize::new(0)),
}));
let tools = vec![Tool {
name: "shell".into(),
description: "x".into(),
parameters: serde_json::json!({}),
}];
let stream = agent.run_event_stream("fail", &tools).await.unwrap();
let mut stream = stream;
let mut saw_error = false;
let mut done = false;
while let Some(ev) = stream.next().await {
match ev.unwrap() {
AgentEvent::ToolCall { result, .. } => saw_error = saw_error || result.is_error,
AgentEvent::Done { .. } => done = true,
_ => {}
}
}
assert!(
saw_error,
"missing command must surface as an error tool result"
);
assert!(done);
}
#[tokio::test]
async fn run_agentic_respects_loop_cap() {
let agent = local_agent(Box::new(LoopForeverModel));
let tools = vec![Tool {
name: "shell".into(),
description: "x".into(),
parameters: serde_json::json!({}),
}];
let stream = agent.run_event_stream("loop", &tools).await.unwrap();
let mut stream = stream;
let mut model_steps = 0usize;
let mut done = false;
while let Some(ev) = stream.next().await {
match ev.unwrap() {
AgentEvent::Step { phase, .. } if phase == "model" => model_steps += 1,
AgentEvent::Done { .. } => done = true,
_ => {}
}
}
assert!(done, "must terminate with a Done event even when looping");
assert!(
model_steps <= Agent::MAX_AGENTIC_STEPS,
"model steps bounded by cap, got {model_steps}"
);
}
#[tokio::test]
async fn exec_tool_call_runs_in_sandbox_and_persists() {
let memo = in_memory_memo();
let agent = Agent::with_sandbox(
AgentConfig::default(),
Box::new(StubModel::new("agent")),
memo.clone(),
Arc::new(ActiveHarness::baseline("agent")),
Arc::new(LocalSandbox),
)
.unwrap();
let call = ToolCall {
id: "c1".into(),
name: "shell".into(),
arguments: serde_json::json!({ "command": ["echo", "hi"] }),
};
let res = agent.exec_tool_call(&call).await.unwrap();
assert!(res.content.contains("hi"));
assert!(!res.is_error);
let frags = memo
.recall(&RecallQuery::new("default", "hi"))
.await
.unwrap();
assert!(frags.iter().any(|f| f.content.contains("hi")));
}
#[tokio::test]
async fn exec_tool_call_missing_command_errors() {
let agent = local_agent(Box::new(StubModel::new("agent")));
let call = ToolCall {
id: "c1".into(),
name: "shell".into(),
arguments: serde_json::json!({}),
};
let res = agent.exec_tool_call(&call).await;
assert!(matches!(res, Err(CoreError::Model(_))));
}
struct ToolLoopModel {
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl ModelClient for ToolLoopModel {
async fn complete(&self, req: &ModelRequest) -> Result<ModelResponse, CoreError> {
Ok(ModelResponse {
text: format!("[stub] {}", req.input),
})
}
async fn complete_with_tools(
&self,
req: &ModelRequest,
_tools: &[Tool],
) -> Result<ModelTurn, CoreError> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok(ModelTurn {
text: String::new(),
tool_calls: vec![ToolCall {
id: "call_1".into(),
name: "shell".into(),
arguments: serde_json::json!({ "command": ["echo", "hello"] }),
}],
})
} else {
Ok(ModelTurn {
text: format!("final reply for: {}", req.input),
tool_calls: vec![],
})
}
}
}
struct MissingCommandModel {
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl ModelClient for MissingCommandModel {
async fn complete(&self, _req: &ModelRequest) -> Result<ModelResponse, CoreError> {
Ok(ModelResponse {
text: String::new(),
})
}
async fn complete_with_tools(
&self,
_req: &ModelRequest,
_tools: &[Tool],
) -> Result<ModelTurn, CoreError> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok(ModelTurn {
text: String::new(),
tool_calls: vec![ToolCall {
id: "bad".into(),
name: "shell".into(),
arguments: serde_json::json!({}),
}],
})
} else {
Ok(ModelTurn {
text: "recovered".into(),
tool_calls: vec![],
})
}
}
}
struct LoopForeverModel;
#[async_trait]
impl ModelClient for LoopForeverModel {
async fn complete(&self, _req: &ModelRequest) -> Result<ModelResponse, CoreError> {
Ok(ModelResponse {
text: String::new(),
})
}
async fn complete_with_tools(
&self,
_req: &ModelRequest,
_tools: &[Tool],
) -> Result<ModelTurn, CoreError> {
Ok(ModelTurn {
text: String::new(),
tool_calls: vec![ToolCall {
id: "c".into(),
name: "shell".into(),
arguments: serde_json::json!({ "command": ["echo", "x"] }),
}],
})
}
}
}