robot_lab-audit 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ # Getting Started
2
+
3
+ ## Prerequisites
4
+
5
+ - Ruby 3.2+ (per the gemspec's `required_ruby_version`)
6
+ - `robot_lab` — `robot_lab/audit` requires `RobotLab::Hook` to already be defined, so `robot_lab` must be `require`d first.
7
+ - SQLite — the gem depends on the `sqlite3` gem directly; no separate database server to run.
8
+
9
+ ## Installation
10
+
11
+ Add to your `Gemfile`:
12
+
13
+ ```ruby
14
+ gem "robot_lab"
15
+ gem "robot_lab-audit"
16
+ ```
17
+
18
+ Then:
19
+
20
+ ```sh
21
+ bundle install
22
+ ```
23
+
24
+ Or install directly:
25
+
26
+ ```sh
27
+ gem install robot_lab-audit
28
+ ```
29
+
30
+ ## Enabling Globally
31
+
32
+ ```ruby
33
+ require "robot_lab"
34
+ require "robot_lab/audit"
35
+
36
+ RobotLab::Audit.enable(db_path: "~/.robot_lab/audit.db")
37
+
38
+ network = RobotLab.create_network(...)
39
+ network.run(message: "do something")
40
+ ```
41
+
42
+ `RobotLab::Audit.enable(db_path:)`:
43
+
44
+ 1. Instantiates `RobotLab::Audit::EventLog`, which opens (or creates) the SQLite
45
+ file at `db_path`, creating any missing parent directories along the way.
46
+ 2. Assigns the log to `RobotLab::Audit::Hook.event_log`.
47
+ 3. Registers the hook globally via `RobotLab.on(Hook)`.
48
+
49
+ Calling `enable` again with a new `db_path` simply repoints the hook at a new
50
+ `EventLog` — it is safe to call once at boot.
51
+
52
+ If `robot_lab` has not been loaded yet, `enable` raises
53
+ `RobotLab::Audit::Error` ("robot_lab must be loaded before calling
54
+ RobotLab::Audit.enable").
55
+
56
+ ## What Gets Logged
57
+
58
+ | Table | One row per | Key columns |
59
+ |-------|--------------|-------------|
60
+ | `network_runs` | `Network#run` call | `run_id`, `network_name`, `input`, `result`, `error_class`, `error_message`, `started_at`, `finished_at`, `duration_ms` |
61
+ | `audit_events` | `Robot#run` or `Tool#call` | `run_id`, `event_type`, `robot_name`, `tool_name`, `input`, `output`, `error_class`, `error_message`, `started_at`, `finished_at`, `duration_ms` |
62
+
63
+ `event_type` is `"robot_run"` or `"tool_call"`. All `input`/`output`/`result`
64
+ values are stored as JSON text. Timestamps are UTC, ISO 8601, with millisecond
65
+ precision.
66
+
67
+ Every event produced while a `Network#run` is in flight shares that run's
68
+ `run_id`, so you can pull the entire trace for one execution with
69
+ `events_for(run_id)` (see [Querying the Log](querying.md)).
70
+
71
+ Standalone `Robot#run` calls made outside of a network are recorded too —
72
+ their `run_id` column is `nil`.
73
+
74
+ ## Minimal Example
75
+
76
+ ```ruby
77
+ require "robot_lab"
78
+ require "robot_lab/audit"
79
+ require "tmpdir"
80
+
81
+ RobotLab::Audit.enable(db_path: File.join(Dir.mktmpdir, "audit.db"))
82
+
83
+ robot = RobotLab.build(name: "greeter", system_prompt: "You are a friendly greeter.")
84
+ robot.run("Say hello")
85
+
86
+ log = RobotLab::Audit::Hook.event_log
87
+ log.events_for(nil).each do |event|
88
+ puts "#{event[:event_type]} #{event[:robot_name]} #{event[:duration_ms]}ms"
89
+ end
90
+ ```
91
+
92
+ The gem ships a fuller, self-contained demo with no API keys required — it
93
+ uses deterministic stubbed robots and tools so the audit trail is
94
+ reproducible:
95
+
96
+ ```sh
97
+ bundle exec ruby examples/01_basic_usage.rb
98
+ ```
99
+
100
+ It walks through five scenarios: enabling the hook, a two-robot network run,
101
+ tool call capture, error capture, and scoping the hook to a single robot.
102
+
103
+ ## Scoping to a Robot or Network
104
+
105
+ `RobotLab::Audit.enable` registers the hook globally — every robot and
106
+ network in the process gets logged. To audit only specific objects, skip
107
+ `enable` and register the hook directly instead:
108
+
109
+ ```ruby
110
+ RobotLab::Audit::Hook.event_log = RobotLab::Audit::EventLog.new(db_path: "audit.db")
111
+
112
+ robot.on(RobotLab::Audit::Hook)
113
+ # or
114
+ network.on(RobotLab::Audit::Hook)
115
+ ```
116
+
117
+ Robots and networks that never call `.on(RobotLab::Audit::Hook)` produce no
118
+ audit entries at all — the hook simply never fires for them.
119
+
120
+ If you had previously called `RobotLab::Audit.enable`, clear the global hook
121
+ registry first (`RobotLab.hooks.clear`) so the same events aren't logged
122
+ twice — once globally and once for the specific object.
123
+
124
+ ## Key Constraints
125
+
126
+ - `robot_lab` must be `require`d before `robot_lab/audit` — see Prerequisites above.
127
+ - `EventLog` is not thread-safe by itself; SQLite handles concurrent writes via its own file locking.
128
+ - Run IDs are correlated via `Thread.current` — they are **not** propagated across Fibers or Ractors. Work dispatched off-thread (e.g. via `robot_lab-ractor`) will not share a `run_id` with its parent network run.
129
+ - `safe_json` (used internally to serialise `input`/`output`) silently falls back to `#inspect` for values that can't be JSON-encoded — don't assume every `input`/`output` column is valid JSON when a tool passes an unusual argument type.
@@ -0,0 +1,193 @@
1
+ # How It Works
2
+
3
+ ## Schema
4
+
5
+ `EventLog#initialize` opens the SQLite file at `db_path` (expanding `~` and
6
+ creating any missing parent directories via `FileUtils.mkdir_p`), then runs a
7
+ `CREATE TABLE IF NOT EXISTS` schema batch — safe to call every time the
8
+ process boots.
9
+
10
+ ```sql
11
+ CREATE TABLE network_runs (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
13
+ run_id TEXT NOT NULL UNIQUE,
14
+ network_name TEXT,
15
+ input TEXT,
16
+ result TEXT,
17
+ error_class TEXT,
18
+ error_message TEXT,
19
+ started_at TEXT NOT NULL,
20
+ finished_at TEXT,
21
+ duration_ms REAL
22
+ );
23
+
24
+ CREATE TABLE audit_events (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ run_id TEXT,
27
+ event_type TEXT NOT NULL,
28
+ robot_name TEXT,
29
+ tool_name TEXT,
30
+ input TEXT,
31
+ output TEXT,
32
+ error_class TEXT,
33
+ error_message TEXT,
34
+ started_at TEXT NOT NULL,
35
+ finished_at TEXT,
36
+ duration_ms REAL
37
+ );
38
+ ```
39
+
40
+ Two tables, no foreign key constraint between them — `audit_events.run_id`
41
+ is simply a shared correlation string, nullable for events that happen
42
+ outside of any network run.
43
+
44
+ | Column | Present on | Notes |
45
+ |--------|-----------|-------|
46
+ | `run_id` | both | `NOT NULL UNIQUE` on `network_runs` (one row per run); nullable, non-unique on `audit_events` (many rows share a run) |
47
+ | `event_type` | `audit_events` only | `"robot_run"` or `"tool_call"` |
48
+ | `robot_name` / `tool_name` | `audit_events` only | `tool_name` is `nil` for `"robot_run"` events |
49
+ | `network_name` | `network_runs` only | taken from `ctx.network&.name` |
50
+ | `input` / `output` / `result` | both | JSON-encoded text (see [JSON Serialisation](#json-serialisation-safe_json) below) |
51
+ | `error_class` / `error_message` | both | populated from the exception object when the call raised, else `nil` |
52
+ | `started_at` / `finished_at` | both | UTC, ISO 8601, millisecond precision (`Time#iso8601(3)`) |
53
+ | `duration_ms` | both | `(finished - started) * 1000`, rounded to 2 decimal places |
54
+
55
+ ## Hook Callback Mapping
56
+
57
+ `RobotLab::Audit::Hook` is a subclass of `RobotLab::Hook` with
58
+ `self.namespace = :audit`. It implements three before/after callback pairs
59
+ from RobotLab's [Hook system](https://github.com/MadBomber/robot_lab/blob/main/docs/guides/hooks.md):
60
+
61
+ | Hook family | Callbacks | Writes |
62
+ |-------------|-----------|--------|
63
+ | `:network_run` | `before_network_run`, `after_network_run` | one `network_runs` row |
64
+ | `:run` | `before_run`, `after_run` | one `audit_events` row, `event_type: "robot_run"` |
65
+ | `:tool_call` | `before_tool_call`, `after_tool_call` | one `audit_events` row, `event_type: "tool_call"` |
66
+
67
+ `before_network_run`, `after_network_run`, `after_run`, and `after_tool_call`
68
+ all `return unless event_log` (or `event_log && current_run_id`) as their
69
+ first line — they are true no-ops when `Hook.event_log` is `nil`. This is what
70
+ makes it safe to `require "robot_lab/audit"` without calling `enable`, and
71
+ what makes per-robot/per-network scoping work cleanly.
72
+
73
+ `before_run` and `before_tool_call` are a partial exception: they run
74
+ unconditionally, stashing `Time.now.utc` on `ctx.local.started_at` even when
75
+ `event_log` is `nil`. That write has no observable effect on its own — it's
76
+ local, namespace-scoped state (see below) that only gets read if the matching
77
+ `after_*` callback goes on to record a row, and that callback still checks
78
+ `event_log` before touching the database. So the *net* behavior is the same
79
+ (nothing is written when auditing is off), it's just the `after_*` half of
80
+ each pair that carries the guard, not the `before_*` half.
81
+
82
+ ### Network run lifecycle
83
+
84
+ ```
85
+ before_network_run(ctx)
86
+ → generates a new run_id (SecureRandom.uuid)
87
+ → stores run_id and started_at in Thread.current
88
+
89
+ ... network executes: robots run, tools are called ...
90
+
91
+ after_network_run(ctx)
92
+ → writes the network_runs row using the thread-local run_id/started_at
93
+ → clears both thread-locals in an `ensure` block
94
+ ```
95
+
96
+ `after_network_run` is a no-op if there is no `current_run_id` — this
97
+ guards against `after_network_run` firing without a matching
98
+ `before_network_run` (for example, if the hook was registered mid-flight).
99
+
100
+ ### Robot run and tool call lifecycle
101
+
102
+ `before_run` / `before_tool_call` don't touch `Thread.current` at all —
103
+ they stash `started_at` on `ctx.local`, which is namespace-scoped state that
104
+ RobotLab's `HookContext` provides per-callback-pair (see
105
+ `ctx.with_namespace(:audit)` in the test suite for how this is activated).
106
+
107
+ `after_run` / `after_tool_call` then read `ctx.local.started_at`, look up the
108
+ thread-local `run_id` set by an enclosing `before_network_run` (or `nil` if
109
+ there is none), and write one `audit_events` row via the shared private
110
+ `record_audit_event` helper.
111
+
112
+ ### Run ID correlation
113
+
114
+ All events produced while a given `Network#run` is executing — the network
115
+ run itself, every robot invocation, every tool call — share the same
116
+ `run_id`, because that ID lives in `Thread.current` for the duration of the
117
+ network run and every robot/tool callback reads it. This is what makes
118
+ `EventLog#events_for(run_id)` a full execution trace rather than a fragment.
119
+
120
+ Standalone `Robot#run` or `Tool#call` invocations made outside of any
121
+ network run see `current_run_id` as `nil` — they still get logged, just
122
+ without a correlating run.
123
+
124
+ ## Thread-Local State
125
+
126
+ Two `Thread.current` keys carry state between the `before_*` and `after_*`
127
+ halves of the network-run lifecycle:
128
+
129
+ | Key | Set in | Cleared in | Purpose |
130
+ |-----|--------|-----------|---------|
131
+ | `:robot_lab_audit_run_id` | `before_network_run` | `after_network_run` (`ensure`) | correlates all events within one network run |
132
+ | `:robot_lab_audit_network_started_at` | `before_network_run` | `after_network_run` (`ensure`) | computes the network run's `duration_ms` |
133
+
134
+ Because this is `Thread.current` rather than `ctx.local`, run correlation
135
+ does **not** cross Fiber or Ractor boundaries — parallel work dispatched via
136
+ `robot_lab-ractor`, for instance, will not inherit the parent network run's
137
+ `run_id`. `before_run`/`before_tool_call` state, by contrast, uses `ctx.local`
138
+ and is scoped per-callback-pair regardless of thread.
139
+
140
+ ## JSON Serialisation (`safe_json`)
141
+
142
+ `input`/`output` values (tool arguments, robot request/response payloads) are
143
+ serialised with a private `safe_json` helper:
144
+
145
+ ```ruby
146
+ def safe_json(value)
147
+ return nil if value.nil?
148
+
149
+ JSON.generate(value)
150
+ rescue JSON::GeneratorError, TypeError
151
+ value.inspect
152
+ end
153
+ ```
154
+
155
+ If a value can't be JSON-encoded (circular references, unsupported types),
156
+ the column falls back to Ruby's `#inspect` string instead of raising. Don't
157
+ assume every `input`/`output` value is parseable JSON — check for `nil`
158
+ first if you write tooling against the column, and be prepared for an
159
+ `inspect`-style string on the rare unencodable payload.
160
+
161
+ `network_runs.input` and `.result` use dedicated extractors
162
+ (`extract_network_input`, `extract_network_result`) that look for a
163
+ `:message`/`'message'` key on the network context, or — for the result — a
164
+ `.reply` on `result.value` when the result object responds to `:value` (the
165
+ shape `simple_flow`'s result wrapper returns), falling back to `safe_json`
166
+ and finally to `#inspect` if anything raises.
167
+
168
+ ## Error Capture
169
+
170
+ When a network run, robot run, or tool call raises, the hook's
171
+ `error_fields` helper extracts `error_class` (the exception's class name as
172
+ a string) and `error_message` (the exception's message) from whatever
173
+ `ctx.error` / `ctx.tool_error` object RobotLab's Hook system populates. Both
174
+ columns are `nil` on success. `EventLog#recent_errors` filters
175
+ `audit_events` down to rows where `error_class IS NOT NULL`.
176
+
177
+ ## RobotLab Extension Registration
178
+
179
+ `lib/robot_lab/audit.rb` ends with:
180
+
181
+ ```ruby
182
+ require_relative 'audit/hook' if defined?(RobotLab::Hook)
183
+
184
+ if defined?(RobotLab) && RobotLab.respond_to?(:register_extension)
185
+ RobotLab.register_extension(:audit, RobotLab::Audit)
186
+ end
187
+ ```
188
+
189
+ The `Hook` subclass is only loaded when `RobotLab::Hook` is already defined,
190
+ and extension registration only runs when `RobotLab.register_extension`
191
+ exists — both guards make `require "robot_lab/audit"` safe even in contexts
192
+ where `robot_lab` hasn't been (fully) loaded, though calling `enable` in that
193
+ state still raises (see [Getting Started](getting_started.md)).
data/docs/index.md ADDED
@@ -0,0 +1,44 @@
1
+ # robot_lab-audit
2
+
3
+ A SQLite-backed execution audit log for the [RobotLab](https://github.com/MadBomber/robot_lab) LLM agent framework.
4
+
5
+ `RobotLab::Audit` plugs into RobotLab's [Hook system](https://github.com/MadBomber/robot_lab/blob/main/docs/guides/hooks.md) and writes one row per network run, robot invocation, and tool call to a local SQLite database — timestamps, JSON input/output, errors, and duration in milliseconds. Zero changes required to your robots, networks, or tools.
6
+
7
+ ```ruby
8
+ require "robot_lab"
9
+ require "robot_lab/audit"
10
+
11
+ RobotLab::Audit.enable(db_path: "~/.robot_lab/audit.db")
12
+
13
+ # All subsequent network and robot runs are recorded automatically.
14
+ network = RobotLab.create_network(...)
15
+ network.run(message: "do something")
16
+ ```
17
+
18
+ Every event from a single `Network#run` shares the same `run_id`, so the full
19
+ execution trace — which robots ran, which tools they called, what each one
20
+ returned, and how long it took — can be reconstructed after the fact for
21
+ post-mortem analysis or debugging.
22
+
23
+ ## Navigation
24
+
25
+ - [Getting Started](getting_started.md) — installation, enabling the hook, a minimal example, scoping to one robot or network
26
+ - [How It Works](how_it_works.md) — the SQLite schema, how each Hook callback maps to a row, run_id correlation, thread-local state, JSON serialisation
27
+ - [Querying the Log](querying.md) — the `EventLog` query API and raw SQL examples for post-mortem analysis
28
+
29
+ ## At a Glance
30
+
31
+ | | |
32
+ |---|---|
33
+ | **Storage** | SQLite (via the `sqlite3` gem) — one file, no server |
34
+ | **Tables** | `network_runs`, `audit_events` |
35
+ | **Wiring** | `RobotLab::Hook` subclass (`RobotLab::Audit::Hook`), namespace `:audit` |
36
+ | **Enable globally** | `RobotLab::Audit.enable(db_path:)` |
37
+ | **Enable per robot/network** | `robot.on(RobotLab::Audit::Hook)` / `network.on(RobotLab::Audit::Hook)` |
38
+ | **Query API** | `EventLog#network_runs`, `#events_for(run_id)`, `#recent_errors` |
39
+
40
+ ## Links
41
+
42
+ - [RobotLab Core](https://github.com/MadBomber/robot_lab)
43
+ - [RubyGems](https://rubygems.org/gems/robot_lab-audit)
44
+ - [GitHub](https://github.com/MadBomber/robot_lab-audit)
data/docs/querying.md ADDED
@@ -0,0 +1,126 @@
1
+ # Querying the Log
2
+
3
+ `RobotLab::Audit::EventLog` is the only public query surface. Get a handle
4
+ to the active log via the hook (when using global `enable`):
5
+
6
+ ```ruby
7
+ log = RobotLab::Audit::Hook.event_log
8
+ ```
9
+
10
+ or hold onto the `EventLog` instance yourself if you constructed it directly
11
+ for scoped auditing (see [Getting Started — Scoping](getting_started.md#scoping-to-a-robot-or-network)).
12
+
13
+ Each query method returns an `Array` of `Hash` objects keyed by column name
14
+ as symbols (e.g. `row[:run_id]`, `row[:duration_ms]`).
15
+
16
+ ## `network_runs(limit: 100)`
17
+
18
+ Recent network runs, newest first (ordered by `started_at DESC`).
19
+
20
+ ```ruby
21
+ log.network_runs(limit: 20).each do |run|
22
+ status = run[:error_class] ? "FAILED (#{run[:error_class]})" : "OK"
23
+ puts "#{run[:run_id]} #{run[:network_name]} #{status} #{run[:duration_ms]}ms"
24
+ end
25
+ ```
26
+
27
+ Columns returned: `id`, `run_id`, `network_name`, `input`, `result`,
28
+ `error_class`, `error_message`, `started_at`, `finished_at`, `duration_ms`.
29
+
30
+ ## `events_for(run_id)`
31
+
32
+ Every `audit_events` row for one network run, ordered chronologically
33
+ (`ORDER BY started_at`) — the full execution trace for that run: every robot
34
+ invocation and every tool call, interleaved in the order they actually
35
+ happened.
36
+
37
+ ```ruby
38
+ run_id = log.network_runs.first[:run_id]
39
+
40
+ log.events_for(run_id).each do |event|
41
+ label = event[:tool_name] || event[:robot_name]
42
+ puts "#{event[:event_type].ljust(10)} #{label.ljust(20)} #{event[:duration_ms]}ms"
43
+ end
44
+ ```
45
+
46
+ Columns returned: `id`, `run_id`, `event_type`, `robot_name`, `tool_name`,
47
+ `input`, `output`, `error_class`, `error_message`, `started_at`,
48
+ `finished_at`, `duration_ms`.
49
+
50
+ Standalone `Robot#run`/`Tool#call` invocations made outside any network run
51
+ are recorded with `run_id: nil` — pass `nil` to `events_for` to retrieve
52
+ them:
53
+
54
+ ```ruby
55
+ log.events_for(nil)
56
+ ```
57
+
58
+ ## `recent_errors(limit: 50)`
59
+
60
+ `audit_events` rows where `error_class IS NOT NULL`, newest first — every
61
+ failed robot run or tool call across all network runs (and standalone
62
+ calls), regardless of `run_id`.
63
+
64
+ ```ruby
65
+ log.recent_errors.each do |event|
66
+ puts "#{event[:started_at]} #{event[:robot_name]}/#{event[:tool_name]} " \
67
+ "#{event[:error_class]}: #{event[:error_message]}"
68
+ end
69
+ ```
70
+
71
+ This is the fastest way to answer "what broke recently?" without knowing a
72
+ specific `run_id` up front.
73
+
74
+ ## Querying with Raw SQL
75
+
76
+ `EventLog` doesn't try to be a full reporting layer — for anything beyond
77
+ the three methods above, query the SQLite file directly. It's a plain
78
+ single-file database; any SQL client works.
79
+
80
+ ```bash
81
+ sqlite3 ~/.robot_lab/audit.db \
82
+ "SELECT event_type, robot_name, tool_name, duration_ms, error_class
83
+ FROM audit_events
84
+ WHERE run_id = 'some-run-id'
85
+ ORDER BY started_at;"
86
+ ```
87
+
88
+ Useful ad hoc queries:
89
+
90
+ ```sql
91
+ -- Slowest tool calls across all runs
92
+ SELECT tool_name, robot_name, duration_ms, started_at
93
+ FROM audit_events
94
+ WHERE event_type = 'tool_call'
95
+ ORDER BY duration_ms DESC
96
+ LIMIT 20;
97
+
98
+ -- Error rate by robot
99
+ SELECT robot_name,
100
+ COUNT(*) AS total,
101
+ SUM(CASE WHEN error_class IS NOT NULL THEN 1 ELSE 0 END) AS errors
102
+ FROM audit_events
103
+ WHERE event_type = 'robot_run'
104
+ GROUP BY robot_name;
105
+
106
+ -- Network runs that failed
107
+ SELECT run_id, network_name, error_class, error_message, started_at
108
+ FROM network_runs
109
+ WHERE error_class IS NOT NULL
110
+ ORDER BY started_at DESC;
111
+ ```
112
+
113
+ Because `input`/`output`/`result` are stored as JSON text, SQLite's
114
+ [JSON1 functions](https://www.sqlite.org/json1.html) (`json_extract`, etc.)
115
+ can reach into them directly:
116
+
117
+ ```sql
118
+ SELECT json_extract(input, '$.text') AS text_arg, duration_ms
119
+ FROM audit_events
120
+ WHERE tool_name = 'word_count_tool';
121
+ ```
122
+
123
+ Remember that a small fraction of `input`/`output` values may be a
124
+ `#inspect` fallback string rather than valid JSON (see
125
+ [How It Works — JSON Serialisation](how_it_works.md#json-serialisation-safe_json)) — `json_extract` will
126
+ simply return `NULL` for those rows rather than raising.