active_harness 0.3.7 → 0.3.9

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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/lib/active_harness/agent/goal_check.rb +26 -0
  3. data/lib/active_harness/agent/state.rb +90 -0
  4. data/lib/active_harness/agent.rb +734 -0
  5. data/lib/active_harness/core/errors.rb +11 -0
  6. data/lib/active_harness/http/client.rb +58 -11
  7. data/lib/active_harness/http/streaming_client.rb +33 -1
  8. data/lib/active_harness/pipeline/hooks.rb +1 -1
  9. data/lib/active_harness/providers/anthropic.rb +57 -8
  10. data/lib/active_harness/providers/base.rb +6 -0
  11. data/lib/active_harness/providers/deepseek.rb +7 -4
  12. data/lib/active_harness/providers/gpustack.rb +7 -4
  13. data/lib/active_harness/providers/groq.rb +7 -4
  14. data/lib/active_harness/providers/hetzner.rb +7 -4
  15. data/lib/active_harness/providers/mistral.rb +7 -4
  16. data/lib/active_harness/providers/ollama.rb +7 -4
  17. data/lib/active_harness/providers/ollama_cloud.rb +7 -4
  18. data/lib/active_harness/providers/openai.rb +7 -4
  19. data/lib/active_harness/providers/openrouter.rb +7 -4
  20. data/lib/active_harness/providers/tool_calling.rb +95 -0
  21. data/lib/active_harness/providers/xai.rb +7 -4
  22. data/lib/active_harness/railtie.rb +1 -1
  23. data/lib/active_harness/request/hooks.rb +1 -1
  24. data/lib/active_harness/request/providers.rb +15 -0
  25. data/lib/active_harness/request.rb +39 -7
  26. data/lib/active_harness/result.rb +4 -0
  27. data/lib/active_harness/tool/path_policy.rb +28 -0
  28. data/lib/active_harness/tool.rb +348 -0
  29. data/lib/active_harness/tribunal/hooks.rb +1 -1
  30. data/lib/active_harness.rb +4 -1
  31. data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
  32. data/lib/generators/active_harness/agent/templates/agent.rb.tt +14 -0
  33. data/lib/generators/active_harness/goal/goal_generator.rb +16 -0
  34. data/lib/generators/active_harness/goal/templates/goal.rb.tt +14 -0
  35. data/lib/generators/active_harness/install/install_generator.rb +30 -25
  36. data/lib/generators/active_harness/install/templates/agents/files_agent.rb +39 -0
  37. data/lib/generators/active_harness/install/templates/concerns/agent_tracing.rb +36 -0
  38. data/lib/generators/active_harness/install/templates/concerns/pipeline_tracing.rb +28 -0
  39. data/lib/generators/active_harness/install/templates/concerns/request_tracing.rb +44 -0
  40. data/lib/generators/active_harness/install/templates/concerns/tool_tracing.rb +21 -0
  41. data/lib/generators/active_harness/install/templates/concerns/tracing_log.rb +18 -0
  42. data/lib/generators/active_harness/install/templates/concerns/tribunal_tracing.rb +28 -0
  43. data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +23 -2
  44. data/lib/generators/active_harness/install/templates/goals/answer_present_goal.rb +22 -0
  45. data/lib/generators/active_harness/install/templates/pipelines/support_pipeline.rb +8 -18
  46. data/lib/generators/active_harness/install/templates/prompts/files_prompt.rb +9 -0
  47. data/lib/generators/active_harness/install/templates/prompts/support_prompt.rb +3 -0
  48. data/lib/generators/active_harness/install/templates/requests/files_request.rb +18 -0
  49. data/lib/generators/active_harness/install/templates/requests/support_guard_request.rb +12 -0
  50. data/lib/generators/active_harness/install/templates/requests/support_request.rb +6 -0
  51. data/lib/generators/active_harness/install/templates/tools/ls_tool.rb +40 -0
  52. data/lib/generators/active_harness/install/templates/tools/read_file_tool.rb +43 -0
  53. data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +7 -0
  54. data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +5 -0
  55. data/lib/generators/active_harness/request/templates/request.rb.tt +2 -0
  56. data/lib/generators/active_harness/tool/templates/tool.rb.tt +22 -0
  57. data/lib/generators/active_harness/tool/tool_generator.rb +16 -0
  58. data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +3 -0
  59. metadata +31 -7
@@ -0,0 +1,734 @@
1
+ require "securerandom"
2
+ require "time"
3
+ require_relative "agent/state"
4
+ require_relative "agent/goal_check"
5
+
6
+ module ActiveHarness
7
+ # A loop: ask the LLM -> run the tools it asked for -> feed the results back
8
+ # -> repeat until an answer, a goal check or a limit stops it.
9
+ #
10
+ # An Agent owns almost no logic. It declares which requests it may call, which
11
+ # tools it may use and when to stop. Everything risky stays inside Tools,
12
+ # everything model-related stays inside Requests.
13
+ #
14
+ # class SearchAgent < ActiveHarness::Agent
15
+ # request SearchRequest
16
+ # tools WebSearchTool, FetchPageTool
17
+ # max_turns 10
18
+ # max_cost 0.50
19
+ # end
20
+ #
21
+ # agent = SearchAgent.call(input: "Find 5 Ruby frameworks")
22
+ # agent.status # => :done | :stopped | :failed
23
+ # agent.stop_reason # => :final_answer | :goal_reached | :max_turns | ...
24
+ # agent.output # => final answer text
25
+ #
26
+ # See docs/AGENT.md.
27
+ class Agent
28
+ GOAL_STATUSES = %i[achieved continue replan needs_input failed].freeze
29
+
30
+ VALID_HOOKS = %i[
31
+ before_setup after_setup
32
+ before_turn after_turn
33
+ before_step after_step
34
+ before_llm after_llm
35
+ before_tool after_tool tool_denied
36
+ replan stopped
37
+ before_finalize complete
38
+ ].freeze
39
+ STEP_HOOKS = %i[before_step after_step].freeze
40
+ TOOL_HOOKS = %i[before_tool after_tool tool_denied].freeze
41
+ STEPS = %i[context llm tools goal limits].freeze
42
+
43
+ DEFAULT_LIMITS = {
44
+ max_turns: 10,
45
+ max_cost: nil,
46
+ max_tool_calls: nil,
47
+ max_tool_errors: 3,
48
+ max_denials: 5,
49
+ max_goal_retries: 2
50
+ }.freeze
51
+
52
+ # -------------------------------------------------------------------------
53
+ # Class-level DSL
54
+ # -------------------------------------------------------------------------
55
+ class << self
56
+ def agent_config
57
+ @agent_config ||= new_agent_config
58
+ end
59
+
60
+ # Each subclass gets its own isolated config.
61
+ def inherited(subclass)
62
+ super
63
+ subclass.instance_variable_set(:@agent_config, new_agent_config)
64
+ end
65
+
66
+ # request SomeRequest — the default Request
67
+ # request :cheap, CheapRequest — a named one (see request_for)
68
+ def request(name_or_class, klass = nil)
69
+ name, klass = klass ? [name_or_class.to_sym, klass] : [:default, name_or_class]
70
+ agent_config[:requests][name] = klass
71
+ end
72
+
73
+ # Allowed tools (default deny: anything else is "unknown tool").
74
+ def tools(*classes)
75
+ agent_config[:tools].concat(classes.flatten)
76
+ end
77
+
78
+ DEFAULT_LIMITS.each_key do |limit|
79
+ define_method(limit) do |value|
80
+ agent_config[:limits][limit] = value
81
+ end
82
+ end
83
+
84
+ # Pick the Request for the next turn: request_for { |turn| turn.number > 4 ? :cheap : :default }
85
+ def request_for(&block)
86
+ agent_config[:request_for] = block
87
+ end
88
+
89
+ # A goal check: an object with call(candidate:, goal:, state:) returning GoalCheckResult,
90
+ # or a block { |candidate, goal, state| ... }.
91
+ def goal(check = nil, &block)
92
+ agent_config[:goal] = check || block
93
+ end
94
+
95
+ def setup(pipeline)
96
+ agent_config[:setup] = pipeline
97
+ end
98
+
99
+ def finalize(pipeline)
100
+ agent_config[:finalize] = pipeline
101
+ end
102
+
103
+ # An object with call(state:) -> messages for the model. Default: the full history.
104
+ def context_builder(builder)
105
+ agent_config[:context_builder] = builder
106
+ end
107
+
108
+ # Stop when the same tool calls with the same input repeat `times` turns in a row.
109
+ def loop_guard(times: 3)
110
+ agent_config[:loop_guard] = times
111
+ end
112
+
113
+ # Custom stop rule: stop_if(:name) { |turn| ... } — the stop_reason is :name.
114
+ def stop_if(name, &block)
115
+ agent_config[:stop_rules] << [name.to_sym, block]
116
+ end
117
+
118
+ # on :before_tool do |call| ... end
119
+ # on :before_tool, "os::rm" do |call| ... end (per-tool form)
120
+ # on :after_step, :goal do |turn| ... end (per-step form)
121
+ def on(event, name = nil, &block)
122
+ raise ArgumentError, "Unknown Agent hook :#{event}. Valid: #{VALID_HOOKS.join(', ')}" unless VALID_HOOKS.include?(event)
123
+
124
+ if name.nil?
125
+ (agent_config[:hooks][event] ||= []) << block
126
+ elsif STEP_HOOKS.include?(event)
127
+ ((agent_config[:step_hooks][name.to_sym] ||= {})[event] ||= []) << block
128
+ elsif TOOL_HOOKS.include?(event)
129
+ key = name.respond_to?(:full_name) ? name.full_name : name.to_s
130
+ ((agent_config[:tool_hooks][key] ||= {})[event] ||= []) << block
131
+ else
132
+ raise ArgumentError, "Hook :#{event} has no per-name form"
133
+ end
134
+ end
135
+
136
+ def before(event, name = nil, &block)
137
+ on(:"before_#{event}", name, &block)
138
+ end
139
+
140
+ def after(event, name = nil, &block)
141
+ on(:"after_#{event}", name, &block)
142
+ end
143
+
144
+ def callback(event, &block)
145
+ on(event, &block)
146
+ end
147
+
148
+ # Same style as Request.call: returns the (already run) instance.
149
+ def call(input:, context: {}, params: {}, memory: nil, token: nil, stream: nil)
150
+ new(input: input, context: context, params: params, memory: memory, token: token, stream: stream).call
151
+ end
152
+
153
+ private
154
+
155
+ def new_agent_config
156
+ {
157
+ requests: {},
158
+ tools: [],
159
+ limits: DEFAULT_LIMITS.dup,
160
+ hooks: {},
161
+ step_hooks: {},
162
+ tool_hooks: {},
163
+ stop_rules: []
164
+ }
165
+ end
166
+ end
167
+
168
+ include Core::HookRunner
169
+
170
+ # -------------------------------------------------------------------------
171
+ # Instance API
172
+ # -------------------------------------------------------------------------
173
+ attr_accessor :input, :context, :params, :memory
174
+ attr_reader :state, :turns, :error, :execution_time
175
+
176
+ def initialize(input: nil, context: {}, params: {}, memory: nil, token: nil, stream: nil)
177
+ @input = input
178
+ @context = context
179
+ @params = params
180
+ @memory = memory
181
+ @token = token
182
+ @stream = stream
183
+ @config = self.class.agent_config
184
+ @turns = []
185
+ @state = nil
186
+ @output = nil
187
+ end
188
+
189
+ # Runs the loop. Returns self (like Request#call); never raises for run-time
190
+ # failures — see #status / #stop_reason / #error.
191
+ def call(input = nil, token: nil, stream: nil)
192
+ @input = input if input
193
+ @token = token if token
194
+ @stream = stream if stream
195
+
196
+ raise ArgumentError, "#{self.class.name}: no request configured (use `request SomeRequest`)" if @config[:requests].empty?
197
+
198
+ started = clock
199
+ reset_run
200
+ begin
201
+ run_setup
202
+ run_loop if running?
203
+ rescue Errors::AllModelsFailed, Errors::ProviderError => e
204
+ fail_run(:request_error, e)
205
+ rescue StandardError, NotImplementedError => e # NotImplementedError is a ScriptError: a forgotten #call/#perform fails the run, not the caller
206
+ fail_run(:runtime_error, e)
207
+ end
208
+ run_finalize
209
+ @execution_time = (clock - started).round(3)
210
+
211
+ fire(:stopped, @state.stop_reason) unless %i[final_answer goal_reached].include?(@state.stop_reason)
212
+ fire(:complete, result)
213
+ self
214
+ end
215
+
216
+ def status; @state&.status; end
217
+ def stop_reason; @state&.stop_reason; end
218
+ def output; @output; end
219
+ def usage; @state&.usage; end
220
+ def run_id; @state&.run_id; end
221
+ def goal; @state&.goal; end
222
+
223
+ def tool_calls
224
+ @turns.flat_map(&:tool_calls)
225
+ end
226
+
227
+ def denied_calls
228
+ tool_calls.select { |c| c.result && c.result.status != :ok }
229
+ end
230
+
231
+ # Result-compatible outcome, so an agent can be a Pipeline step.
232
+ # processed is the last Request's processed value (parsed JSON for `format :json`).
233
+ def result
234
+ last = @turns.reverse.map(&:response).compact.first
235
+ Result.new(
236
+ input: @input,
237
+ output: @output,
238
+ processed: @final_response ? @final_response.processed : @output,
239
+ model: last&.model,
240
+ attempts: [],
241
+ execution_time: @execution_time,
242
+ usage: @state&.usage
243
+ )
244
+ end
245
+
246
+ # Human-readable trace of the whole run.
247
+ def trace
248
+ lines = @turns.map do |turn|
249
+ res = turn.response
250
+ tokens = res&.usage&.tokens&.total
251
+ cost = res&.usage&.cost&.total
252
+ head = format("turn %-2d llm(%s) %s", turn.number, turn.request_name, res&.model&.name)
253
+ head += " #{tokens} tok" if tokens
254
+ head += format(" $%.4f", cost) if cost
255
+ head += " #{(res.execution_time * 1000).round}ms" if res&.execution_time
256
+ body = turn.tool_calls.map do |c|
257
+ r = c.result
258
+ line = " tool #{c.full_name} #{c.input.inspect} #{r&.status}"
259
+ line += " at #{r.failed_step.name}: #{r.failed_step.message}" if r&.failed_step
260
+ line += " [#{r.failed_step.detail}]" if r&.failed_step&.detail
261
+ line
262
+ end
263
+ body << " answer: #{res.output.to_s[0, 80].inspect}" if turn.tool_calls.empty? && res
264
+ ([head] + body).join("\n")
265
+ end
266
+ lines << format("stop %s %d turns, %d tool calls (%d denied), $%.4f",
267
+ @state.stop_reason, @turns.size, tool_calls.size, denied_calls.size,
268
+ @state.usage&.cost&.total.to_f)
269
+ lines.join("\n")
270
+ end
271
+
272
+ private
273
+
274
+ # -------------------------------------------------------------------------
275
+ # Run lifecycle
276
+ # -------------------------------------------------------------------------
277
+
278
+ def reset_run
279
+ @turns = []
280
+ @error = nil
281
+ @output = nil
282
+ @last_text = nil
283
+ @final_response = nil
284
+ @goal_retries = 0
285
+ @denials = 0
286
+ @consecutive_errors = 0
287
+ @signatures = []
288
+ @state = AgentState.new(
289
+ run_id: SecureRandom.uuid, status: :running, input: @input,
290
+ goal: nil, plan: nil, messages: [], events: [], artifacts: {},
291
+ turn: 0, tool_calls_count: 0,
292
+ usage: UsageInfo.new(tokens: TokenCounts.new(input: 0, output: 0, total: 0), cost: nil),
293
+ stop_reason: nil
294
+ )
295
+ end
296
+
297
+ def running?
298
+ @state.status == :running
299
+ end
300
+
301
+ def run_setup
302
+ pipeline_class = @config[:setup]
303
+ fire(:before_setup, @state)
304
+
305
+ goal = nil
306
+ if pipeline_class
307
+ pipeline = pipeline_class.new(input: @input, context: @context.dup, params: @params, memory: @memory, stream: @stream)
308
+ pipeline.call
309
+ journal(:setup, stopped: pipeline.stopped?, stopped_at: pipeline.stopped_at)
310
+
311
+ if pipeline.stopped?
312
+ reject_setup(pipeline)
313
+ fire(:after_setup, @state)
314
+ return
315
+ end
316
+
317
+ last = pipeline.steps.to_a.last
318
+ goal = Goal.from(last ? last[2].processed : pipeline.output)
319
+ if goal.nil?
320
+ @output = "Setup did not produce a goal (expected a Hash with :task or a String)"
321
+ stop!(:setup_rejected)
322
+ fire(:after_setup, @state)
323
+ return
324
+ end
325
+ else
326
+ goal = Goal.new(task: @input.to_s)
327
+ end
328
+
329
+ @state.goal = goal
330
+ @state.plan = goal.plan
331
+ @state.messages << { role: :user, content: goal.task.to_s }
332
+ fire(:after_setup, @state)
333
+ end
334
+
335
+ def reject_setup(pipeline)
336
+ refusal = pipeline.stop_reason
337
+ text = refusal.respond_to?(:output) ? refusal.output : nil
338
+ text = refusal.processed.to_s if text.to_s.empty? && refusal.respond_to?(:processed)
339
+ @output = text.to_s.empty? ? "Rejected at setup step :#{pipeline.stopped_at}" : text.to_s
340
+ stop!(:setup_rejected)
341
+ end
342
+
343
+ def run_loop
344
+ while running?
345
+ reason = limit_reason
346
+ (stop!(reason); break) if reason
347
+
348
+ turn = AgentTurn.new(number: @state.turn + 1, tool_calls: [], steps: [])
349
+ @state.turn = turn.number
350
+ @turns << turn
351
+ turn_started = clock
352
+ fire(:before_turn, turn)
353
+
354
+ messages = step(turn, :context) { context_builder.call(state: @state) }
355
+ response = step(turn, :llm) { call_llm(turn, messages) }
356
+
357
+ if response.tool_calls && !response.tool_calls.empty?
358
+ if cost_exceeded?
359
+ stop!(:max_cost)
360
+ else
361
+ step(turn, :tools) { run_tools(turn, response.tool_calls) }
362
+ end
363
+ else
364
+ step(turn, :goal) { decide_goal(turn, response) }
365
+ end
366
+
367
+ step(turn, :limits) { check_limits(turn) } if running?
368
+
369
+ turn.ms = ((clock - turn_started) * 1000).round
370
+ fire(:after_turn, turn)
371
+ end
372
+ end
373
+
374
+ def run_finalize
375
+ pipeline_class = @config[:finalize]
376
+ return unless pipeline_class
377
+
378
+ fire(:before_finalize, @state)
379
+ ctx = @context.merge(state: @state, status: @state.status, stop_reason: @state.stop_reason)
380
+ pipeline = pipeline_class.new(input: @output.to_s, context: ctx, params: @params, memory: @memory, stream: @stream)
381
+ pipeline.call
382
+ @output = pipeline.output unless pipeline.stopped? || pipeline.output.nil?
383
+ journal(:finalize, stopped: pipeline.stopped?)
384
+ rescue StandardError => e
385
+ # A failure in finalization never erases the primary stop reason.
386
+ @finalize_error = e
387
+ journal(:finalize_error, error: "#{e.class}: #{e.message}")
388
+ end
389
+
390
+ def stop!(reason)
391
+ @state.status = :stopped
392
+ @state.stop_reason = reason
393
+ @output ||= @last_text
394
+ journal(:stop, reason: reason)
395
+ end
396
+
397
+ def finish!(reason, output)
398
+ @state.status = :done
399
+ @state.stop_reason = reason
400
+ @output = output
401
+ journal(:stop, reason: reason)
402
+ end
403
+
404
+ def fail_run(reason, error)
405
+ @error = error
406
+ @state.status = :failed
407
+ @state.stop_reason = reason
408
+ @output ||= @last_text
409
+ journal(:error, reason: reason, error: "#{error.class}: #{error.message}")
410
+ end
411
+
412
+ # -------------------------------------------------------------------------
413
+ # One turn
414
+ # -------------------------------------------------------------------------
415
+
416
+ # Runs a named step of the turn: hooks, timing, trace.
417
+ def step(turn, name)
418
+ fire(:before_step, name, turn)
419
+ fire_step(:before_step, name, turn)
420
+ t0 = clock
421
+ begin
422
+ value = yield
423
+ turn.steps << { name: name, status: :ok, ms: ((clock - t0) * 1000).round }
424
+ value
425
+ rescue StandardError
426
+ turn.steps << { name: name, status: :error, ms: ((clock - t0) * 1000).round }
427
+ raise
428
+ ensure
429
+ fire(:after_step, name, turn)
430
+ fire_step(:after_step, name, turn)
431
+ end
432
+ end
433
+
434
+ def call_llm(turn, messages)
435
+ name = request_name_for(turn)
436
+ klass = @config[:requests][name] or raise ArgumentError, "#{self.class.name}: no request named #{name.inspect}"
437
+ schemas = tool_schemas
438
+
439
+ request = klass.new(
440
+ context: @context.merge(goal: @state.goal, agent_state: @state),
441
+ params: @params,
442
+ memory: @memory,
443
+ token: @token,
444
+ stream: @stream,
445
+ messages: messages,
446
+ tools: schemas.empty? ? nil : schemas
447
+ )
448
+ turn.request_name = name
449
+ turn.request = request
450
+
451
+ fire(:before_llm, turn, request)
452
+ request.call
453
+ response = request.result
454
+ turn.response = response
455
+
456
+ add_usage(response.usage)
457
+ @state.messages << response.message
458
+ @last_text = response.output unless response.output.to_s.empty?
459
+ journal(:llm, turn: turn.number, request: name, model: response.model&.name,
460
+ tokens: response.usage&.tokens&.total, cost: response.usage&.cost&.total,
461
+ tool_calls: Array(response.tool_calls).map { |c| c[:name] }, ms: (response.execution_time.to_f * 1000).round)
462
+ fire(:after_llm, turn, request)
463
+ response
464
+ end
465
+
466
+ def request_name_for(turn)
467
+ block = @config[:request_for]
468
+ name = block ? instance_exec(turn, &block) : :default
469
+ (name || :default).to_sym
470
+ end
471
+
472
+ def run_tools(turn, calls)
473
+ calls.each do |raw|
474
+ max = limit(:max_tool_calls)
475
+ if max && @state.tool_calls_count >= max
476
+ stop!(:max_tool_calls)
477
+ return
478
+ end
479
+
480
+ call = execute_tool_call(raw, turn)
481
+ turn.tool_calls << call
482
+ @state.tool_calls_count += 1
483
+ @state.messages << { role: :tool, tool_call_id: call.id, name: call.name, content: call.result.model_message }
484
+
485
+ track_tool_outcome(call)
486
+ reason = tool_failure_limit
487
+ if reason
488
+ stop!(reason)
489
+ return
490
+ end
491
+ end
492
+ end
493
+
494
+ def execute_tool_call(raw, turn)
495
+ klass = tool_registry[raw[:name].to_s]
496
+ args = raw[:arguments]
497
+ call = ToolCall.new(id: raw[:id], name: raw[:name].to_s, tool: klass, input: args)
498
+ t0 = clock
499
+
500
+ result =
501
+ if klass.nil?
502
+ refusal(:lookup, "unknown tool: #{raw[:name]}")
503
+ elsif !args.is_a?(Hash)
504
+ refusal(:arguments, "arguments are not valid JSON", detail: raw[:raw_arguments].to_s[0, 200])
505
+ else
506
+ call.input = symbolize_keys(args)
507
+ error = klass.validate_input(call.input)
508
+ if error
509
+ refusal(:arguments, error)
510
+ else
511
+ begin
512
+ fire(:before_tool, call)
513
+ fire_tool(:before_tool, call, call)
514
+ klass.call(input: call.input, context: @context)
515
+ rescue Tool::Denied => e
516
+ refusal(:before_tool, e.message, detail: e.detail)
517
+ end
518
+ end
519
+ end
520
+
521
+ call.result = result
522
+ fire(:after_tool, call, result)
523
+ fire_tool(:after_tool, call, call, result)
524
+ if result.status == :denied
525
+ fire(:tool_denied, call, result)
526
+ fire_tool(:tool_denied, call, call, result)
527
+ end
528
+
529
+ journal(:tool_call, turn: turn.number, id: call.id, tool: call.full_name, input: call.input,
530
+ status: result.status, message: result.failed_step&.message,
531
+ detail: result.failed_step&.detail, outcome_unknown: result.outcome_unknown,
532
+ ms: ((clock - t0) * 1000).round)
533
+ call
534
+ end
535
+
536
+ def refusal(step_name, message, detail: nil)
537
+ failed = StepResult.new(name: step_name, status: :denied, message: message, detail: detail, ms: 0)
538
+ ToolResult.new(status: :denied, input: nil, failed_step: failed, steps: [failed], outcome_unknown: false)
539
+ end
540
+
541
+ def track_tool_outcome(call)
542
+ case call.result.status
543
+ when :ok
544
+ @consecutive_errors = 0
545
+ when :denied
546
+ @denials += 1
547
+ when :error
548
+ @consecutive_errors += 1
549
+ end
550
+ end
551
+
552
+ def tool_failure_limit
553
+ max_denials = limit(:max_denials)
554
+ max_errors = limit(:max_tool_errors)
555
+ return :too_many_denials if max_denials && @denials >= max_denials
556
+ return :too_many_tool_errors if max_errors && @consecutive_errors >= max_errors
557
+
558
+ nil
559
+ end
560
+
561
+ # -------------------------------------------------------------------------
562
+ # Goal
563
+ # -------------------------------------------------------------------------
564
+
565
+ def decide_goal(turn, response)
566
+ candidate = response.output.to_s
567
+ spec = @config[:goal]
568
+
569
+ unless spec
570
+ @final_response = response
571
+ return finish!(:final_answer, candidate)
572
+ end
573
+
574
+ verdict = GoalCheckResult.from(run_goal_check(spec, candidate))
575
+ unless GOAL_STATUSES.include?(verdict.status)
576
+ raise ArgumentError, "Goal check returned unknown status #{verdict.status.inspect} (expected one of #{GOAL_STATUSES.join(', ')})"
577
+ end
578
+ turn.verdict = verdict
579
+ journal(:goal, turn: turn.number, status: verdict.status, feedback: verdict.feedback, evidence: verdict.evidence)
580
+
581
+ case verdict.status
582
+ when :achieved
583
+ @final_response = response
584
+ finish!(:goal_reached, candidate)
585
+ when :continue, :replan
586
+ fire(:replan, verdict) if verdict.status == :replan
587
+ @goal_retries += 1
588
+ if @goal_retries > limit(:max_goal_retries).to_i
589
+ @output = candidate
590
+ stop!(:goal_failed)
591
+ else
592
+ @state.messages << { role: :user, content: "Goal not reached: #{verdict.feedback || 'try again'}" }
593
+ end
594
+ when :needs_input
595
+ @output = candidate
596
+ stop!(:needs_input)
597
+ when :failed
598
+ @output = candidate
599
+ stop!(:goal_failed)
600
+ end
601
+ end
602
+
603
+ def run_goal_check(spec, candidate)
604
+ goal = @state.goal
605
+ case spec
606
+ when Proc then spec.call(candidate, goal, @state)
607
+ when Class then spec.new.call(candidate: candidate, goal: goal, state: @state)
608
+ else spec.call(candidate: candidate, goal: goal, state: @state)
609
+ end
610
+ end
611
+
612
+ # -------------------------------------------------------------------------
613
+ # Limits
614
+ # -------------------------------------------------------------------------
615
+
616
+ def limit(name)
617
+ @config[:limits][name]
618
+ end
619
+
620
+ def cost_total
621
+ @state.usage&.cost&.total.to_f
622
+ end
623
+
624
+ def cost_exceeded?
625
+ max = limit(:max_cost)
626
+ !max.nil? && cost_total >= max
627
+ end
628
+
629
+ # Limits that must hold before another Request is made.
630
+ def limit_reason
631
+ max_turns = limit(:max_turns)
632
+ return :max_turns if max_turns && @state.turn >= max_turns
633
+ return :max_cost if cost_exceeded?
634
+
635
+ nil
636
+ end
637
+
638
+ # Checked at the end of a turn that did not finish the run.
639
+ def check_limits(turn)
640
+ @config[:stop_rules].each do |name, block|
641
+ return stop!(name) if instance_exec(turn, &block)
642
+ end
643
+
644
+ times = @config[:loop_guard]
645
+ if times && !turn.tool_calls.empty?
646
+ @signatures << turn.tool_calls.map { |c| [c.name, c.input] }
647
+ recent = @signatures.last(times)
648
+ return stop!(:repeating) if recent.size == times && recent.uniq.size == 1
649
+ end
650
+
651
+ reason = limit_reason
652
+ stop!(reason) if reason
653
+ end
654
+
655
+ def add_usage(usage)
656
+ return unless usage
657
+
658
+ total = @state.usage
659
+ total.tokens.input += usage.tokens.input.to_i
660
+ total.tokens.output += usage.tokens.output.to_i
661
+ total.tokens.total += usage.tokens.total.to_i
662
+
663
+ return unless usage.cost&.total
664
+
665
+ total.cost ||= CostBreakdown.new(input: 0.0, output: 0.0, total: 0.0)
666
+ total.cost.input += usage.cost.input.to_f
667
+ total.cost.output += usage.cost.output.to_f
668
+ total.cost.total += usage.cost.total.to_f
669
+ end
670
+
671
+ # -------------------------------------------------------------------------
672
+ # Tools registry, context
673
+ # -------------------------------------------------------------------------
674
+
675
+ def tool_registry
676
+ @tool_registry ||= @config[:tools].each_with_object({}) { |klass, h| h[klass.wire_name] = klass }
677
+ end
678
+
679
+ def tool_schemas
680
+ @config[:tools].map(&:to_schema)
681
+ end
682
+
683
+ def context_builder
684
+ builder = @config[:context_builder]
685
+ case builder
686
+ when nil then ContextBuilder.new
687
+ when Class then builder.new
688
+ else builder
689
+ end
690
+ end
691
+
692
+ def symbolize_keys(hash)
693
+ hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
694
+ end
695
+
696
+ # -------------------------------------------------------------------------
697
+ # Hooks, events, helpers
698
+ # -------------------------------------------------------------------------
699
+
700
+ # Refuse a tool call from a :before_tool hook. The message goes to the model.
701
+ def deny!(msg, detail: nil)
702
+ raise Tool::Denied.new(msg, detail: detail)
703
+ end
704
+
705
+ def fire(event, *args)
706
+ run_hooks(@config[:hooks], event, *args)
707
+ @stream&.call(:agent, event, *args)
708
+ rescue ClientGone
709
+ nil
710
+ end
711
+
712
+ def fire_step(event, step_name, turn)
713
+ run_hooks(@config[:step_hooks][step_name] || {}, event, turn)
714
+ end
715
+
716
+ def fire_tool(event, call, *args)
717
+ @config[:tool_hooks].each do |name, hooks|
718
+ next unless name == call.full_name || name == call.name
719
+
720
+ run_hooks(hooks, event, *args)
721
+ end
722
+ end
723
+
724
+ def journal(type, **data)
725
+ @state.events << { type: type, at: Time.now.utc.iso8601(3) }.merge(data)
726
+ end
727
+
728
+ def clock
729
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
730
+ end
731
+ end
732
+ end
733
+
734
+