Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7de3f5b
chore(deps): update vendored submodules
senamakel Sep 24, 2026
58fd56b
fix(events): handle missing `parent_span_id` in event deserialization
senamakel Sep 24, 2026
311af4f
fix(harness): handle tool call with no arguments
senamakel Sep 24, 2026
e4fd188
chore(observability): remove unused observability module
senamakel Sep 24, 2026
c9f462c
fix(observability): correct test assertion for observability event count
senamakel Sep 24, 2026
4fd60d5
fix(stream): handle empty stream in test helper
senamakel Sep 24, 2026
3f22781
fix(testkit): add missing `input` field to `ToolStarted` events in te…
senamakel Sep 24, 2026
97120f3
fix(tests): add missing input field to ToolStarted events
senamakel Sep 24, 2026
2695c71
fix(test): add missing input field to ToolStarted event in contract t…
senamakel Sep 24, 2026
dc0056a
chore(deps): update vendored submodules
senamakel Sep 24, 2026
4ca8523
chore(project): rename `Stream` to `Projection` for clarity
senamakel Sep 24, 2026
94a9f01
fix(test): add missing `input` field to ToolStarted in tests
senamakel Sep 24, 2026
cbc988b
feat(orchestration): add e2e test for orchestrator subagents
senamakel Sep 24, 2026
7b56507
fix(agent_loop): correct test assertion for agent response
senamakel Sep 24, 2026
30c4336
chore(deps): update testkit module to use latest harness API
senamakel Sep 24, 2026
232b253
fix(run_queue): handle empty queue without panicking
senamakel Sep 24, 2026
6564f14
chore(run_queue): add test module for run queue
senamakel Sep 24, 2026
2f4abba
refactor(run_queue): reformat snapshot method and test assertion
senamakel Sep 24, 2026
b31d918
fix(middleware): handle missing library type fields gracefully
senamakel Sep 24, 2026
8e7ea73
fix(middleware): handle missing type field in library types
senamakel Sep 24, 2026
4a3055d
chore: files changed crates/tinyagents-harness/src/middleware/library…
senamakel Sep 24, 2026
69c1987
chore(deps): update test dependency to use workspace version
senamakel Sep 24, 2026
5a05bc4
docs(harness): add middleware documentation
senamakel Sep 24, 2026
3f994bf
chore(harness): reformat constructor arguments for consistency
senamakel Sep 24, 2026
bef73a2
fix(transcript): handle empty history in session transcript
senamakel Sep 24, 2026
8e549fd
fix(transcript): handle empty message list in transcript
senamakel Sep 24, 2026
c7ba05b
fix(transcript): correct test assertion for empty transcript
senamakel Sep 24, 2026
967f84b
fix(transcript): reformat long method signatures and calls
senamakel Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/tinyagents-graph/src/stream/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,9 @@ impl StreamProjection {
},
});
}
AgentEvent::ToolStarted { call_id, tool_name } => {
AgentEvent::ToolStarted {
call_id, tool_name, ..
} => {
self.push_tool_call(call_id.clone(), tool_name.clone(), ToolCallPhase::Started);
}
AgentEvent::ToolCompleted {
Expand Down
2 changes: 2 additions & 0 deletions crates/tinyagents-graph/src/stream/project/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ fn stream_projection_folds_tool_lifecycle_as_two_entries() {
projection.fold_agent_event(&AgentEvent::ToolStarted {
call_id: CallId::from("call-1".to_string()),
tool_name: "search".into(),
input: None,
});
projection.fold_agent_event(&AgentEvent::ToolCompleted {
call_id: CallId::from("call-1".to_string()),
Expand Down Expand Up @@ -148,6 +149,7 @@ fn stream_projection_since_replays_only_items_after_the_given_cursor() {
projection.fold_agent_event(&AgentEvent::ToolStarted {
call_id: CallId::from("call-1".to_string()),
tool_name: "search".into(),
input: None,
});
let cursor_after_first = projection.cursor();
projection.fold_agent_event(&AgentEvent::ModelDelta {
Expand Down
82 changes: 82 additions & 0 deletions crates/tinyagents-harness/src/agent_loop/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5389,6 +5389,88 @@ async fn tool_completed_event_carries_outcome() {
assert_eq!(output_bytes, Some(6), "\"kaboom\".len() == 6");
}

#[tokio::test]
async fn tool_started_event_carries_input_when_capture_enabled() {
use crate::events::RecordingListener;
use crate::runtime::PayloadCapture;

// Hosts render a tool call's arguments as soon as it starts, not only on
// completion, so `ToolStarted` must carry the same captured input the
// policy already puts on `ToolCompleted`.
let mut harness: AgentHarness<()> = AgentHarness::new();
harness.register_model(
"mock",
Arc::new(MockModel::with_responses(vec![
tool_call_response("c1", "echo", json!({ "q": "weather" })),
text_response("done", 1, 1),
])),
);
harness.register_tool(Arc::new(FakeTool::new("echo", "ok")));
harness.with_policy(RunPolicy {
capture: PayloadCapture::all(),
..RunPolicy::default()
});

let recorder = Arc::new(RecordingListener::new());
let ctx: RunContext<()> = RunContext::new(RunConfig::new("run-ts"), ());
ctx.events.subscribe(recorder.clone());
harness
.invoke_in_context(&(), ctx, vec![Message::user("go")])
.await
.expect("run succeeds");

let input = recorder
.events()
.into_iter()
.find_map(|record| match record.event {
AgentEvent::ToolStarted {
tool_name, input, ..
} if tool_name == "echo" => Some(input),
_ => None,
})
.expect("a ToolStarted event for `echo`");

assert_eq!(input, Some(json!({ "q": "weather" })));
}

#[tokio::test]
async fn tool_started_event_has_no_input_when_capture_disabled() {
use crate::events::RecordingListener;

// Default policy is payload-free: `ToolStarted.input` stays `None` so no
// tool argument is captured unless the host opts in.
let mut harness: AgentHarness<()> = AgentHarness::new();
harness.register_model(
"mock",
Arc::new(MockModel::with_responses(vec![
tool_call_response("c1", "echo", json!({ "q": "weather" })),
text_response("done", 1, 1),
])),
);
harness.register_tool(Arc::new(FakeTool::new("echo", "ok")));

let recorder = Arc::new(RecordingListener::new());
let ctx: RunContext<()> = RunContext::new(RunConfig::new("run-ts-off"), ());
ctx.events.subscribe(recorder.clone());
harness
.invoke_in_context(&(), ctx, vec![Message::user("go")])
.await
.expect("run succeeds");

let input = recorder
.events()
.into_iter()
.find_map(|record| match record.event {
AgentEvent::ToolStarted {
tool_name, input, ..
} if tool_name == "echo" => Some(input),
_ => None,
})
.expect("a ToolStarted event for `echo`");

assert_eq!(input, None);
}

// ── `ModelResponse::continue_turn` ───────────────────────────────────────────

/// A tool-less response that keeps the floor by carrying `nudge`.
Expand Down
9 changes: 6 additions & 3 deletions crates/tinyagents-harness/src/agent_loop/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -902,9 +902,15 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
// Captured here (where the call actually starts) so the completed
// event carries a real start time for duration-aware exporters.
let started_at_ms = crate::ids::now_ms();
// Snapshot the arguments for observability before `call` is moved
// into execution, gated by the capture policy. Shared between the
// `ToolStarted` event (so a host sees the arguments as soon as the
// call starts) and the fold-phase `ToolCompleted` event.
let captured_input = self.policy.capture.tool_io.then(|| call.arguments.clone());
let record = ctx.emit(AgentEvent::ToolStarted {
call_id: call_id.clone(),
tool_name: tool_name.clone(),
input: captured_input.clone(),
});
crate::runtime::emit_host_progress::<State, Ctx>(
ctx,
Expand All @@ -915,9 +921,6 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
},
);
status.set_last_event(record.id);
// Snapshot the arguments for observability before `call` is moved
// into execution, gated by the capture policy.
let captured_input = self.policy.capture.tool_io.then(|| call.arguments.clone());
PreparedToolCall {
call_id,
tool_name,
Expand Down
8 changes: 8 additions & 0 deletions crates/tinyagents-harness/src/events/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,14 @@ pub enum AgentEvent {
call_id: CallId,
/// Name of the tool being invoked.
tool_name: String,
/// The arguments the tool is being invoked with, captured only when
/// [`PayloadCapture::tool_io`][crate::runtime::PayloadCapture::tool_io]
/// is enabled. `None` in the default payload-free mode, and for
/// events serialized before this field existed. Populated so a host
/// or UI can render the call's arguments as soon as it starts,
/// instead of waiting for [`AgentEvent::ToolCompleted`].
#[serde(default, skip_serializing_if = "Option::is_none")]
input: Option<serde_json::Value>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority critical critique confident

Update every ToolStarted constructor

Adding a required field to this enum variant breaks the existing constructor in agent_loop/tools.rs, which still constructs AgentEvent::ToolStarted without input (the repository search finds that production construction site). As a result, the crate will not compile. Add the captured tool arguments there, gated by the run's PayloadCapture::tool_io setting, or explicitly set input: None when capture is disabled.

[RULE] compile-break ·

},

/// A tool invocation returned.
Expand Down
154 changes: 154 additions & 0 deletions crates/tinyagents-harness/src/middleware/library/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1676,3 +1676,157 @@ async fn tracing_records_are_bounded_by_max_records() {
let counts = tracing.counts();
assert_eq!(counts.agent, 50);
}

// ── PlanModeMiddleware ───────────────────────────────────────────────────────

fn plan_mode_policies() -> std::collections::HashMap<String, ToolPolicy> {
let mut policies = std::collections::HashMap::new();
policies.insert("read_file".to_string(), ToolPolicy::read_only());
policies.insert(
"write_file".to_string(),
ToolPolicy::classified().with_side_effects(ToolSideEffects {
writes_files: true,
..ToolSideEffects::default()
}),
);
policies
}

#[tokio::test]
async fn build_mode_leaves_every_tool_exposed_and_executable() {
let (mut ctx, _recorder) = ctx_with_recorder();
let mode = RunModeHandle::new(RunMode::Build);
let mw = plan_mode_middleware(mode, plan_mode_policies());
let mut stack: MiddlewareStack<()> = MiddlewareStack::new();
stack.push(Arc::new(mw));

let mut request = ModelRequest::new(Vec::new())
.with_tools(vec![schema_named("read_file"), schema_named("write_file")]);
stack
.run_before_model(&mut ctx, &(), &mut request)
.await
.unwrap();
assert_eq!(request.tools.len(), 2);

let mut call = tool_call("write_file");
stack
.run_before_tool(&mut ctx, &(), &mut call)
.await
.expect("build mode admits every tool");
}

#[tokio::test]
async fn plan_mode_hides_side_effecting_tools_and_keeps_read_only_ones() {
let (mut ctx, recorder) = ctx_with_recorder();
let mode = RunModeHandle::new(RunMode::Plan);
let mw = plan_mode_middleware(mode, plan_mode_policies());
let mut stack: MiddlewareStack<()> = MiddlewareStack::new();
stack.push(Arc::new(mw));

let mut request = ModelRequest::new(Vec::new())
.with_tools(vec![schema_named("read_file"), schema_named("write_file")]);
stack
.run_before_model(&mut ctx, &(), &mut request)
.await
.unwrap();
assert_eq!(request.tools.len(), 1);
assert_eq!(request.tools[0].name, "read_file");

let filtered = recorder.events().into_iter().find_map(|r| match r.event {
AgentEvent::ToolsFiltered { excluded, .. } => Some(excluded),
_ => None,
});
assert_eq!(filtered, Some(vec!["write_file".to_string()]));
}

#[tokio::test]
async fn plan_mode_denies_side_effecting_tool_at_execution() {
let (mut ctx, _recorder) = ctx_with_recorder();
let mode = RunModeHandle::new(RunMode::Plan);
let mw = plan_mode_middleware(mode, plan_mode_policies());
let mut stack: MiddlewareStack<()> = MiddlewareStack::new();
stack.push(Arc::new(mw));

let mut call = tool_call("write_file");
let err = stack
.run_before_tool(&mut ctx, &(), &mut call)
.await
.expect_err("side-effecting tool denied in plan mode");
assert!(matches!(err, TinyAgentsError::Validation(_)));

let mut call = tool_call("read_file");
stack
.run_before_tool(&mut ctx, &(), &mut call)
.await
.expect("read-only tool stays executable in plan mode");
}

#[tokio::test]
async fn plan_mode_allowlist_keeps_specific_tools_available() {
let (mut ctx, _recorder) = ctx_with_recorder();
let mode = RunModeHandle::new(RunMode::Plan);
let mw = plan_mode_middleware(mode, plan_mode_policies()).allow(["plan_exit"]);
let mut stack: MiddlewareStack<()> = MiddlewareStack::new();
stack.push(Arc::new(mw));

let mut request = ModelRequest::new(Vec::new()).with_tools(vec![
schema_named("read_file"),
schema_named("write_file"),
schema_named("plan_exit"),
]);
stack
.run_before_model(&mut ctx, &(), &mut request)
.await
.unwrap();
let names: Vec<_> = request.tools.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["read_file", "plan_exit"]);

let mut call = tool_call("plan_exit");
stack
.run_before_tool(&mut ctx, &(), &mut call)
.await
.expect("allowlisted tool stays executable in plan mode");
}

#[tokio::test]
async fn plan_mode_treats_unclassified_tools_as_side_effecting() {
// Fail-closed: a tool with no policy entry at all is hidden/denied in
// plan mode unless explicitly allowlisted.
let (mut ctx, _recorder) = ctx_with_recorder();
let mode = RunModeHandle::new(RunMode::Plan);
let mw = plan_mode_middleware(mode, std::collections::HashMap::new());
let mut stack: MiddlewareStack<()> = MiddlewareStack::new();
stack.push(Arc::new(mw));

let mut call = tool_call("mystery");
let err = stack
.run_before_tool(&mut ctx, &(), &mut call)
.await
.expect_err("unclassified tool denied by default in plan mode");
assert!(matches!(err, TinyAgentsError::Validation(_)));
}

#[tokio::test]
async fn run_mode_handle_set_takes_effect_immediately_on_shared_clones() {
let mode = RunModeHandle::new(RunMode::Build);
let mw_mode = mode.clone();
let mw = plan_mode_middleware(mw_mode, plan_mode_policies());
let mut stack: MiddlewareStack<()> = MiddlewareStack::new();
stack.push(Arc::new(mw));
let (mut ctx, _recorder) = ctx_with_recorder();

let mut call = tool_call("write_file");
stack
.run_before_tool(&mut ctx, &(), &mut call)
.await
.expect("build mode admits the call");

// Flip the shared handle; the middleware's own clone observes it.
mode.set(RunMode::Plan);

let mut call = tool_call("write_file");
stack
.run_before_tool(&mut ctx, &(), &mut call)
.await
.expect_err("plan mode now denies the same call");
}
Loading
Loading