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.
@@ -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