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,294 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # robot_lab-audit Demo
5
+ #
6
+ # Shows how robot_lab-audit plugs into RobotLab via the Hook system.
7
+ # Five scenarios are demonstrated:
8
+ #
9
+ # 1. Enable — one call wires up global audit logging
10
+ # 2. Network — a two-robot network run and its audit trail
11
+ # 3. Tools — tool calls captured with args, results, and timing
12
+ # 4. Errors — tool errors recorded with error_class and error_message
13
+ # 5. Scoped — attach the hook to one robot instead of globally
14
+ #
15
+ # No API keys required. Robots and tools use deterministic stubs so the
16
+ # demo is fully reproducible and the focus stays on the audit log.
17
+ #
18
+ # Usage:
19
+ # bundle exec ruby examples/01_basic_usage.rb
20
+
21
+ require_relative 'common'
22
+ require 'tmpdir'
23
+ require 'json'
24
+
25
+ # ── Fake LLM response ───────────────────────────────────────────────────────────
26
+
27
+ FakeResponse = Data.define(:content, :tool_calls, :stop_reason) do
28
+ def initialize(content:, tool_calls: nil, stop_reason: 'end_turn') = super
29
+ def reply = content
30
+ end
31
+
32
+ # ── Tools ────────────────────────────────────────────────────────────────────────
33
+
34
+ class WordCountTool < RobotLab::Tool
35
+ description 'Counts words in a text string'
36
+ param :text, type: 'string', desc: 'The text to analyse'
37
+
38
+ def execute(text:)
39
+ { word_count: text.split.size, status: 'ok' }
40
+ end
41
+ end
42
+
43
+
44
+ class SentimentTool < RobotLab::Tool
45
+ description 'Returns a sentiment score for a text string'
46
+ param :text, type: 'string', desc: 'The text to score'
47
+
48
+ def execute(text:)
49
+ score = text.downcase.include?('great') ? 0.9 : 0.5
50
+ { score: score, label: score > 0.7 ? 'positive' : 'neutral' }
51
+ end
52
+ end
53
+
54
+
55
+ class FailingTool < RobotLab::Tool
56
+ description 'Always raises a ToolError (for error logging demo)'
57
+ param :reason, type: 'string', desc: 'Failure reason'
58
+
59
+ def execute(reason:)
60
+ raise RobotLab::ToolError, "Simulated failure: #{reason}"
61
+ end
62
+ end
63
+
64
+ # ── Build deterministic stub robots ─────────────────────────────────────────────
65
+
66
+ def stub_robot(name:, system_prompt:, response:, tools: [])
67
+ robot = RobotLab.build(name: name, system_prompt: system_prompt, local_tools: tools)
68
+ robot.instance_variable_get(:@chat).define_singleton_method(:ask) do |_msg = nil, **_kw, &_b|
69
+ FakeResponse.new(content: response)
70
+ end
71
+ robot
72
+ end
73
+
74
+ # ── Display helpers ──────────────────────────────────────────────────────────────
75
+
76
+ def print_network_runs(runs)
77
+ if runs.empty?
78
+ puts ' (none)'
79
+ return
80
+ end
81
+ runs.each do |run|
82
+ status = run[:error_class] ? "#{ExOut::RED}FAILED#{ExOut::RESET}" : "#{ExOut::GREEN}OK#{ExOut::RESET}"
83
+ puts " #{ExOut::BOLD}run_id#{ExOut::RESET} #{ExOut::GRAY}#{run[:run_id]}#{ExOut::RESET}"
84
+ kv 'network', run[:network_name]
85
+ kv 'status', status
86
+ kv 'started_at', run[:started_at]
87
+ kv 'duration_ms', "#{run[:duration_ms]}ms"
88
+ kv 'input', run[:input]
89
+ kv('result', run[:result]&.then { |r| r.length > 60 ? "#{r[0..57]}..." : r })
90
+ puts
91
+ end
92
+ end
93
+
94
+
95
+ def print_events(events, label: nil)
96
+ if events.empty?
97
+ puts ' (none)'
98
+ return
99
+ end
100
+ puts " #{label}\n\n" if label
101
+ events.each_with_index do |ev, i|
102
+ type_color = ev[:event_type] == 'tool_call' ? ExOut::CYAN : ExOut::GREEN
103
+ error_flag = ev[:error_class] ? " #{ExOut::RED}[#{ev[:error_class]}]#{ExOut::RESET}" : ''
104
+ puts " #{i + 1}. #{type_color}#{ev[:event_type]}#{ExOut::RESET}#{error_flag}"
105
+ kv 'robot', ev[:robot_name]
106
+ kv 'tool', ev[:tool_name] || '—'
107
+ kv 'duration_ms', "#{ev[:duration_ms]}ms"
108
+ kv('input', ev[:input]&.then { |v| v.length > 58 ? "#{v[0..55]}..." : v })
109
+ kv('output', ev[:output]&.then { |v| v.length > 58 ? "#{v[0..55]}..." : v })
110
+ kv 'error', ev[:error_message] if ev[:error_class]
111
+ puts
112
+ end
113
+ end
114
+
115
+ # ════════════════════════════════════════════════════════════════════════════════
116
+ # Demo
117
+ # ════════════════════════════════════════════════════════════════════════════════
118
+
119
+ DB_DIR = Dir.mktmpdir('robot_lab_audit_demo_')
120
+ DB_PATH = File.join(DB_DIR, 'audit.db')
121
+
122
+ banner 'robot_lab-audit Demo'
123
+
124
+ # ── 1. Enable ────────────────────────────────────────────────────────────────────
125
+
126
+ section '1. Enabling the Audit Hook'
127
+
128
+ puts <<~TEXT
129
+ One call creates the SQLite database, assigns it to the hook, and
130
+ registers the hook globally with RobotLab.on(Hook).
131
+
132
+ TEXT
133
+
134
+ puts " #{ExOut::BOLD}RobotLab::Audit.enable(db_path: \"~/.robot_lab/audit.db\")#{ExOut::RESET}"
135
+ RobotLab::Audit.enable(db_path: DB_PATH)
136
+ log = RobotLab::Audit::Hook.event_log
137
+ kv 'DB path', DB_PATH, color: ExOut::GRAY
138
+
139
+ # ── 2. Network run ───────────────────────────────────────────────────────────────
140
+
141
+ section '2. Network Run'
142
+
143
+ puts <<~TEXT
144
+ A two-robot content pipeline: an analyst followed by a summariser.
145
+ The audit hook captures the network run and each robot invocation
146
+ automatically — no changes to robot or network setup required.
147
+
148
+ TEXT
149
+
150
+ analyst = stub_robot(
151
+ name: 'analyst',
152
+ system_prompt: 'You analyse text and produce a structured report.',
153
+ response: 'Analysis complete. Positive sentiment. 11 words.'
154
+ )
155
+
156
+ summariser = stub_robot(
157
+ name: 'summariser',
158
+ system_prompt: 'You distil analyst output into one sentence.',
159
+ response: 'Great writing — positive tone and concise.'
160
+ )
161
+
162
+ network = RobotLab.create_network(name: 'content_pipeline') do
163
+ task :analyse, analyst, depends_on: :none
164
+ task :summarise, summariser, depends_on: :analyse
165
+ end
166
+
167
+ input = 'This is a great piece of writing that deserves careful review.'
168
+ kv 'Input', "\"#{input}\""
169
+ puts
170
+
171
+ network.run(message: input)
172
+
173
+ puts " Network run complete.\n\n"
174
+
175
+ runs = log.network_runs
176
+ print_network_runs(runs)
177
+
178
+ run_id = runs.first&.dig(:run_id)
179
+ if run_id
180
+ puts " Events for run #{ExOut::GRAY}#{run_id}#{ExOut::RESET}:\n\n"
181
+ print_events(log.events_for(run_id))
182
+ end
183
+
184
+ # ── 3. Tool calls ────────────────────────────────────────────────────────────────
185
+
186
+ section '3. Tool Calls'
187
+
188
+ puts <<~TEXT
189
+ Tool invocations are captured in audit_events with event_type "tool_call".
190
+ Each row records the tool name, arguments, result, robot, and timing.
191
+ Tool calls outside a network run have a nil run_id.
192
+
193
+ TEXT
194
+
195
+ word_count = WordCountTool.new
196
+ word_count.instance_variable_set(:@robot, analyst)
197
+
198
+ sentiment = SentimentTool.new
199
+ sentiment.instance_variable_set(:@robot, analyst)
200
+
201
+ word_count.call({ 'text' => input })
202
+ sentiment.call({ 'text' => input })
203
+
204
+ all_tool_events = log.instance_variable_get(:@db)
205
+ .execute("SELECT * FROM audit_events WHERE event_type = 'tool_call'")
206
+ .map do |r|
207
+ keys = %i[id run_id event_type robot_name tool_name input output
208
+ error_class error_message started_at finished_at duration_ms]
209
+ keys.zip(r).to_h
210
+ end
211
+
212
+ print_events(all_tool_events)
213
+
214
+ # ── 4. Error capture ─────────────────────────────────────────────────────────────
215
+
216
+ section '4. Error Capture'
217
+
218
+ puts <<~TEXT
219
+ When a tool raises RobotLab::ToolError, the error is caught, a graceful
220
+ string is returned to the LLM, and the audit log records error_class and
221
+ error_message for post-mortem analysis.
222
+
223
+ TEXT
224
+
225
+ failing = FailingTool.new
226
+ failing.instance_variable_set(:@robot, analyst)
227
+ failing.call({ 'reason' => 'network timeout' })
228
+
229
+ print_events(log.recent_errors, label: 'recent_errors (newest first):')
230
+
231
+ # ── 5. Scoped audit ───────────────────────────────────────────────────────────────
232
+
233
+ section '5. Scoped Audit (Per-Robot)'
234
+
235
+ puts <<~TEXT
236
+ Instead of global registration, attach the hook to specific robots only.
237
+ Clear the global registry first, point Hook.event_log at a fresh database,
238
+ then call robot.on(Hook) on the robots you want to audit. Robots without
239
+ the hook produce no audit entries.
240
+
241
+ TEXT
242
+
243
+ scoped_db_path = File.join(DB_DIR, 'scoped.db')
244
+ scoped_log = RobotLab::Audit::EventLog.new(db_path: scoped_db_path)
245
+
246
+ RobotLab.hooks.clear
247
+ RobotLab::Audit::Hook.event_log = scoped_log
248
+
249
+ audited_robot = stub_robot(
250
+ name: 'audited_robot',
251
+ system_prompt: 'This robot is explicitly audited.',
252
+ response: 'Audited robot response.'
253
+ )
254
+ audited_robot.on(RobotLab::Audit::Hook)
255
+
256
+ silent_robot = stub_robot(
257
+ name: 'silent_robot',
258
+ system_prompt: 'This robot has no audit hook.',
259
+ response: 'Silent robot response — not in any audit log.'
260
+ )
261
+
262
+ audited_robot.run('run with auditing')
263
+ silent_robot.run('run without auditing')
264
+
265
+ scoped_events = scoped_log.instance_variable_get(:@db)
266
+ .execute('SELECT * FROM audit_events')
267
+ .map do |r|
268
+ keys = %i[id run_id event_type robot_name tool_name input output
269
+ error_class error_message started_at finished_at duration_ms]
270
+ keys.zip(r).to_h
271
+ end
272
+
273
+ kv 'Scoped DB', scoped_db_path, color: ExOut::GRAY
274
+ kv 'Audited', 'audited_robot (hook registered)'
275
+ kv 'Silent', 'silent_robot (no hook — absent from log)'
276
+ puts
277
+ print_events(scoped_events)
278
+
279
+ # ── Summary ───────────────────────────────────────────────────────────────────────
280
+
281
+ hr
282
+ puts
283
+ puts "#{ExOut::BOLD}Audit log summary#{ExOut::RESET}"
284
+
285
+ total_events = log.instance_variable_get(:@db)
286
+ .execute('SELECT COUNT(*) FROM audit_events').first.first
287
+
288
+ kv 'Global DB', DB_PATH
289
+ kv 'Network runs', log.network_runs.size.to_s
290
+ kv 'Total events', total_events.to_s
291
+ kv 'Error events', log.recent_errors.size.to_s
292
+ kv 'Scoped DB', scoped_db_path
293
+ kv 'Scoped events', scoped_events.size.to_s
294
+ puts
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ require_relative '../../robot_lab/lib/robot_lab'
6
+ require_relative '../lib/robot_lab/audit'
7
+
8
+ RubyLLM.configure do |c|
9
+ c.logger = Logger.new(File::NULL)
10
+ end
11
+
12
+ RobotLab.configure do |c|
13
+ c.logger = Logger.new(File::NULL)
14
+ end
15
+
16
+ # ── Output helpers ─────────────────────────────────────────────────────────────
17
+
18
+ module ExOut
19
+ WIDTH = 68
20
+ RESET = "\e[0m"
21
+ BOLD = "\e[1m"
22
+ DIM = "\e[2m"
23
+ CYAN = "\e[36m"
24
+ GREEN = "\e[32m"
25
+ RED = "\e[31m"
26
+ GRAY = "\e[90m"
27
+ end
28
+
29
+
30
+ def banner(title)
31
+ puts
32
+ puts "#{ExOut::BOLD}#{'=' * ExOut::WIDTH}#{ExOut::RESET}"
33
+ puts "#{ExOut::BOLD} #{title}#{ExOut::RESET}"
34
+ puts "#{ExOut::BOLD}#{'=' * ExOut::WIDTH}#{ExOut::RESET}"
35
+ puts
36
+ end
37
+
38
+
39
+ def section(title)
40
+ puts
41
+ tail = '─' * [ExOut::WIDTH - title.length - 4, 2].max
42
+ puts "#{ExOut::BOLD}#{ExOut::CYAN}── #{title} #{tail}#{ExOut::RESET}"
43
+ puts
44
+ end
45
+
46
+
47
+ def hr
48
+ puts "#{ExOut::DIM}#{'─' * ExOut::WIDTH}#{ExOut::RESET}"
49
+ end
50
+
51
+
52
+ def kv(label, value, color: ExOut::RESET)
53
+ puts " #{ExOut::BOLD}#{label.ljust(18)}#{ExOut::RESET}#{color}#{value}#{ExOut::RESET}"
54
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sqlite3'
4
+ require 'json'
5
+ require 'fileutils'
6
+
7
+ module RobotLab
8
+ module Audit
9
+ class EventLog
10
+ SCHEMA = <<~SQL
11
+ CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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
+ SQL
39
+
40
+ def initialize(db_path:)
41
+ path = File.expand_path(db_path.to_s)
42
+ FileUtils.mkdir_p(File.dirname(path))
43
+ @db = SQLite3::Database.new(path)
44
+ @db.execute_batch(SCHEMA)
45
+ end
46
+
47
+ def record_network_run(run_id:, network_name:, input:, result:, error_class:,
48
+ error_message:, started_at:, finished_at:, duration_ms:)
49
+ @db.execute(
50
+ "INSERT INTO network_runs
51
+ (run_id, network_name, input, result, error_class, error_message,
52
+ started_at, finished_at, duration_ms)
53
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
54
+ [run_id, network_name, input, result, error_class, error_message,
55
+ started_at, finished_at, duration_ms]
56
+ )
57
+ end
58
+
59
+ def record_event(run_id:, event_type:, robot_name:, tool_name:, input:, output:,
60
+ error_class:, error_message:, started_at:, finished_at:, duration_ms:)
61
+ @db.execute(
62
+ "INSERT INTO audit_events
63
+ (run_id, event_type, robot_name, tool_name, input, output,
64
+ error_class, error_message, started_at, finished_at, duration_ms)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
66
+ [run_id, event_type, robot_name, tool_name, input, output,
67
+ error_class, error_message, started_at, finished_at, duration_ms]
68
+ )
69
+ end
70
+
71
+ def network_runs(limit: 100)
72
+ rows = @db.execute('SELECT * FROM network_runs ORDER BY started_at DESC LIMIT ?', [limit])
73
+ rows.map { |r| row_to_hash(:network_runs, r) }
74
+ end
75
+
76
+ def events_for(run_id)
77
+ rows = @db.execute('SELECT * FROM audit_events WHERE run_id = ? ORDER BY started_at', [run_id])
78
+ rows.map { |r| row_to_hash(:audit_events, r) }
79
+ end
80
+
81
+ def recent_errors(limit: 50)
82
+ rows = @db.execute(
83
+ 'SELECT * FROM audit_events WHERE error_class IS NOT NULL ORDER BY started_at DESC LIMIT ?',
84
+ [limit]
85
+ )
86
+ rows.map { |r| row_to_hash(:audit_events, r) }
87
+ end
88
+
89
+ NETWORK_RUN_COLUMNS = %i[id run_id network_name input result error_class
90
+ error_message started_at finished_at duration_ms].freeze
91
+ AUDIT_EVENT_COLUMNS = %i[id run_id event_type robot_name tool_name input output
92
+ error_class error_message started_at finished_at duration_ms].freeze
93
+
94
+ private
95
+
96
+ def row_to_hash(table, row)
97
+ cols = table == :network_runs ? NETWORK_RUN_COLUMNS : AUDIT_EVENT_COLUMNS
98
+ cols.zip(row).to_h
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'json'
5
+
6
+ module RobotLab
7
+ module Audit
8
+ class Hook < RobotLab::Hook
9
+ self.namespace = :audit
10
+
11
+ class << self
12
+ attr_accessor :event_log
13
+
14
+ def before_network_run(_ctx)
15
+ return unless event_log
16
+
17
+ set_run_id(SecureRandom.uuid)
18
+ set_network_started_at(Time.now.utc)
19
+ end
20
+
21
+ def after_network_run(ctx)
22
+ return unless event_log && current_run_id
23
+
24
+ event_log.record_network_run(
25
+ run_id: current_run_id,
26
+ network_name: ctx.network&.name,
27
+ input: extract_network_input(ctx.context),
28
+ result: extract_network_result(ctx.result),
29
+ **error_fields(ctx.error),
30
+ **timing(current_network_started_at)
31
+ )
32
+ ensure
33
+ clear_run_id
34
+ clear_network_started_at
35
+ end
36
+
37
+ def before_run(ctx)
38
+ ctx.local.started_at = Time.now.utc
39
+ end
40
+
41
+ def after_run(ctx)
42
+ return unless event_log
43
+
44
+ record_audit_event(
45
+ 'robot_run', ctx, ctx.local.started_at,
46
+ nil, safe_json(ctx.request), safe_json(ctx.response), ctx.error
47
+ )
48
+ end
49
+
50
+ def before_tool_call(ctx)
51
+ ctx.local.started_at = Time.now.utc
52
+ end
53
+
54
+ def after_tool_call(ctx)
55
+ return unless event_log
56
+
57
+ record_audit_event(
58
+ 'tool_call', ctx, ctx.local.started_at,
59
+ ctx.tool_name, safe_json(ctx.tool_args), safe_json(ctx.tool_result), ctx.tool_error
60
+ )
61
+ end
62
+
63
+ private
64
+
65
+ def record_audit_event(event_type, ctx, started, tool_name, input, output, error)
66
+ event_log.record_event(
67
+ run_id: current_run_id,
68
+ event_type: event_type,
69
+ robot_name: ctx.robot&.name,
70
+ tool_name: tool_name,
71
+ input: input,
72
+ output: output,
73
+ **error_fields(error),
74
+ **timing(started)
75
+ )
76
+ end
77
+
78
+ def timing(started)
79
+ finished = Time.now.utc
80
+ {
81
+ started_at: started&.iso8601(3),
82
+ finished_at: finished.iso8601(3),
83
+ duration_ms: started ? ((finished - started) * 1000).round(2) : nil
84
+ }
85
+ end
86
+
87
+ def error_fields(err)
88
+ {
89
+ error_class: err&.class&.name,
90
+ error_message: err&.message
91
+ }
92
+ end
93
+
94
+ def set_run_id(id)
95
+ Thread.current[:robot_lab_audit_run_id] = id
96
+ end
97
+
98
+ def current_run_id
99
+ Thread.current[:robot_lab_audit_run_id]
100
+ end
101
+
102
+ def clear_run_id
103
+ Thread.current[:robot_lab_audit_run_id] = nil
104
+ end
105
+
106
+ def set_network_started_at(time)
107
+ Thread.current[:robot_lab_audit_network_started_at] = time
108
+ end
109
+
110
+ def current_network_started_at
111
+ Thread.current[:robot_lab_audit_network_started_at]
112
+ end
113
+
114
+ def clear_network_started_at
115
+ Thread.current[:robot_lab_audit_network_started_at] = nil
116
+ end
117
+
118
+ def safe_json(value)
119
+ return nil if value.nil?
120
+
121
+ JSON.generate(value)
122
+ rescue JSON::GeneratorError, TypeError
123
+ value.inspect
124
+ end
125
+
126
+ def extract_network_input(context)
127
+ return nil if context.nil?
128
+
129
+ msg = context[:message] || context['message']
130
+ msg ? JSON.generate(msg) : safe_json(context)
131
+ rescue StandardError
132
+ context.inspect
133
+ end
134
+
135
+ def extract_network_result(result)
136
+ return nil if result.nil?
137
+
138
+ reply = result.value&.reply if result.respond_to?(:value)
139
+ reply ? JSON.generate(reply) : safe_json(result)
140
+ rescue StandardError
141
+ result.inspect
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Audit
5
+ VERSION = '0.2.6'
6
+ end
7
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'audit/version'
4
+ require_relative 'audit/event_log'
5
+
6
+ module RobotLab
7
+ module Audit
8
+ class Error < StandardError; end
9
+
10
+ # Configure the audit hook with a SQLite database path and register it globally.
11
+ #
12
+ # @param db_path [String] path to the SQLite database file (created if absent)
13
+ #
14
+ # @example
15
+ # RobotLab::Audit.enable(db_path: "~/.robot_lab/audit.db")
16
+ def self.enable(db_path:)
17
+ unless defined?(RobotLab::Audit::Hook)
18
+ raise Error, 'robot_lab must be loaded before calling RobotLab::Audit.enable'
19
+ end
20
+
21
+ Hook.event_log = EventLog.new(db_path: db_path)
22
+ RobotLab.on(Hook)
23
+ end
24
+ end
25
+ end
26
+
27
+ require_relative 'audit/hook' if defined?(RobotLab::Hook)
28
+
29
+ if defined?(RobotLab) && RobotLab.respond_to?(:register_extension)
30
+ RobotLab.register_extension(:audit, RobotLab::Audit)
31
+ end