agent_runtime 0.2.0
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 +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +234 -0
- data/lib/agent_runtime/agent.rb +150 -0
- data/lib/agent_runtime/agent_fsm.rb +446 -0
- data/lib/agent_runtime/audit_log.rb +62 -0
- data/lib/agent_runtime/decision.rb +35 -0
- data/lib/agent_runtime/errors.rb +28 -0
- data/lib/agent_runtime/executor.rb +85 -0
- data/lib/agent_runtime/fsm.rb +213 -0
- data/lib/agent_runtime/planner.rb +93 -0
- data/lib/agent_runtime/policy.rb +42 -0
- data/lib/agent_runtime/state.rb +81 -0
- data/lib/agent_runtime/tool_registry.rb +50 -0
- data/lib/agent_runtime/version.rb +8 -0
- data/lib/agent_runtime.rb +51 -0
- metadata +72 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module AgentRuntime
|
|
7
|
+
# Agentic workflow implementation using formal FSM.
|
|
8
|
+
#
|
|
9
|
+
# Maps directly to the canonical agentic workflow specification with 8 states:
|
|
10
|
+
# - INTAKE: Normalize input, initialize state
|
|
11
|
+
# - PLAN: Single-shot planning using /generate
|
|
12
|
+
# - DECIDE: Make bounded decision (continue vs stop)
|
|
13
|
+
# - EXECUTE: LLM proposes next actions using /chat (looping state)
|
|
14
|
+
# - OBSERVE: Execute tools, inject real-world results
|
|
15
|
+
# - LOOP_CHECK: Control continuation
|
|
16
|
+
# - FINALIZE: Produce terminal output (terminal state)
|
|
17
|
+
# - HALT: Abort safely (terminal state)
|
|
18
|
+
#
|
|
19
|
+
# This implementation provides a complete agentic workflow with explicit state
|
|
20
|
+
# transitions, tool execution, and audit logging.
|
|
21
|
+
#
|
|
22
|
+
# @example Basic usage
|
|
23
|
+
# agent_fsm = AgentFSM.new(
|
|
24
|
+
# planner: planner,
|
|
25
|
+
# policy: policy,
|
|
26
|
+
# executor: executor,
|
|
27
|
+
# state: state,
|
|
28
|
+
# tool_registry: tools
|
|
29
|
+
# )
|
|
30
|
+
# result = agent_fsm.run(initial_input: "Analyze this data")
|
|
31
|
+
#
|
|
32
|
+
# @see FSM
|
|
33
|
+
# @see Agent
|
|
34
|
+
class AgentFSM
|
|
35
|
+
# Initialize a new AgentFSM instance.
|
|
36
|
+
#
|
|
37
|
+
# @param planner [Planner] The planner for generating plans and chat responses
|
|
38
|
+
# @param policy [Policy] The policy validator for decisions
|
|
39
|
+
# @param executor [Executor] The executor for tool calls (currently unused, tools called directly)
|
|
40
|
+
# @param state [State] The state manager for agent state
|
|
41
|
+
# @param tool_registry [ToolRegistry] The registry containing available tools
|
|
42
|
+
# @param audit_log [AuditLog, nil] Optional audit logger for recording decisions
|
|
43
|
+
# @param max_iterations [Integer] Maximum number of iterations before raising an error (default: 50)
|
|
44
|
+
def initialize(planner:, policy:, executor:, state:, tool_registry:, audit_log: nil, max_iterations: 50)
|
|
45
|
+
@planner = planner
|
|
46
|
+
@policy = policy
|
|
47
|
+
@executor = executor
|
|
48
|
+
@state = state
|
|
49
|
+
@tool_registry = tool_registry
|
|
50
|
+
@audit_log = audit_log
|
|
51
|
+
@fsm = FSM.new(max_iterations: max_iterations)
|
|
52
|
+
@messages = []
|
|
53
|
+
@plan = nil
|
|
54
|
+
@decision = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Run the complete agentic workflow from INTAKE to FINALIZE/HALT.
|
|
58
|
+
#
|
|
59
|
+
# Executes the full FSM workflow, transitioning through all states until
|
|
60
|
+
# reaching a terminal state (FINALIZE or HALT). The workflow handles planning,
|
|
61
|
+
# decision-making, tool execution, and observation in a structured loop.
|
|
62
|
+
#
|
|
63
|
+
# @param initial_input [String] The initial input to start the workflow
|
|
64
|
+
# @return [Hash] Final result hash containing:
|
|
65
|
+
# - done: Boolean indicating completion status
|
|
66
|
+
# - iterations: Number of iterations executed
|
|
67
|
+
# - state: Final state snapshot
|
|
68
|
+
# - fsm_history: Array of state transition history
|
|
69
|
+
# - final_message: Optional final message content (if FINALIZE)
|
|
70
|
+
# - error: Error reason (if HALT)
|
|
71
|
+
# @raise [ExecutionError] If the workflow halts due to an error
|
|
72
|
+
# @raise [MaxIterationsExceeded] If maximum iterations are exceeded
|
|
73
|
+
#
|
|
74
|
+
# @example
|
|
75
|
+
# result = agent_fsm.run(initial_input: "Find weather and send email")
|
|
76
|
+
# # => { done: true, iterations: 3, state: {...}, fsm_history: [...] }
|
|
77
|
+
def run(initial_input:)
|
|
78
|
+
@fsm.reset
|
|
79
|
+
@messages = []
|
|
80
|
+
@plan = nil
|
|
81
|
+
@decision = nil
|
|
82
|
+
|
|
83
|
+
loop do
|
|
84
|
+
case @fsm.state_name
|
|
85
|
+
when :INTAKE
|
|
86
|
+
handle_intake(initial_input)
|
|
87
|
+
when :PLAN
|
|
88
|
+
handle_plan
|
|
89
|
+
when :DECIDE
|
|
90
|
+
handle_decide
|
|
91
|
+
when :EXECUTE
|
|
92
|
+
handle_execute
|
|
93
|
+
when :OBSERVE
|
|
94
|
+
handle_observe
|
|
95
|
+
when :LOOP_CHECK
|
|
96
|
+
handle_loop_check
|
|
97
|
+
when :FINALIZE
|
|
98
|
+
return handle_finalize
|
|
99
|
+
when :HALT
|
|
100
|
+
return handle_halt
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
break if @fsm.terminal?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @!attribute [r] fsm
|
|
108
|
+
# @return [FSM] The finite state machine instance
|
|
109
|
+
# @!attribute [r] messages
|
|
110
|
+
# @return [Array<Hash>] Array of message hashes with :role and :content
|
|
111
|
+
# @!attribute [r] plan
|
|
112
|
+
# @return [Hash, nil] The plan hash with :goal, :required_capabilities, :initial_steps
|
|
113
|
+
# @!attribute [r] decision
|
|
114
|
+
# @return [Hash, nil] The decision hash with :continue and :reason
|
|
115
|
+
attr_reader :fsm, :messages, :plan, :decision
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# S0: INTAKE - Normalize input, initialize state.
|
|
120
|
+
#
|
|
121
|
+
# Initializes the workflow by creating the initial user message and
|
|
122
|
+
# setting up the state with goal and timestamp.
|
|
123
|
+
#
|
|
124
|
+
# @param input [String] The initial input string
|
|
125
|
+
# @return [void]
|
|
126
|
+
def handle_intake(input)
|
|
127
|
+
@messages = [{ role: "user", content: input }]
|
|
128
|
+
@state.apply!({ goal: input, started_at: Time.now.utc.iso8601 })
|
|
129
|
+
@fsm.transition_to(FSM::STATES[:PLAN], reason: "Input normalized")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# S1: PLAN - Single-shot planning using /generate.
|
|
133
|
+
#
|
|
134
|
+
# Generates a plan using the planner's plan method. Expects Planner#plan
|
|
135
|
+
# to return a Decision with params containing:
|
|
136
|
+
# - goal: string (required)
|
|
137
|
+
# - required_capabilities: array (optional, defaults to [])
|
|
138
|
+
# - initial_steps: array (optional, defaults to [])
|
|
139
|
+
#
|
|
140
|
+
# @return [void]
|
|
141
|
+
# @raise [ExecutionError] If planner is missing schema or prompt_builder
|
|
142
|
+
def handle_plan
|
|
143
|
+
schema = @planner.instance_variable_get(:@schema)
|
|
144
|
+
prompt_builder = @planner.instance_variable_get(:@prompt_builder)
|
|
145
|
+
raise ExecutionError, "Planner requires schema and prompt_builder for PLAN state" unless schema && prompt_builder
|
|
146
|
+
|
|
147
|
+
plan_result = @planner.plan(
|
|
148
|
+
input: @messages.first[:content],
|
|
149
|
+
state: @state.snapshot
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Extract plan from Decision#params
|
|
153
|
+
# Contract: decision.params must contain :goal (required), :required_capabilities, :initial_steps (optional)
|
|
154
|
+
params = plan_result.params || {}
|
|
155
|
+
goal = params[:goal] || params["goal"] || @messages.first[:content]
|
|
156
|
+
required_capabilities = params[:required_capabilities] || params["required_capabilities"] || []
|
|
157
|
+
initial_steps = params[:initial_steps] || params["initial_steps"] || []
|
|
158
|
+
|
|
159
|
+
@plan = {
|
|
160
|
+
goal: goal,
|
|
161
|
+
required_capabilities: required_capabilities,
|
|
162
|
+
initial_steps: initial_steps
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@state.apply!({ plan: @plan })
|
|
166
|
+
|
|
167
|
+
@fsm.transition_to(FSM::STATES[:DECIDE], reason: "Plan created")
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
@fsm.transition_to(FSM::STATES[:HALT], reason: "Plan failed: #{e.message}")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# S2: DECIDE - Make bounded decision (continue vs stop).
|
|
173
|
+
#
|
|
174
|
+
# Makes a simple decision based on plan validity. If plan exists and has
|
|
175
|
+
# a goal, continues to EXECUTE. Otherwise, halts the workflow.
|
|
176
|
+
#
|
|
177
|
+
# In real implementations, this could use LLM or rule-based logic for
|
|
178
|
+
# more sophisticated decision-making.
|
|
179
|
+
#
|
|
180
|
+
# @return [void]
|
|
181
|
+
def handle_decide
|
|
182
|
+
# Simple decision: if plan exists and is valid, continue to EXECUTE
|
|
183
|
+
# In real implementations, this could use LLM or rule-based logic
|
|
184
|
+
if @plan && @plan[:goal]
|
|
185
|
+
@decision = { continue: true, reason: "Plan valid, proceeding to execution" }
|
|
186
|
+
@fsm.transition_to(FSM::STATES[:EXECUTE], reason: "Decision: continue")
|
|
187
|
+
else
|
|
188
|
+
@decision = { continue: false, reason: "Invalid plan" }
|
|
189
|
+
@fsm.transition_to(FSM::STATES[:HALT], reason: "Invalid plan")
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# S3: EXECUTE - LLM proposes next actions using /chat.
|
|
194
|
+
#
|
|
195
|
+
# This is the ONLY looping state. Uses chat_raw to get full response with
|
|
196
|
+
# tool_calls. If tool calls are present, transitions to OBSERVE. Otherwise,
|
|
197
|
+
# transitions to FINALIZE.
|
|
198
|
+
#
|
|
199
|
+
# @return [void]
|
|
200
|
+
# @raise [ExecutionError] If execution fails
|
|
201
|
+
def handle_execute
|
|
202
|
+
@fsm.increment_iteration
|
|
203
|
+
|
|
204
|
+
# Use chat_raw to get full response with tool_calls (ollama-client)
|
|
205
|
+
# chat_raw returns the complete response including tool_calls
|
|
206
|
+
response = @planner.chat_raw(messages: @messages, tools: build_tools_for_chat)
|
|
207
|
+
|
|
208
|
+
# Extract tool calls if present
|
|
209
|
+
tool_calls = extract_tool_calls(response)
|
|
210
|
+
|
|
211
|
+
if tool_calls.any?
|
|
212
|
+
# Store tool calls for OBSERVE state
|
|
213
|
+
@state.apply!({ pending_tool_calls: tool_calls })
|
|
214
|
+
@fsm.transition_to(FSM::STATES[:OBSERVE], reason: "Tool calls requested")
|
|
215
|
+
else
|
|
216
|
+
# No tool calls, agent is done
|
|
217
|
+
# Extract content from chat_raw response
|
|
218
|
+
content = response.dig(:message,
|
|
219
|
+
:content) || response.dig("message", "content") || response[:content] || response.to_s
|
|
220
|
+
@messages << { role: "assistant", content: content }
|
|
221
|
+
@fsm.transition_to(FSM::STATES[:FINALIZE], reason: "No tool calls, execution complete")
|
|
222
|
+
end
|
|
223
|
+
rescue StandardError => e
|
|
224
|
+
@fsm.transition_to(FSM::STATES[:HALT], reason: "Execution failed: #{e.message}")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# S4: OBSERVE - Execute tools, inject real-world results.
|
|
228
|
+
#
|
|
229
|
+
# Executes pending tool calls from the EXECUTE state. Parses tool call
|
|
230
|
+
# arguments (handling JSON strings), calls tools via the registry, and
|
|
231
|
+
# appends results to messages. Handles errors gracefully by including
|
|
232
|
+
# error messages in tool results.
|
|
233
|
+
#
|
|
234
|
+
# @return [void]
|
|
235
|
+
def handle_observe
|
|
236
|
+
tool_calls = @state.snapshot[:pending_tool_calls] || []
|
|
237
|
+
|
|
238
|
+
tool_results = []
|
|
239
|
+
parse_error = nil
|
|
240
|
+
|
|
241
|
+
tool_calls.each do |tool_call|
|
|
242
|
+
# ollama-client tool_call format: { "function" => { "name" => "...", "arguments" => "..." } }
|
|
243
|
+
function = tool_call[:function] || tool_call["function"] || {}
|
|
244
|
+
action = function[:name] || function["name"] || tool_call[:name] || tool_call["name"]
|
|
245
|
+
|
|
246
|
+
# Parse arguments (may be JSON string or hash)
|
|
247
|
+
args_str = function[:arguments] || function["arguments"] ||
|
|
248
|
+
tool_call[:arguments] || tool_call["arguments"] || "{}"
|
|
249
|
+
|
|
250
|
+
begin
|
|
251
|
+
params = args_str.is_a?(String) ? JSON.parse(args_str) : (args_str || {})
|
|
252
|
+
rescue JSON::ParserError => e
|
|
253
|
+
# Malformed JSON in tool call arguments - transition to HALT
|
|
254
|
+
parse_error = e
|
|
255
|
+
break
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
begin
|
|
259
|
+
result = @tool_registry.call(action, params)
|
|
260
|
+
tool_results << {
|
|
261
|
+
tool_call_id: tool_call[:id] || tool_call["id"] || SecureRandom.hex(8),
|
|
262
|
+
name: action,
|
|
263
|
+
result: result
|
|
264
|
+
}
|
|
265
|
+
rescue StandardError => e
|
|
266
|
+
tool_results << {
|
|
267
|
+
tool_call_id: tool_call[:id] || tool_call["id"] || SecureRandom.hex(8),
|
|
268
|
+
name: action,
|
|
269
|
+
error: e.message
|
|
270
|
+
}
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
if parse_error
|
|
275
|
+
@fsm.transition_to(FSM::STATES[:HALT],
|
|
276
|
+
reason: "Invalid tool call arguments (JSON parse error): #{parse_error.message}")
|
|
277
|
+
return
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Append tool results to messages
|
|
281
|
+
tool_results.each do |tool_result|
|
|
282
|
+
@messages << {
|
|
283
|
+
role: "tool",
|
|
284
|
+
content: tool_result.to_json,
|
|
285
|
+
tool_call_id: tool_result[:tool_call_id]
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Update state with observations
|
|
290
|
+
@state.apply!({
|
|
291
|
+
observations: (@state.snapshot[:observations] || []) + tool_results,
|
|
292
|
+
pending_tool_calls: nil
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
@fsm.transition_to(FSM::STATES[:LOOP_CHECK], reason: "Tools executed, #{tool_results.size} results")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# S5: LOOP_CHECK - Control continuation.
|
|
299
|
+
#
|
|
300
|
+
# Checks guards for continuation: max iterations, policy violations, etc.
|
|
301
|
+
# Uses a simple heuristic: if observations exist, continue to EXECUTE.
|
|
302
|
+
# Otherwise, finalize.
|
|
303
|
+
#
|
|
304
|
+
# @return [void]
|
|
305
|
+
def handle_loop_check
|
|
306
|
+
# Check guards: max iterations, policy violations, etc.
|
|
307
|
+
if @fsm.iteration_count >= @fsm.instance_variable_get(:@max_iterations)
|
|
308
|
+
@fsm.transition_to(FSM::STATES[:HALT], reason: "Max iterations exceeded")
|
|
309
|
+
return
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Check if we should continue (simple heuristic: if we have observations, continue)
|
|
313
|
+
if @state.snapshot[:observations]&.any?
|
|
314
|
+
@fsm.transition_to(FSM::STATES[:EXECUTE], reason: "Continuing loop")
|
|
315
|
+
else
|
|
316
|
+
@fsm.transition_to(FSM::STATES[:FINALIZE], reason: "No observations, finalizing")
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# S6: FINALIZE - Produce terminal output.
|
|
321
|
+
#
|
|
322
|
+
# Produces the final result hash with completion status, iterations,
|
|
323
|
+
# state snapshot, and FSM history. Records audit log entry.
|
|
324
|
+
#
|
|
325
|
+
# @return [Hash] Final result hash with done: true
|
|
326
|
+
def handle_finalize
|
|
327
|
+
# Optional: call LLM for summary (no tool calls allowed)
|
|
328
|
+
final_message = @messages.last
|
|
329
|
+
|
|
330
|
+
result = {
|
|
331
|
+
done: true,
|
|
332
|
+
iterations: @fsm.iteration_count,
|
|
333
|
+
state: @state.snapshot,
|
|
334
|
+
fsm_history: @fsm.history
|
|
335
|
+
}
|
|
336
|
+
result[:final_message] = final_message[:content] if final_message
|
|
337
|
+
|
|
338
|
+
@audit_log&.record(
|
|
339
|
+
input: @messages.first[:content],
|
|
340
|
+
decision: @decision,
|
|
341
|
+
result: result
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
result
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# S7: HALT - Abort safely.
|
|
348
|
+
#
|
|
349
|
+
# Handles workflow halt due to error. Produces error result hash and
|
|
350
|
+
# records audit log entry, then raises ExecutionError.
|
|
351
|
+
#
|
|
352
|
+
# @return [void]
|
|
353
|
+
# @raise [ExecutionError] Always raises with halt reason
|
|
354
|
+
def handle_halt
|
|
355
|
+
error_reason = @fsm.history.last&.dig(:reason) || "Unknown error"
|
|
356
|
+
|
|
357
|
+
result = {
|
|
358
|
+
done: false,
|
|
359
|
+
error: error_reason,
|
|
360
|
+
iterations: @fsm.iteration_count,
|
|
361
|
+
state: @state.snapshot,
|
|
362
|
+
fsm_history: @fsm.history
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
@audit_log&.record(
|
|
366
|
+
input: @messages.first&.dig(:content) || "unknown",
|
|
367
|
+
decision: @decision,
|
|
368
|
+
result: result
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
raise ExecutionError, "Agent halted: #{error_reason}"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Convert ToolRegistry tools to Ollama tool definitions.
|
|
375
|
+
#
|
|
376
|
+
# Returns array of tool definitions in Ollama format. This is a basic
|
|
377
|
+
# implementation that creates minimal tool schemas. Override this method
|
|
378
|
+
# to provide proper JSON schemas for each tool.
|
|
379
|
+
#
|
|
380
|
+
# @return [Array<Hash>] Array of tool definition hashes in Ollama format
|
|
381
|
+
#
|
|
382
|
+
# @example Override to provide custom schemas
|
|
383
|
+
# def build_tools_for_chat
|
|
384
|
+
# [
|
|
385
|
+
# {
|
|
386
|
+
# type: "function",
|
|
387
|
+
# function: {
|
|
388
|
+
# name: "search",
|
|
389
|
+
# description: "Search the web",
|
|
390
|
+
# parameters: {
|
|
391
|
+
# type: "object",
|
|
392
|
+
# properties: {
|
|
393
|
+
# query: { type: "string", description: "Search query" }
|
|
394
|
+
# },
|
|
395
|
+
# required: ["query"]
|
|
396
|
+
# }
|
|
397
|
+
# }
|
|
398
|
+
# }
|
|
399
|
+
# ]
|
|
400
|
+
# end
|
|
401
|
+
def build_tools_for_chat
|
|
402
|
+
tools_hash = @tool_registry.instance_variable_get(:@tools) || {}
|
|
403
|
+
return [] if tools_hash.empty?
|
|
404
|
+
|
|
405
|
+
# Basic tool definition format for Ollama
|
|
406
|
+
# Users should override this method to provide proper JSON schemas for each tool
|
|
407
|
+
tools_hash.keys.map do |tool_name|
|
|
408
|
+
{
|
|
409
|
+
type: "function",
|
|
410
|
+
function: {
|
|
411
|
+
name: tool_name.to_s,
|
|
412
|
+
description: "Tool: #{tool_name}",
|
|
413
|
+
parameters: {
|
|
414
|
+
type: "object",
|
|
415
|
+
properties: {},
|
|
416
|
+
additionalProperties: true
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Extract tool calls from ollama-client chat_raw() response.
|
|
424
|
+
#
|
|
425
|
+
# Handles various response formats and extracts tool_calls array.
|
|
426
|
+
# Supports both symbol and string keys, and checks multiple possible
|
|
427
|
+
# locations in the response hash.
|
|
428
|
+
#
|
|
429
|
+
# @param response [Hash, Object] The chat_raw response (may be hash or object with tool_calls method)
|
|
430
|
+
# @return [Array] Array of tool call hashes, empty array if none found
|
|
431
|
+
def extract_tool_calls(response)
|
|
432
|
+
if response.is_a?(Hash)
|
|
433
|
+
# ollama-client chat_raw returns tool_calls in message.tool_calls
|
|
434
|
+
tool_calls = response.dig(:message, :tool_calls) || response.dig("message", "tool_calls")
|
|
435
|
+
return tool_calls if tool_calls.is_a?(Array) && !tool_calls.empty?
|
|
436
|
+
|
|
437
|
+
# Fallback: check other possible locations
|
|
438
|
+
response[:tool_calls] || response["tool_calls"] || []
|
|
439
|
+
elsif response.respond_to?(:tool_calls)
|
|
440
|
+
response.tool_calls
|
|
441
|
+
else
|
|
442
|
+
[]
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module AgentRuntime
|
|
6
|
+
# Optional audit logging for agent decisions and results.
|
|
7
|
+
#
|
|
8
|
+
# This class provides a simple audit logging mechanism that records
|
|
9
|
+
# agent inputs, decisions, and results as JSON to stdout.
|
|
10
|
+
#
|
|
11
|
+
# Subclass this to implement custom logging (e.g., to a file or database).
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage
|
|
14
|
+
# audit_log = AuditLog.new
|
|
15
|
+
# audit_log.record(input: "Search", decision: decision, result: { result: "..." })
|
|
16
|
+
#
|
|
17
|
+
# @example Custom audit log implementation
|
|
18
|
+
# class DatabaseAuditLog < AuditLog
|
|
19
|
+
# def record(input:, decision:, result:)
|
|
20
|
+
# super # Still log to stdout
|
|
21
|
+
# AuditRecord.create(input: input, decision: decision, result: result)
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
class AuditLog
|
|
25
|
+
# Record an audit log entry.
|
|
26
|
+
#
|
|
27
|
+
# Outputs a JSON object to stdout containing:
|
|
28
|
+
# - time: ISO8601 timestamp
|
|
29
|
+
# - input: The input that triggered the decision
|
|
30
|
+
# - decision: The decision made (converted to hash if possible)
|
|
31
|
+
# - result: The execution result
|
|
32
|
+
#
|
|
33
|
+
# @param input [String, Object] The input that triggered the decision
|
|
34
|
+
# @param decision [Decision, Hash, nil] The decision made (converted to hash if responds to #to_h)
|
|
35
|
+
# @param result [Hash, Object] The execution result
|
|
36
|
+
# @return [void]
|
|
37
|
+
#
|
|
38
|
+
# @example
|
|
39
|
+
# audit_log.record(
|
|
40
|
+
# input: "What is the weather?",
|
|
41
|
+
# decision: Decision.new(action: "search", params: { query: "weather" }),
|
|
42
|
+
# result: { result: "Sunny, 72°F" }
|
|
43
|
+
# )
|
|
44
|
+
# # Outputs: {"time":"2024-01-01T12:00:00Z","input":"What is the weather?",...}
|
|
45
|
+
def record(input:, decision:, result:)
|
|
46
|
+
decision_hash = if decision.nil?
|
|
47
|
+
nil
|
|
48
|
+
elsif decision.respond_to?(:to_h)
|
|
49
|
+
decision.to_h
|
|
50
|
+
else
|
|
51
|
+
decision
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
puts({
|
|
55
|
+
time: Time.now.utc.iso8601,
|
|
56
|
+
input: input,
|
|
57
|
+
decision: decision_hash,
|
|
58
|
+
result: result
|
|
59
|
+
}.to_json)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentRuntime
|
|
4
|
+
# Represents a decision made by the planner.
|
|
5
|
+
#
|
|
6
|
+
# This struct encapsulates the output of the planning phase, containing
|
|
7
|
+
# the action to take, optional parameters, and optional confidence score.
|
|
8
|
+
#
|
|
9
|
+
# @!attribute action
|
|
10
|
+
# @return [String, Symbol] The action to execute (e.g., "search", "finish")
|
|
11
|
+
#
|
|
12
|
+
# @!attribute params
|
|
13
|
+
# @return [Hash, nil] Optional parameters for the action
|
|
14
|
+
#
|
|
15
|
+
# @!attribute confidence
|
|
16
|
+
# @return [Float, nil] Optional confidence score (0.0 to 1.0)
|
|
17
|
+
#
|
|
18
|
+
# @example Create a decision
|
|
19
|
+
# decision = Decision.new(
|
|
20
|
+
# action: "search",
|
|
21
|
+
# params: { query: "weather" },
|
|
22
|
+
# confidence: 0.9
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# @example Access attributes
|
|
26
|
+
# decision.action # => "search"
|
|
27
|
+
# decision.params # => { query: "weather" }
|
|
28
|
+
# decision.confidence # => 0.9
|
|
29
|
+
Decision = Struct.new(
|
|
30
|
+
:action,
|
|
31
|
+
:params,
|
|
32
|
+
:confidence,
|
|
33
|
+
keyword_init: true
|
|
34
|
+
)
|
|
35
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentRuntime
|
|
4
|
+
# Base error class for all AgentRuntime errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a decision violates policy constraints.
|
|
8
|
+
#
|
|
9
|
+
# @see Policy#validate!
|
|
10
|
+
class PolicyViolation < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when an unknown or invalid action is encountered.
|
|
13
|
+
class UnknownAction < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when a requested tool is not found in the registry.
|
|
16
|
+
#
|
|
17
|
+
# @see ToolRegistry#call
|
|
18
|
+
class ToolNotFound < Error; end
|
|
19
|
+
|
|
20
|
+
# Base class for execution-related errors.
|
|
21
|
+
class ExecutionError < Error; end
|
|
22
|
+
|
|
23
|
+
# Raised when the agent exceeds the maximum number of iterations.
|
|
24
|
+
#
|
|
25
|
+
# @see Agent#run
|
|
26
|
+
# @see AgentFSM#run
|
|
27
|
+
class MaxIterationsExceeded < ExecutionError; end
|
|
28
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentRuntime
|
|
4
|
+
# Executes tool calls via ToolRegistry based on agent decisions.
|
|
5
|
+
#
|
|
6
|
+
# This class is responsible for executing the actions decided by the planner.
|
|
7
|
+
# It normalizes parameters and delegates to the ToolRegistry for actual execution.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# executor = Executor.new(tool_registry: tools)
|
|
11
|
+
# result = executor.execute(decision, state: state)
|
|
12
|
+
# # => { result: "..." }
|
|
13
|
+
class Executor
|
|
14
|
+
# Initialize a new Executor instance.
|
|
15
|
+
#
|
|
16
|
+
# @param tool_registry [ToolRegistry] The registry containing available tools
|
|
17
|
+
def initialize(tool_registry:)
|
|
18
|
+
@tools = tool_registry
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Execute a decision by calling the appropriate tool.
|
|
22
|
+
#
|
|
23
|
+
# If the action is "finish", returns a done hash without executing any tool.
|
|
24
|
+
# Otherwise, normalizes parameters and calls the tool from the registry.
|
|
25
|
+
#
|
|
26
|
+
# @param decision [Decision] The decision to execute
|
|
27
|
+
# @param state [State, Hash, nil] The current state (unused in default implementation)
|
|
28
|
+
# @return [Hash] The execution result hash
|
|
29
|
+
# @raise [ExecutionError] If execution fails or tool is not found
|
|
30
|
+
#
|
|
31
|
+
# @example Execute a tool call
|
|
32
|
+
# decision = Decision.new(action: "search", params: { query: "weather" })
|
|
33
|
+
# result = executor.execute(decision, state: state)
|
|
34
|
+
# # => { result: "Sunny, 72°F" }
|
|
35
|
+
#
|
|
36
|
+
# @example Finish action
|
|
37
|
+
# decision = Decision.new(action: "finish")
|
|
38
|
+
# result = executor.execute(decision, state: state)
|
|
39
|
+
# # => { done: true }
|
|
40
|
+
def execute(decision, state: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
41
|
+
case decision.action
|
|
42
|
+
when "finish"
|
|
43
|
+
{ done: true }
|
|
44
|
+
else
|
|
45
|
+
normalized_params = normalize_params(decision.params || {})
|
|
46
|
+
@tools.call(decision.action, normalized_params)
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
raise ExecutionError, e.message
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Normalize parameter keys to symbols recursively.
|
|
55
|
+
#
|
|
56
|
+
# Converts all hash keys to symbols and recursively normalizes nested hashes and arrays.
|
|
57
|
+
#
|
|
58
|
+
# @param params [Hash, nil] The parameters to normalize
|
|
59
|
+
# @return [Hash] Normalized hash with symbol keys
|
|
60
|
+
def normalize_params(params)
|
|
61
|
+
return {} if params.nil?
|
|
62
|
+
return params if params.empty?
|
|
63
|
+
|
|
64
|
+
params.each_with_object({}) do |(key, value), normalized|
|
|
65
|
+
symbol_key = key.is_a?(Symbol) ? key : key.to_sym
|
|
66
|
+
normalized[symbol_key] = normalize_value(value)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Normalize a value recursively (handles hashes and arrays).
|
|
71
|
+
#
|
|
72
|
+
# @param value [Object] The value to normalize
|
|
73
|
+
# @return [Object] Normalized value (hashes normalized, arrays mapped, primitives unchanged)
|
|
74
|
+
def normalize_value(value)
|
|
75
|
+
case value
|
|
76
|
+
when Hash
|
|
77
|
+
normalize_params(value)
|
|
78
|
+
when Array
|
|
79
|
+
value.map { |item| normalize_value(item) }
|
|
80
|
+
else
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|