mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 01:19:34 +00:00
* refactor: extract shared assertion helpers to support/assertions.rs Move 5 assertion helpers from e2e_spot_checks.rs to a shared module. Add assert_all_tools_succeeded and assert_tool_succeeded for eliminating false positives in E2E tests. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add tool output capture via tool_results() accessor Extract (name, preview) from ToolResult status events in TestChannel and TestRig, enabling content assertions on tool outputs. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: correct tool parameters in 3 broken trace fixtures - tool_time.json: add missing "operation": "now" for time tool - robust_correct_tool.json: same fix - memory_full_cycle.json: change "path" to "target" for memory_write Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add tool success and output assertions to eliminate false positives Every E2E test that exercises tools now calls assert_all_tools_succeeded. Added tool output content assertions where tool results are predictable (time year, read_file content, memory_read content). Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: capture per-tool timing from ToolStarted/ToolCompleted events Record Instant on ToolStarted and compute elapsed duration on ToolCompleted, wiring real timing data into collect_metrics() instead of hardcoded zeros. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: add RAII CleanupGuard for temp file/dir cleanup in tests Replace manual cleanup_test_dir() calls and inline remove_file() with Drop-based CleanupGuard that ensures cleanup even if a test panics. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add Drop impl and graceful shutdown for TestRig Wrap agent_handle in Option so Drop can abort leaked tasks. Signal the channel shutdown before aborting for future cooperative shutdown. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: replace agent startup sleep with oneshot ready signal Use a oneshot channel fired in Channel::start() instead of a fixed 100ms sleep, eliminating the race condition on slow systems. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: replace fragile string-matching iteration limit with count-based detection Use tool completion count vs max_tool_iterations instead of scanning status messages for "iteration"/"limit" substrings. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use assert_all_tools_succeeded for memory_full_cycle test Remove incorrect comment about memory_tree failing with empty path (it actually succeeds). Omit empty path from fixture and use the standard assert_all_tools_succeeded instead of per-tool assertions. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: promote benchmark metrics types to library code Move TraceMetrics, ScenarioResult, RunResult, MetricDelta, and compare_runs() from tests/support/metrics.rs to src/benchmark/metrics.rs. Existing tests use re-export for backward compatibility. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add Scenario and Criterion types for agent benchmarking Scenario defines a task with input, success criteria, and resource limits. Criterion is an enum of programmatic checks (tool_used, response_contains, etc.) evaluated without LLM judgment. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add initial benchmark scenario suite (12 scenarios across 5 categories) Scenarios cover tool_selection, tool_chaining, error_recovery, efficiency, and memory_operations. All loaded from JSON with deserialization validation test. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add benchmark runner with BenchChannel and InstrumentedLlm BenchChannel is a minimal Channel implementation for benchmarks. InstrumentedLlm wraps any LlmProvider to capture per-call metrics. Runner creates a fresh agent per scenario, evaluates success criteria, and produces RunResult with timing, token, and cost metrics. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add baseline management, reports, and benchmark entry point - baseline.rs: load/save/promote benchmark results - report.rs: format comparison reports with regression detection - benchmark_runner.rs: integration test with real LLM (feature-gated) - Add benchmark feature flag to Cargo.toml Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: apply cargo fmt to benchmark module Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add multi-turn scenario types with setup, judge, ResponseNotContains Add BenchScenario, Turn, TurnAssertions, JudgeConfig, ScenarioSetup, WorkspaceSetup, SeedDocument types for multi-turn benchmark scenarios. Add ResponseNotContains criterion variant. Add TurnAssertions::to_criteria() converter for backward compat with existing evaluation engine. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add JSON scenario loader with recursive discovery and tag filter Add load_bench_scenarios() for the new BenchScenario format with recursive directory traversal and tag-based filtering. Create 4 initial trajectory scenarios across tool-selection, multi-turn, and efficiency categories. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): multi-turn runner with workspace seeding and per-turn metrics Add run_bench_scenario() that loops over BenchScenario turns, seeds workspace documents, collects per-turn metrics (tokens, tool calls, wall time), and evaluates per-turn assertions. Add TurnMetrics to metrics.rs and clear_for_next_turn() to BenchChannel. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add LLM-as-judge scoring with prompt formatting and score parsing Create judge.rs with format_judge_prompt, parse_judge_score, and judge_turn. Wire into run_bench_scenario for turns with judge config -- scores below min_score fail the turn. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add CLI subcommand (ironclaw benchmark) Add BenchmarkCommand with --tags, --scenario, --no-judge, --timeout, --update-baseline flags. Wire into Command enum and main.rs dispatch. Feature-gated behind benchmark flag. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): per-scenario JSON output with full trajectory Add save_scenario_results() that writes per-scenario JSON files alongside the run summary. Each scenario gets its own file with turn_metrics trajectory. Update CLI to use new output format. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add ToolRegistry::retain_only and wire tool filtering in scenarios Add a retain_only() method to ToolRegistry that filters tools down to a given allowlist. Wire this into run_bench_scenario() so that when a scenario specifies a tools list in its setup, only those tools are available during the benchmark run. Includes two tests for the new method: one verifying filtering works and one verifying empty input is a no-op. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): wire identity overrides into workspace before agent start Add seed_identity() helper that writes identity files (IDENTITY.md, USER.md, etc.) into the workspace before the agent starts, so that workspace.system_prompt() picks them up. Wire it into run_bench_scenario() after workspace seeding. Include a test that verifies identity files are written and readable. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add --parallel and --max-cost CLI flags Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(benchmark): use feature-conditional snapshot names for CLI help tests Prevents snapshot conflicts between default (no benchmark) and all-features (with benchmark) builds by using separate snapshot names per feature set. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): parallel execution with JoinSet and budget cap enforcement Replace sequential loop in run_all_bench() with parallel execution using JoinSet + semaphore when config.parallel > 1. Add budget cap enforcement that skips remaining scenarios when max_total_cost_usd is exceeded. Track skipped count in RunResult.skipped_scenarios and display it in format_report(). Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add tool restriction and identity override test scenarios Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: fix formatting for Phase 3 Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add SkillRegistry::retain_only and wire skill filtering in scenarios Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(benchmark): add --json flag for machine-readable output Co-Authored-By: Claude Opus 4.6 <[email protected]> * ci: add GitHub Actions benchmark workflow (manual trigger) Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(benchmark): remove in-tree benchmark harness, keep retain_only utilities Move benchmark-specific code out of ironclaw in preparation for the nearai/benchmarks trajectory adapter. This removes: - src/benchmark/ (runner, scenarios, metrics, judge, report, etc.) - src/cli/benchmark.rs and the Benchmark CLI subcommand - benchmarks/ data directory (scenarios + trajectories) - .github/workflows/benchmark.yml - The "benchmark" Cargo feature flag What remains: - ToolRegistry::retain_only() and SkillRegistry::retain_only() - Test support types (TraceMetrics, InstrumentedLlm) inlined into tests/support/ instead of re-exporting from the deleted module Co-Authored-By: Claude Opus 4.6 <[email protected]> * docs: add README for LLM trace fixture format Documents the trajectory JSON format, response types, request hints, directory structure, and how to write new traces. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(test): unify trace format around turns, add multi-turn support Introduce TraceTurn type that groups user_input with LLM response steps, making traces self-contained conversation trajectories. Add run_trace() to TestRig for automatic multi-turn replay. Backward-compatible: flat "steps" JSON is deserialized as a single turn transparently. Includes all trace fixtures (spot, coverage, advanced), plan docs, and new e2e tests for steering, error recovery, long chains, memory, and prompt injection resilience. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): fix CI failures after merging main - Fix tool_json fixture: use "data" parameter (not "input") to match JsonTool schema - Fix status_events test: remove assertion for "time" tool that isn't in the fixture (only "echo" calls are used) - Allow dead_code in test support metrics/instrumented_llm modules (utilities for future benchmark tests) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * Working on recording traces and testing them * feat(test): add declarative expects to trace fixtures, split infra tests Add TraceExpects struct with 9 optional assertion fields (response_contains, tools_used, all_tools_succeeded, etc.) that can be declared in fixture JSON instead of hand-written Rust. Add verify_expects() and run_recorded_trace() so recorded trace tests become one-liners. Split trace infra tests (deserialization, backward compat) into tests/trace_format.rs which doesn't require the libsql feature gate. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(test): add expects to all trace fixtures, simplify e2e tests Add declarative expects blocks to all 19 trace fixture JSONs across spot/, coverage/, advanced/, and root directories. Update all 8 e2e test files to use verify_trace_expects() / run_and_verify_trace(), replacing ~270 lines of hand-written assertions with fixture-driven verification. Tests that check things beyond expects (file content on disk, metrics, event ordering) keep those extra assertions alongside the declarative ones. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): adapt tests to AppBuilder refactor, fix formatting Update test files to work with refactored TestRigBuilder that uses AppBuilder::build_all() (removing with_tools/with_workspace methods). Update telegram_check fixture to use tool_list instead of echo. Fix cargo fmt issues in src/llm/mod.rs and src/llm/recording.rs. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(test): deduplicate support unit tests into single binary Support modules (assertions, cleanup, test_channel, test_rig, trace_llm) had #[cfg(test)] mod tests blocks that were compiled and run 12 times — once per e2e test binary that declares `mod support;`. Extracted all 29 support unit tests into a dedicated `tests/support_unit_tests.rs` so they run exactly once. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix trailing newlines in support files Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor(test): unify trace types and fix recorded multi-turn replay Import shared types (TraceStep, TraceResponse, TraceToolCall, RequestHint, ExpectedToolResult, MemorySnapshotEntry, HttpExchange*) from ironclaw::llm::recording instead of redefining them in trace_llm.rs. Fix the flat-steps deserializer to split at UserInput boundaries into multiple turns, instead of filtering them out and wrapping everything into a single turn. This enables recorded multi-turn traces to be replayed as proper multi-turn conversations via run_trace(). [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): fix CI failures - unused imports and missing struct fields - Add #[allow(unused_imports)] on pub use re-exports in trace_llm.rs (types are re-exported for downstream test files, not used locally) - Add `..` to ToolCompleted pattern in test_channel.rs to match new `error` and `parameters` fields Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): fix CI failures after merging main - Add missing `error` and `parameters` fields to ToolCompleted constructors in support_unit_tests.rs - Add `..` to ToolCompleted pattern match in support_unit_tests.rs - Add #[allow(dead_code)] to CleanupGuard, LlmTrace impl, and TraceLlm impl (only used behind #[cfg(feature = "libsql")]) Co-Authored-By: Claude Opus 4.6 <[email protected]> * Adding coverage running script * fix(test): address review feedback on E2E test infrastructure - Increase wait_for_responses polling to exponential backoff (50ms-500ms) and raise default timeout from 15s to 30s to reduce CI flakiness (#1) - Strengthen prompt_injection_resilience test with positive safety layer assertion via has_safety_warnings(), enable injection_check (#2) - Add assert_tool_order() helper and tools_order field in TraceExpects for verifying tool execution ordering in multi-step traces (#3) - Document TraceLlm sequential-call assumption for concurrency (#6) - Clean up CleanupGuard with PathKind enum instead of shotgun remove_file + remove_dir_all on every path (#8) - Fix coverage.sh: default to --lib only, fix multi-filter syntax, add COV_ALL_TARGETS option - Add coverage/ to .gitignore - Remove planning docs from PR [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review - use HashSet in retain_only, improve skill test - Use HashSet for O(N+M) lookup in SkillRegistry::retain_only and ToolRegistry::retain_only instead of linear scan - Strengthen test_retain_only_empty_is_noop in SkillRegistry to pre-populate with a skill before asserting the no-op behavior [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): revert incorrect safety layer assertion in injection test The safety layer sanitizes tool output, not user input. The injection test sends a malicious user message with no tools called, so the safety layer never fires. Reverted to the original test which correctly validates the LLM refuses via trace expects. Also fixed case-sensitive request hint ("ignore" -> "Ignore") to suppress noisy warning. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: clean stale profdata before coverage run Adds `cargo llvm-cov clean` before each run to prevent "mismatched data" warnings from stale instrumentation profiles. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * style: fix formatting in retain_only test [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Illia Polosukhin <[email protected]>
523 lines
21 KiB
Markdown
523 lines
21 KiB
Markdown
# LLM Trace Fixtures
|
|
|
|
Trace fixtures are JSON files that script LLM behavior for deterministic E2E testing. The `TraceLlm` provider (`tests/support/trace_llm.rs`) replays these canned responses in order, allowing tests to exercise the full agent loop -- tool dispatch, safety layer, context accumulation -- without calling a real LLM.
|
|
|
|
Traces can be **hand-written** or **recorded** from a live session using the `RecordingLlm` wrapper (`src/llm/recording.rs`). Recorded traces include additional fields (memory snapshots, HTTP exchanges, expected tool results) that enable fully deterministic replay.
|
|
|
|
## Trace Format
|
|
|
|
A trace is a model name and a list of **turns**. Each turn pairs a user message with the LLM response steps that follow it.
|
|
|
|
```json
|
|
{
|
|
"model_name": "descriptive-name",
|
|
"turns": [
|
|
{
|
|
"user_input": "Write hello to /tmp/test.txt",
|
|
"steps": [
|
|
{
|
|
"response": {
|
|
"type": "tool_calls",
|
|
"tool_calls": [{ "id": "c1", "name": "write_file", "arguments": {"path": "/tmp/test.txt", "content": "hello"} }],
|
|
"input_tokens": 60, "output_tokens": 20
|
|
}
|
|
},
|
|
{
|
|
"response": {
|
|
"type": "text",
|
|
"content": "Done, wrote hello to the file.",
|
|
"input_tokens": 80, "output_tokens": 15
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"user_input": "Actually, change it to goodbye instead",
|
|
"steps": [
|
|
{
|
|
"response": {
|
|
"type": "tool_calls",
|
|
"tool_calls": [{ "id": "c2", "name": "write_file", "arguments": {"path": "/tmp/test.txt", "content": "goodbye"} }],
|
|
"input_tokens": 100, "output_tokens": 20
|
|
}
|
|
},
|
|
{
|
|
"response": {
|
|
"type": "text",
|
|
"content": "Updated the file to say goodbye.",
|
|
"input_tokens": 120, "output_tokens": 15
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
`TestRig::run_trace()` drives the entire conversation automatically -- no test code needed to send user messages.
|
|
|
|
### Legacy flat format
|
|
|
|
For backward compatibility, traces with a top-level `"steps"` array (no `"turns"`) are accepted. They are deserialized as a single turn with a placeholder user message. Existing fixtures work unchanged; test code provides the user message via `rig.send_message()`.
|
|
|
|
```json
|
|
{
|
|
"model_name": "descriptive-name",
|
|
"memory_snapshot": [
|
|
{ "path": "context/vision.md", "content": "..." }
|
|
],
|
|
"http_exchanges": [
|
|
{
|
|
"request": { "method": "GET", "url": "https://api.example.com/data", "headers": [], "body": null },
|
|
"response": { "status": 200, "headers": [], "body": "{\"result\": 42}" }
|
|
}
|
|
],
|
|
"steps": [
|
|
{ "response": { "type": "text", "content": "Hello", "input_tokens": 10, "output_tokens": 5 } },
|
|
{
|
|
"response": { "type": "user_input", "content": "What time is it?" }
|
|
},
|
|
{
|
|
"request_hint": {
|
|
"last_user_message_contains": "optional substring",
|
|
"min_message_count": 1
|
|
},
|
|
"expected_tool_results": [
|
|
{ "tool_call_id": "call_time_1", "name": "time", "content": "14:30:00" }
|
|
],
|
|
"response": { "..." }
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Top-level fields
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `model_name` | string | yes | Identifier returned by `LlmProvider::model_name()`. Convention: `{category}-{scenario}` (e.g. `spot-smoke-greeting`, `advanced-tool-error-recovery`). |
|
|
| `turns` | array | yes* | List of turns. Each turn has `user_input` (string) and `steps` (array of response steps). |
|
|
| `memory_snapshot` | array | no | Workspace memory documents captured before the recording session. Replay should restore these before running the trace. Each entry has `path` (string) and `content` (string). |
|
|
| `http_exchanges` | array | no | HTTP request/response pairs recorded during the session, in order. During replay, the `ReplayingHttpInterceptor` returns these instead of making real HTTP requests. |
|
|
| `expects` | object | no | Declarative expectations verified after replay. See [Expects fields](#expects-fields). |
|
|
|
|
*Or `steps` for the legacy flat format (deserialized as a single turn with a placeholder user message). Legacy `steps` are ordered: each `complete()` or `complete_with_tools()` call consumes the next `text`/`tool_calls` step. `user_input` steps are metadata markers and must be skipped during replay. If LLM calls exceed the number of playable steps, `TraceLlm` returns an error.
|
|
|
|
### Turn fields
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `user_input` | string | yes | The user message that starts this turn. |
|
|
| `steps` | array | yes | Ordered list of LLM response steps for this turn. |
|
|
| `expects` | object | no | Per-turn expectations. Same schema as top-level `expects`. |
|
|
|
|
### Step fields
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `request_hint` | object | no | Soft validation against the incoming request. Mismatches log a warning but do **not** fail the call. |
|
|
| `response` | object | yes | The canned response for this step. |
|
|
| `expected_tool_results` | array | no | Tool results that appeared in the message context since the previous step. During replay, the test harness can compare actual `Role::Tool` messages against these to verify tool output hasn't changed (regression detection). Each entry has `tool_call_id`, `name`, and `content`. |
|
|
|
|
### Request hints
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `last_user_message_contains` | string | Asserts the last `Role::User` message contains this substring. |
|
|
| `min_message_count` | integer | Asserts the message list has at least this many entries. |
|
|
|
|
Hints are intentionally soft -- they help catch wiring mistakes during test development without making traces brittle.
|
|
|
|
### Determinism requirement
|
|
|
|
Trace fixtures must produce deterministic results across runs. **Do not use tools whose output varies by time or environment state.** Specifically:
|
|
|
|
**Avoid:**
|
|
- `time` -- output changes every run
|
|
- `list_dir` on directories not created by the trace itself
|
|
- `shell` with commands that depend on system state (e.g. `date`, `ps`, `ls /var`)
|
|
- `http` -- external endpoints may change or be unavailable
|
|
- `memory_search` unless the trace writes the memory entry first
|
|
|
|
**Prefer:**
|
|
- `echo` -- always returns its input
|
|
- `json` -- deterministic parsing/formatting
|
|
- `write_file` + `read_file` -- self-contained if the trace writes first
|
|
- `memory_write` + `memory_read` -- deterministic if the trace writes first
|
|
- `shell` with deterministic commands (e.g. `echo "hello"`, `printf`)
|
|
|
|
When a trace needs to exercise a stateful tool (like `list_dir`), have an earlier step create the expected state (e.g. `write_file` to create the directory contents first).
|
|
|
|
### Response types
|
|
|
|
Responses are tagged via the `type` field.
|
|
|
|
#### `text` -- plain text completion
|
|
|
|
```json
|
|
{
|
|
"type": "text",
|
|
"content": "The capital of France is Paris.",
|
|
"input_tokens": 40,
|
|
"output_tokens": 10
|
|
}
|
|
```
|
|
|
|
Returns a `CompletionResponse` / `ToolCompletionResponse` with no tool calls and `FinishReason::Stop`. If `complete()` is called (not `complete_with_tools()`), this is the only valid response type.
|
|
|
|
#### `tool_calls` -- one or more tool invocations
|
|
|
|
```json
|
|
{
|
|
"type": "tool_calls",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_write_1",
|
|
"name": "write_file",
|
|
"arguments": { "path": "/tmp/test.txt", "content": "hello" }
|
|
}
|
|
],
|
|
"input_tokens": 80,
|
|
"output_tokens": 25
|
|
}
|
|
```
|
|
|
|
Returns a `ToolCompletionResponse` with `FinishReason::ToolUse`. The agent loop executes the tool calls against real tool implementations, feeds the results back as tool-result messages, then calls the LLM again (consuming the next step).
|
|
|
|
**Important:** `tool_calls` steps cause real tool execution. The tools run against the actual tool registry, so side effects (file writes, memory operations) happen for real. This is what makes these E2E tests -- the only mock is the LLM itself.
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `id` | string | Unique call ID. Convention: `call_{tool}_{n}`. |
|
|
| `name` | string | Must match a registered tool name (e.g. `echo`, `write_file`, `read_file`, `memory_write`, `shell`). |
|
|
| `arguments` | object | Tool parameters as JSON. Must conform to the tool's `parameters_schema()`. |
|
|
|
|
#### `user_input` -- user message marker (recording only)
|
|
|
|
```json
|
|
{
|
|
"type": "user_input",
|
|
"content": "What time is it?"
|
|
}
|
|
```
|
|
|
|
A metadata marker recording what the user said. This does **not** correspond to an LLM call. During replay, `TraceLlm` must skip `user_input` steps and only consume `text`/`tool_calls` steps. These steps are emitted by `RecordingLlm` when it detects new `Role::User` messages between LLM calls.
|
|
|
|
### Token counts
|
|
|
|
Every `text` and `tool_calls` response includes `input_tokens` and `output_tokens`. These are synthetic values for cost tracking -- set them to reasonable estimates for your scenario. `user_input` steps do not have token counts.
|
|
|
|
### Expected tool results
|
|
|
|
When present on a step, `expected_tool_results` lists the tool output that appeared in the message context before this LLM call. Each entry has:
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `tool_call_id` | string | The `id` of the tool call that produced this result. |
|
|
| `name` | string | The tool name. |
|
|
| `content` | string | The full tool result content as it appeared in the message context. |
|
|
|
|
During replay, after tools execute and before returning the canned LLM response, the test harness should compare actual tool results against these entries. A content mismatch indicates a tool behavior change (regression).
|
|
|
|
### Expects fields
|
|
|
|
The `expects` object can appear at the top level (whole trace) or per-turn. All fields are optional; traces without `expects` work unchanged.
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `response_contains` | `string[]` | Each must appear in response (case-insensitive). |
|
|
| `response_not_contains` | `string[]` | None may appear in response. |
|
|
| `response_matches` | `string` | Regex that must match response. |
|
|
| `tools_used` | `string[]` | Each tool name must appear in started calls. |
|
|
| `tools_not_used` | `string[]` | None of these may appear. |
|
|
| `all_tools_succeeded` | `bool` | If true, all tools must succeed. |
|
|
| `max_tool_calls` | `usize` | Upper bound on tool call count. |
|
|
| `min_responses` | `usize` | Minimum response count. |
|
|
| `tool_results_contain` | `map<string,string>` | Tool result preview must contain substring. |
|
|
|
|
Example (top-level):
|
|
|
|
```json
|
|
{
|
|
"model_name": "recorded-telegram-check",
|
|
"expects": {
|
|
"response_contains": ["Telegram", "connected"],
|
|
"tools_used": ["echo"],
|
|
"all_tools_succeeded": true,
|
|
"tool_results_contain": { "echo": "Checking telegram" },
|
|
"min_responses": 1
|
|
},
|
|
"steps": [ ... ]
|
|
}
|
|
```
|
|
|
|
Example (per-turn):
|
|
|
|
```json
|
|
{
|
|
"model_name": "multi-turn-example",
|
|
"turns": [
|
|
{
|
|
"user_input": "say hello",
|
|
"expects": { "response_contains": ["hello"], "tools_not_used": ["shell"] },
|
|
"steps": [ ... ]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
`run_recorded_trace("filename.json")` in test code loads the fixture, builds a rig, replays, verifies all expects, and shuts down -- turning recorded trace tests into one-liners.
|
|
|
|
## What gets mocked vs. what runs for real
|
|
|
|
| Component | Mocked? | Notes |
|
|
|-----------|---------|-------|
|
|
| LLM responses | Yes | `TraceLlm` replays canned responses from the trace |
|
|
| Tool execution | **No** | Real tools run: file I/O, memory ops, shell commands all execute |
|
|
| Outgoing HTTP (from tools) | **Depends** | Mocked when `http_exchanges` present and `ReplayingHttpInterceptor` is wired; real otherwise |
|
|
| Memory/workspace | **Depends** | Pre-seeded from `memory_snapshot` if present; real workspace operations otherwise |
|
|
| Safety layer | **No** | Sanitizer, validator, policy, leak detector all run |
|
|
| Context/message accumulation | **No** | Messages accumulate naturally across turns |
|
|
| Token counting | Partial | Uses synthetic counts from the trace |
|
|
|
|
## Directory structure
|
|
|
|
```
|
|
llm_traces/
|
|
simple_text.json # Minimal single-turn text response
|
|
file_write_read.json # Write then read a file
|
|
memory_write_read.json # Memory write then text confirmation
|
|
error_path.json # Tool call with missing params, then recovery
|
|
spot/ # Quick smoke tests (1-3 steps each)
|
|
smoke_greeting.json # Simple greeting, no tools
|
|
smoke_math.json # Math question, no tools
|
|
robust_no_tool.json # Factual question, no tools
|
|
tool_echo.json # Single echo tool call + confirmation
|
|
tool_json.json # JSON parse tool call + confirmation
|
|
chain_write_read.json # Write file -> read file -> confirm
|
|
memory_save_recall.json # Memory write -> memory search -> confirm
|
|
robust_correct_tool.json
|
|
coverage/ # Broader tool and feature coverage
|
|
shell_echo.json # Shell command execution
|
|
list_dir.json # Directory listing
|
|
apply_patch_chain.json # File patching workflow
|
|
json_operations.json # JSON tool usage
|
|
injection_in_echo.json # Prompt injection in tool output
|
|
memory_full_cycle.json # Full memory write/search/read cycle
|
|
status_events_tool_chain.json
|
|
advanced/ # Multi-step and edge-case scenarios
|
|
long_tool_chain.json # Many sequential tool calls
|
|
tool_error_recovery.json # Failed tool call -> retry with valid path
|
|
multi_turn_memory.json # Memory across multiple turns
|
|
steering.json # User steering: correct agent mid-conversation
|
|
workspace_search.json # Workspace search workflows
|
|
prompt_injection_resilience.json
|
|
iteration_limit.json # Tests agent loop iteration bounds
|
|
```
|
|
|
|
## Writing a new trace
|
|
|
|
1. **Pick a category**: `spot/` for quick smoke tests, `coverage/` for tool/feature coverage, `advanced/` for complex multi-step scenarios.
|
|
|
|
2. **Name the model**: Use `{category}-{scenario}` (e.g. `spot-tool-echo`, `coverage-shell-echo`).
|
|
|
|
3. **Script the conversation**: Think through the turn sequence. Each LLM call is one step. After a `tool_calls` step, the agent executes the tools and calls the LLM again with the results -- that's the next step.
|
|
|
|
4. **Add request hints** on the first step of each turn (at minimum) to catch wiring issues. Later steps often omit hints since the message content depends on tool output.
|
|
|
|
5. **End each turn with a `text` step** so the agent has a final response to return.
|
|
|
|
Example -- single-turn trace:
|
|
|
|
```json
|
|
{
|
|
"model_name": "spot-tool-echo",
|
|
"turns": [
|
|
{
|
|
"user_input": "Please echo hello for me",
|
|
"steps": [
|
|
{
|
|
"request_hint": { "last_user_message_contains": "echo" },
|
|
"response": {
|
|
"type": "tool_calls",
|
|
"tool_calls": [{ "id": "call_echo_1", "name": "echo", "arguments": { "message": "hello" } }],
|
|
"input_tokens": 60, "output_tokens": 20
|
|
}
|
|
},
|
|
{
|
|
"response": {
|
|
"type": "text",
|
|
"content": "The echo tool returned: hello",
|
|
"input_tokens": 80, "output_tokens": 15
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Example -- multi-turn steering:
|
|
|
|
```json
|
|
{
|
|
"model_name": "advanced-steering",
|
|
"turns": [
|
|
{
|
|
"user_input": "Write hello to /tmp/test.txt",
|
|
"steps": [
|
|
{
|
|
"response": {
|
|
"type": "tool_calls",
|
|
"tool_calls": [{ "id": "c1", "name": "write_file", "arguments": {"path": "/tmp/test.txt", "content": "hello"} }],
|
|
"input_tokens": 60, "output_tokens": 20
|
|
}
|
|
},
|
|
{ "response": { "type": "text", "content": "Done.", "input_tokens": 80, "output_tokens": 5 } }
|
|
]
|
|
},
|
|
{
|
|
"user_input": "Actually, change it to goodbye",
|
|
"steps": [
|
|
{
|
|
"response": {
|
|
"type": "tool_calls",
|
|
"tool_calls": [{ "id": "c2", "name": "write_file", "arguments": {"path": "/tmp/test.txt", "content": "goodbye"} }],
|
|
"input_tokens": 100, "output_tokens": 20
|
|
}
|
|
},
|
|
{ "response": { "type": "text", "content": "Updated.", "input_tokens": 120, "output_tokens": 5 } }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
## TraceLlm API
|
|
|
|
The provider exposes inspection methods for test assertions:
|
|
|
|
```rust
|
|
let llm = TraceLlm::from_file("tests/fixtures/llm_traces/spot/tool_echo.json")?;
|
|
|
|
// ... run agent loop ...
|
|
|
|
assert_eq!(llm.calls(), 2); // Total LLM calls made
|
|
assert_eq!(llm.hint_mismatches(), 0); // Request hint failures
|
|
let reqs = llm.captured_requests(); // Vec<Vec<ChatMessage>> of all requests
|
|
```
|
|
|
|
## TestRig::run_trace()
|
|
|
|
For traces with multiple turns, `run_trace()` drives the entire conversation automatically:
|
|
|
|
```rust
|
|
let trace = LlmTrace::from_file("tests/fixtures/llm_traces/advanced/steering.json")?;
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace.clone())
|
|
.with_tools(tools_with_file_support())
|
|
.build()
|
|
.await;
|
|
|
|
// Sends each turn's user_input, waits for response, accumulates results.
|
|
let all_responses = rig.run_trace(&trace, Duration::from_secs(15)).await;
|
|
|
|
assert!(!all_responses[0].is_empty(), "Turn 1: no response");
|
|
assert!(!all_responses[1].is_empty(), "Turn 2: no response");
|
|
```
|
|
|
|
For legacy flat traces or when you need fine-grained control, use `send_message()` + `wait_for_responses()` directly.
|
|
|
|
## Recording traces from live sessions
|
|
|
|
Instead of hand-writing traces, you can record them from a real LLM session using the `RecordingLlm` wrapper (`src/llm/recording.rs`). This captures everything needed for deterministic replay: user inputs, LLM responses, memory state, HTTP exchanges, and tool results.
|
|
|
|
### Environment variables
|
|
|
|
| Variable | Required | Default | Description |
|
|
|----------|----------|---------|-------------|
|
|
| `IRONCLAW_RECORD_TRACE` | yes | — | Set to any non-empty value to enable recording. |
|
|
| `IRONCLAW_TRACE_OUTPUT` | no | `./trace_{timestamp}.json` | Output file path for the recorded trace. |
|
|
| `IRONCLAW_TRACE_MODEL_NAME` | no | `recorded-{model}` | The `model_name` field in the trace JSON. |
|
|
|
|
### Usage
|
|
|
|
```bash
|
|
# Record a trace (writes to ./trace_20260304T120000.json)
|
|
IRONCLAW_RECORD_TRACE=1 cargo run
|
|
|
|
# Custom output path
|
|
IRONCLAW_RECORD_TRACE=1 IRONCLAW_TRACE_OUTPUT=my_trace.json cargo run
|
|
|
|
# Custom model name
|
|
IRONCLAW_RECORD_TRACE=1 IRONCLAW_TRACE_MODEL_NAME=regression-auth-flow cargo run
|
|
```
|
|
|
|
Run the agent normally, interact with it, then quit. The trace file is written on shutdown.
|
|
|
|
### What gets recorded
|
|
|
|
1. **Memory snapshot** -- all workspace documents are captured before the agent starts, saved in `memory_snapshot`.
|
|
2. **User inputs** -- new `Role::User` messages detected between LLM calls are emitted as `user_input` steps.
|
|
3. **LLM responses** -- every `complete()`/`complete_with_tools()` response is saved as a `text` or `tool_calls` step with `request_hint`.
|
|
4. **Tool results** -- new `Role::Tool` messages between LLM calls are captured in `expected_tool_results` on the next step.
|
|
5. **HTTP exchanges** -- all outgoing HTTP requests from tools are recorded via the `HttpInterceptor` and saved in `http_exchanges`.
|
|
|
|
### Using a recorded trace for replay
|
|
|
|
A recorded trace is a superset of the hand-written format. To use it:
|
|
|
|
1. The replay provider (`TraceLlm`) must skip `user_input` steps -- they are metadata markers, not LLM responses.
|
|
2. If `memory_snapshot` is present, restore workspace documents before running the trace.
|
|
3. If `http_exchanges` is present, wire a `ReplayingHttpInterceptor` into `JobContext.http_interceptor` so tools get pre-recorded HTTP responses instead of making real requests.
|
|
4. If `expected_tool_results` is present on a step, compare actual tool output against recorded values before returning the canned LLM response.
|
|
|
|
### Example recorded trace
|
|
|
|
```json
|
|
{
|
|
"model_name": "recorded-claude-3-5-sonnet",
|
|
"memory_snapshot": [
|
|
{ "path": "context/vision.md", "content": "# Vision\nBuild a secure AI assistant." }
|
|
],
|
|
"http_exchanges": [
|
|
{
|
|
"request": { "method": "GET", "url": "https://api.example.com/time" },
|
|
"response": { "status": 200, "body": "{\"time\": \"14:30\"}" }
|
|
}
|
|
],
|
|
"steps": [
|
|
{
|
|
"response": { "type": "user_input", "content": "What time is it?" }
|
|
},
|
|
{
|
|
"request_hint": { "last_user_message_contains": "What time is it?", "min_message_count": 2 },
|
|
"response": {
|
|
"type": "tool_calls",
|
|
"tool_calls": [
|
|
{ "id": "call_http_1", "name": "http", "arguments": { "url": "https://api.example.com/time" } }
|
|
],
|
|
"input_tokens": 60,
|
|
"output_tokens": 20
|
|
}
|
|
},
|
|
{
|
|
"request_hint": { "min_message_count": 4 },
|
|
"expected_tool_results": [
|
|
{ "tool_call_id": "call_http_1", "name": "http", "content": "{\"status\":200,\"body\":{\"time\":\"14:30\"}}" }
|
|
],
|
|
"response": {
|
|
"type": "text",
|
|
"content": "The current time is 2:30 PM.",
|
|
"input_tokens": 80,
|
|
"output_tokens": 15
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Backward compatibility
|
|
|
|
Recorded traces are backward-compatible with hand-written traces. All new fields (`memory_snapshot`, `http_exchanges`, `expected_tool_results`, `user_input` steps) are optional and default to empty. Existing hand-written traces work unchanged.
|