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.
- checksums.yaml +4 -4
- data/lib/active_harness/agent/goal_check.rb +26 -0
- data/lib/active_harness/agent/state.rb +90 -0
- data/lib/active_harness/agent.rb +734 -0
- data/lib/active_harness/core/errors.rb +11 -0
- data/lib/active_harness/http/client.rb +58 -11
- data/lib/active_harness/http/streaming_client.rb +33 -1
- data/lib/active_harness/pipeline/hooks.rb +1 -1
- data/lib/active_harness/providers/anthropic.rb +57 -8
- data/lib/active_harness/providers/base.rb +6 -0
- data/lib/active_harness/providers/deepseek.rb +7 -4
- data/lib/active_harness/providers/gpustack.rb +7 -4
- data/lib/active_harness/providers/groq.rb +7 -4
- data/lib/active_harness/providers/hetzner.rb +7 -4
- data/lib/active_harness/providers/mistral.rb +7 -4
- data/lib/active_harness/providers/ollama.rb +7 -4
- data/lib/active_harness/providers/ollama_cloud.rb +7 -4
- data/lib/active_harness/providers/openai.rb +7 -4
- data/lib/active_harness/providers/openrouter.rb +7 -4
- data/lib/active_harness/providers/tool_calling.rb +95 -0
- data/lib/active_harness/providers/xai.rb +7 -4
- data/lib/active_harness/railtie.rb +1 -1
- data/lib/active_harness/request/hooks.rb +1 -1
- data/lib/active_harness/request/providers.rb +15 -0
- data/lib/active_harness/request.rb +39 -7
- data/lib/active_harness/result.rb +4 -0
- data/lib/active_harness/tool/path_policy.rb +28 -0
- data/lib/active_harness/tool.rb +348 -0
- data/lib/active_harness/tribunal/hooks.rb +1 -1
- data/lib/active_harness.rb +4 -1
- data/lib/generators/active_harness/agent/agent_generator.rb +16 -0
- data/lib/generators/active_harness/agent/templates/agent.rb.tt +14 -0
- data/lib/generators/active_harness/goal/goal_generator.rb +16 -0
- data/lib/generators/active_harness/goal/templates/goal.rb.tt +14 -0
- data/lib/generators/active_harness/install/install_generator.rb +30 -25
- data/lib/generators/active_harness/install/templates/agents/files_agent.rb +39 -0
- data/lib/generators/active_harness/install/templates/concerns/agent_tracing.rb +36 -0
- data/lib/generators/active_harness/install/templates/concerns/pipeline_tracing.rb +28 -0
- data/lib/generators/active_harness/install/templates/concerns/request_tracing.rb +44 -0
- data/lib/generators/active_harness/install/templates/concerns/tool_tracing.rb +21 -0
- data/lib/generators/active_harness/install/templates/concerns/tracing_log.rb +18 -0
- data/lib/generators/active_harness/install/templates/concerns/tribunal_tracing.rb +28 -0
- data/lib/generators/active_harness/install/templates/controllers/ai_controller.rb +23 -2
- data/lib/generators/active_harness/install/templates/goals/answer_present_goal.rb +22 -0
- data/lib/generators/active_harness/install/templates/pipelines/support_pipeline.rb +8 -18
- data/lib/generators/active_harness/install/templates/prompts/files_prompt.rb +9 -0
- data/lib/generators/active_harness/install/templates/prompts/support_prompt.rb +3 -0
- data/lib/generators/active_harness/install/templates/requests/files_request.rb +18 -0
- data/lib/generators/active_harness/install/templates/requests/support_guard_request.rb +12 -0
- data/lib/generators/active_harness/install/templates/requests/support_request.rb +6 -0
- data/lib/generators/active_harness/install/templates/tools/ls_tool.rb +40 -0
- data/lib/generators/active_harness/install/templates/tools/read_file_tool.rb +43 -0
- data/lib/generators/active_harness/install/templates/tribunals/support_guard_tribunal.rb +7 -0
- data/lib/generators/active_harness/pipeline/templates/pipeline.rb.tt +5 -0
- data/lib/generators/active_harness/request/templates/request.rb.tt +2 -0
- data/lib/generators/active_harness/tool/templates/tool.rb.tt +22 -0
- data/lib/generators/active_harness/tool/tool_generator.rb +16 -0
- data/lib/generators/active_harness/tribunal/templates/tribunal.rb.tt +3 -0
- 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
|
+
|