use super::support::{
as_component, eval_cx, flatten_text, install_agent_lib, install_test_codec, register_sum_tool,
request_frame, temp_memory_path,
};
use sim_kernel::{Args, Cx, Expr, ReadPolicy, Result, Symbol, Value};
use sim_lib_server::{EvalSite, FrameKind, ServerFrame, StreamSink, eval_reply_from_frame};
#[derive(Default)]
struct CollectSink {
chunks: Vec<Expr>,
seen: Vec<FrameKind>,
ended: bool,
}
impl StreamSink for CollectSink {
fn chunk(&mut self, cx: &mut Cx, frame: ServerFrame) -> Result<()> {
self.seen.push(frame.kind.clone());
let expr = match frame.kind {
FrameKind::Response => eval_reply_from_frame(cx, &frame)?
.value
.object()
.as_expr(cx)?,
FrameKind::StreamChunk => frame.decode_expr(cx, ReadPolicy::default())?,
FrameKind::StreamStart => return Ok(()),
FrameKind::StreamEnd => {
self.ended = true;
return Ok(());
}
_ => return Ok(()),
};
self.chunks.push(expr);
Ok(())
}
fn end(&mut self, _cx: &mut Cx) -> Result<()> {
self.ended = true;
Ok(())
}
}
#[test]
fn a6_phase5_agent_injects_manifest_tool_for_runner_loop() {
let mut cx = phase5_cx();
cx.grant_named("math");
let tool = register_sum_tool(&mut cx);
let tool_value = cx.resolve_value(&tool.symbol).unwrap();
let runner = fake_runner(
&mut cx,
"inject-fake",
vec![
tool_call_response(vec![tool_call(
"call-1",
Symbol::qualified("test", "sum"),
vec![number(2), number(3)],
)]),
final_response("continued after injected tool"),
],
);
let agent = started_agent(&mut cx, vec![runner], vec![tool_value], Vec::new());
let (expr, diagnostics) = agent_answer_expr(&mut cx, &agent, model_request("sum", Vec::new()));
assert!(diagnostics.is_empty());
assert!(flatten_text(&expr).contains("continued after injected tool"));
}
#[test]
fn a6_phase5_request_policy_denied_tool_fails_closed() {
let mut cx = phase5_cx();
cx.grant_named("math");
let tool = register_sum_tool(&mut cx);
let tool_value = cx.resolve_value(&tool.symbol).unwrap();
let runner = fake_runner(
&mut cx,
"denied-fake",
vec![tool_call_response(vec![tool_call(
"call-denied",
Symbol::qualified("test", "sum"),
vec![number(1), number(1)],
)])],
);
let agent = started_agent(&mut cx, vec![runner], vec![tool_value], Vec::new());
let request = model_request(
"denied",
vec![key_expr(
"tool-policy",
Expr::Map(vec![key_expr("allow", Expr::List(Vec::new()))]),
)],
);
let (expr, _) = agent_answer_expr(&mut cx, &agent, request);
assert!(format!("{expr:?}").contains("tool test/sum was not declared"));
}
#[test]
fn a6_phase5_explicit_conflicting_descriptor_is_rejected() {
let mut cx = phase5_cx();
let tool = register_sum_tool(&mut cx);
let tool_value = cx.resolve_value(&tool.symbol).unwrap();
let runner = fake_runner(&mut cx, "conflict-fake", vec![final_response("unused")]);
let agent = started_agent(&mut cx, vec![runner], vec![tool_value], Vec::new());
let request = model_request(
"conflict",
vec![key_expr(
"tools",
Expr::List(vec![Expr::Map(vec![
key_expr("name", Expr::Symbol(Symbol::qualified("test", "sum"))),
key_expr("description", Expr::String("wrong descriptor".to_owned())),
])]),
)],
);
let error = agent_answer_frame(&mut cx, &agent, request).unwrap_err();
assert!(error.to_string().contains("conflicts on field description"));
}
#[test]
fn a6_phase5_tool_capability_is_not_bypassed_by_injection() {
let mut cx = phase5_cx();
let tool = register_sum_tool(&mut cx);
let tool_value = cx.resolve_value(&tool.symbol).unwrap();
let runner = fake_runner(
&mut cx,
"capability-fake",
vec![
tool_call_response(vec![tool_call(
"call-capability",
Symbol::qualified("test", "sum"),
vec![number(4), number(5)],
)]),
final_response("continued after denied tool"),
],
);
let agent = started_agent(&mut cx, vec![runner], vec![tool_value], Vec::new());
let (expr, diagnostics) =
agent_answer_expr(&mut cx, &agent, model_request("capability", Vec::new()));
assert!(flatten_text(&expr).contains("continued after denied tool"));
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("tool test/sum failed"))
);
}
#[test]
fn a6_phase5_injected_descriptors_replay_through_cassette() {
let mut cx = phase5_cx();
let tool = register_sum_tool(&mut cx);
let tool_value = cx.resolve_value(&tool.symbol).unwrap();
let journal = temp_memory_path("tool-injection-cassette");
let recorder = recorder_journal(&mut cx, journal.display().to_string());
let fake = fake_runner(
&mut cx,
"record-fake",
vec![final_response("recorded with injected descriptor")],
);
let recording_agent = started_agent(
&mut cx,
vec![fake],
vec![tool_value.clone()],
vec![recorder.clone()],
);
let (recorded, _) = agent_answer_expr(
&mut cx,
&recording_agent,
model_request("record", Vec::new()),
);
assert!(flatten_text(&recorded).contains("recorded with injected descriptor"));
let snapshot = recorder_snapshot(&mut cx, &recorder);
let snapshot_text = format!("{snapshot:?}");
assert!(snapshot_text.contains("agent-tool-injection"));
assert!(snapshot_text.contains("descriptors"));
assert!(snapshot_text.contains("sum"));
let cassette = cassette_runner(&mut cx, journal.display().to_string());
let replay_agent = started_agent(&mut cx, vec![cassette], vec![tool_value], Vec::new());
let (replayed, _) =
agent_answer_expr(&mut cx, &replay_agent, model_request("record", Vec::new()));
assert!(flatten_text(&replayed).contains("recorded with injected descriptor"));
let _ = std::fs::remove_file(journal);
}
#[test]
fn phase0_agent_tool_calls_still_route_through_tool_call_values() {
let mut cx = phase5_cx();
let tool = register_sum_tool(&mut cx);
let args = vec![number_value(&mut cx, 2), number_value(&mut cx, 3)];
let denied = tool.call_values(&mut cx, args.clone()).unwrap_err();
assert!(denied.to_string().contains("math"));
cx.grant_named("math");
let result = tool.call_values(&mut cx, args).unwrap();
match result.object().as_expr(&mut cx).unwrap() {
Expr::Number(number) => assert_eq!(number.canonical, "5"),
other => panic!("expected number result, got {other:?}"),
}
let source = include_str!("../components/runtime/runner_tools.rs");
assert!(source.contains("tool.call_values(cx, args)?"));
}
#[test]
fn phase0_streaming_agent_tool_calls_emit_model_events() {
let mut cx = phase5_cx();
cx.grant_named("math");
register_sum_tool(&mut cx);
let runner = fake_runner(
&mut cx,
"phase0-stream-tools",
vec![
tool_call_response(vec![tool_call(
"call-stream",
Symbol::qualified("test", "sum"),
vec![number(2), number(4)],
)]),
final_response("stream continued"),
],
);
let request = model_request(
"stream tool",
vec![key_expr(
"tools",
Expr::List(vec![tool_descriptor(Symbol::qualified("test", "sum"))]),
)],
);
let mut sink = CollectSink::default();
let frame = request_frame(&mut cx, request);
as_component(&runner)
.stream(&mut cx, frame, &mut sink)
.unwrap();
assert!(sink.ended);
assert_eq!(sink.seen.first(), Some(&FrameKind::StreamStart));
assert_eq!(sink.seen.last(), Some(&FrameKind::StreamEnd));
let events = sink
.chunks
.iter()
.filter_map(|expr| field(expr, "event").cloned())
.collect::<Vec<_>>();
assert!(events.contains(&Expr::Symbol(Symbol::new("tool-call"))));
assert!(events.contains(&Expr::Symbol(Symbol::new("tool-result"))));
assert!(
sink.chunks
.iter()
.any(|expr| format!("{expr:?}").contains("stream continued"))
);
}
fn phase5_cx() -> Cx {
let mut cx = eval_cx();
install_test_codec(&mut cx);
install_agent_lib(&mut cx).unwrap();
cx.grant_named("agent-spawn");
cx
}
fn started_agent(
cx: &mut Cx,
runners: Vec<Value>,
tools: Vec<Value>,
recorders: Vec<Value>,
) -> Value {
let runners = manifest_arg(cx, runners);
let tools = manifest_arg(cx, tools);
let recorders = manifest_arg(cx, recorders);
let agent = cx
.call_function(
&Symbol::qualified("agent", "make"),
Args::new(vec![
cx.factory().symbol(Symbol::new(":name")).unwrap(),
cx.factory().symbol(Symbol::new("phase5-agent")).unwrap(),
cx.factory().symbol(Symbol::new(":runners")).unwrap(),
runners,
cx.factory().symbol(Symbol::new(":tools")).unwrap(),
tools,
cx.factory().symbol(Symbol::new(":recorders")).unwrap(),
recorders,
]),
)
.unwrap();
cx.call_function(
&Symbol::qualified("agent", "start"),
Args::new(vec![agent.clone()]),
)
.unwrap();
agent
}
fn manifest_arg(cx: &mut Cx, mut values: Vec<Value>) -> Value {
if values.len() == 1 {
values.remove(0)
} else {
cx.factory().list(values).unwrap()
}
}
fn agent_answer_expr(
cx: &mut Cx,
agent: &Value,
request: Expr,
) -> (Expr, Vec<sim_kernel::Diagnostic>) {
let frame = agent_answer_frame(cx, agent, request).unwrap();
let reply = eval_reply_from_frame(cx, &frame).unwrap();
let expr = reply.value.object().as_expr(cx).unwrap();
(expr, reply.diagnostics)
}
fn agent_answer_frame(cx: &mut Cx, agent: &Value, request: Expr) -> Result<ServerFrame> {
let agent = agent.object().downcast_ref::<crate::Agent>().unwrap();
let frame = request_frame(cx, request);
agent.site()?.answer(cx, frame)
}
fn fake_runner(cx: &mut Cx, name: &str, script: Vec<Expr>) -> Value {
let script_value = cx.factory().expr(Expr::List(script)).unwrap();
cx.call_function(
&Symbol::qualified("runner", "fake"),
Args::new(vec![
cx.factory().symbol(Symbol::new(":name")).unwrap(),
cx.factory().symbol(Symbol::new(name)).unwrap(),
cx.factory().symbol(Symbol::new(":model")).unwrap(),
cx.factory().string(format!("{name}/model")).unwrap(),
cx.factory().symbol(Symbol::new(":script")).unwrap(),
script_value,
]),
)
.unwrap()
}
fn cassette_runner(cx: &mut Cx, journal: String) -> Value {
cx.call_function(
&Symbol::qualified("runner", "cassette"),
Args::new(vec![
cx.factory().symbol(Symbol::new(":journal")).unwrap(),
cx.factory().string(journal).unwrap(),
]),
)
.unwrap()
}
fn recorder_journal(cx: &mut Cx, path: String) -> Value {
cx.call_function(
&Symbol::qualified("recorder", "journal"),
Args::new(vec![
cx.factory().symbol(Symbol::new(":path")).unwrap(),
cx.factory().string(path).unwrap(),
]),
)
.unwrap()
}
fn recorder_snapshot(cx: &mut Cx, recorder: &Value) -> Expr {
let frame = request_frame(cx, Expr::List(vec![Expr::Symbol(Symbol::new("snapshot"))]));
let reply = as_component(recorder).answer(cx, frame).unwrap();
eval_reply_from_frame(cx, &reply)
.unwrap()
.value
.object()
.as_expr(cx)
.unwrap()
}
fn model_request(task: &str, extra: Vec<(Expr, Expr)>) -> Expr {
let mut entries = vec![
key_expr("model-request", Expr::Bool(true)),
key_expr("task", Expr::String(task.to_owned())),
key_expr("messages", Expr::List(Vec::new())),
];
entries.extend(extra);
Expr::Map(entries)
}
fn tool_call_response(tool_calls: Vec<Expr>) -> Expr {
Expr::Map(vec![
key_expr("model-response", Expr::Bool(true)),
key_expr("runner", Expr::Symbol(Symbol::new("phase5-fake"))),
key_expr("model", Expr::String("runner/fake".to_owned())),
key_expr("content", Expr::List(Vec::new())),
key_expr("stop-reason", Expr::Symbol(Symbol::new("tool-call"))),
key_expr("tool-calls", Expr::List(tool_calls)),
])
}
fn final_response(text: &str) -> Expr {
Expr::Map(vec![
key_expr("model-response", Expr::Bool(true)),
key_expr("runner", Expr::Symbol(Symbol::new("phase5-fake"))),
key_expr("model", Expr::String("runner/fake".to_owned())),
key_expr(
"content",
Expr::List(vec![Expr::Map(vec![
key_expr("type", Expr::Symbol(Symbol::new("text"))),
key_expr("text", Expr::String(text.to_owned())),
])]),
),
key_expr("stop-reason", Expr::Symbol(Symbol::new("stop"))),
key_expr("text", Expr::String(text.to_owned())),
])
}
fn tool_call(id: &str, name: Symbol, args: Vec<Expr>) -> Expr {
Expr::Map(vec![
key_expr("id", Expr::String(id.to_owned())),
key_expr("name", Expr::Symbol(name)),
key_expr("arguments", Expr::List(args)),
])
}
fn tool_descriptor(name: Symbol) -> Expr {
Expr::Map(vec![key_expr("name", Expr::Symbol(name))])
}
fn number(value: u32) -> Expr {
Expr::Number(sim_kernel::NumberLiteral {
domain: Symbol::qualified("numbers", "f64"),
canonical: value.to_string(),
})
}
fn number_value(cx: &mut Cx, value: u32) -> Value {
cx.factory()
.number_literal(Symbol::qualified("numbers", "f64"), value.to_string())
.unwrap()
}
fn field<'a>(expr: &'a Expr, name: &str) -> Option<&'a Expr> {
let Expr::Map(entries) = expr else {
return None;
};
entries.iter().find_map(|(key, value)| match key {
Expr::Symbol(symbol) if symbol.namespace.is_none() && symbol.name.as_ref() == name => {
Some(value)
}
_ => None,
})
}
fn key_expr(name: &str, value: Expr) -> (Expr, Expr) {
(Expr::Symbol(Symbol::new(name)), value)
}