phronomy 0.14.0 → 0.15.1

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +236 -57
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/examples/workflows/agent_event_mapping.rb +104 -0
  8. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  9. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  10. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  11. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  12. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  13. data/lib/phronomy/agent/async_event_api.rb +553 -0
  14. data/lib/phronomy/agent/base.rb +242 -509
  15. data/lib/phronomy/agent/context/capability/base.rb +51 -119
  16. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  17. data/lib/phronomy/agent/phase_machine_builder.rb +75 -137
  18. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  19. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  20. data/lib/phronomy/agent/tool_executor.rb +47 -69
  21. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  23. data/lib/phronomy/agent.rb +21 -9
  24. data/lib/phronomy/configuration.rb +42 -6
  25. data/lib/phronomy/engine/event_loop.rb +269 -112
  26. data/lib/phronomy/engine/fsm_session.rb +180 -142
  27. data/lib/phronomy/engine/task.rb +5 -10
  28. data/lib/phronomy/event.rb +8 -8
  29. data/lib/phronomy/generator_verifier.rb +253 -142
  30. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  31. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  32. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  33. data/lib/phronomy/invocation_context.rb +5 -19
  34. data/lib/phronomy/llm_adapter/base.rb +25 -34
  35. data/lib/phronomy/metrics.rb +2 -0
  36. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  37. data/lib/phronomy/stream_callback_error.rb +35 -0
  38. data/lib/phronomy/tools/mcp.rb +25 -0
  39. data/lib/phronomy/version.rb +1 -1
  40. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -186
  41. data/lib/phronomy/workflow.rb +122 -261
  42. data/lib/phronomy/workflow_context.rb +54 -102
  43. data/lib/phronomy/workflow_runner.rb +238 -300
  44. data/lib/phronomy.rb +6 -4
  45. data/scripts/check_readme_runnable.rb +4 -1
  46. metadata +18 -47
  47. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  48. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  49. data/lib/phronomy/agent/invocation_context.rb +0 -171
  50. data/lib/phronomy/agent/invocation_session.rb +0 -352
  51. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
@@ -0,0 +1,448 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Builds FSMSession instances for AgentInvocation objects.
6
+ #
7
+ # Blocking/provider work returns through explicit Agent-internal events.
8
+ # Entry actions start operations and return synchronously.
9
+ #
10
+ # @api private
11
+ class AgentInvocationSessionBuilder
12
+ AUTO_STATE_SET = {
13
+ idle: true,
14
+ filtering_input: true,
15
+ building_context: true,
16
+ starting_tools: true,
17
+ evaluating_tools: true,
18
+ dispatching_tools: true,
19
+ recording_tool_results: true,
20
+ output_filtering: true
21
+ }.freeze
22
+
23
+ DECLARED_STATES = %i[
24
+ idle filtering_input building_context calling_llm starting_tools
25
+ evaluating_tools waiting_for_tools dispatching_tools
26
+ recording_tool_results suspended output_filtering completed blocked failed
27
+ ].freeze
28
+
29
+ WAIT_STATES = %i[suspended].freeze
30
+
31
+ TOOL_EVENTS = %i[
32
+ tool_authorized
33
+ tool_approval_required
34
+ tool_completed
35
+ tool_failed
36
+ tool_rejected
37
+ tool_cancelled
38
+ ].freeze
39
+
40
+ def self.build(
41
+ agent:,
42
+ input:,
43
+ messages:,
44
+ config:,
45
+ approval_policy: nil,
46
+ approval_listener: nil,
47
+ mode: :invoke,
48
+ on_event: nil,
49
+ runtime: Phronomy::Runtime.instance
50
+ )
51
+ invocation = AgentInvocation.new(
52
+ agent: agent,
53
+ input: input,
54
+ messages: messages,
55
+ config: config,
56
+ approval_policy: approval_policy,
57
+ approval_listener: approval_listener,
58
+ event_listener: on_event,
59
+ mode: mode
60
+ )
61
+ build_session(
62
+ agent_invocation: invocation,
63
+ runtime: runtime,
64
+ mode: mode
65
+ )
66
+ end
67
+
68
+ def self.build_for_resume(
69
+ agent_invocation:,
70
+ resume_event:,
71
+ resume_phase:,
72
+ runtime: Phronomy::Runtime.instance
73
+ )
74
+ build_session(
75
+ agent_invocation: agent_invocation,
76
+ runtime: runtime,
77
+ mode: agent_invocation.mode,
78
+ resume_event: resume_event,
79
+ resume_phase: resume_phase
80
+ )
81
+ end
82
+
83
+ def self.build_session(
84
+ agent_invocation:,
85
+ runtime:,
86
+ mode:,
87
+ resume_event: nil,
88
+ resume_phase: nil
89
+ )
90
+ agent = agent_invocation.agent
91
+ actions = build_entry_actions(agent, runtime, mode: mode)
92
+ phase_machine = Agent::PhaseMachineBuilder.new(
93
+ entry_actions: actions
94
+ ).build
95
+ iterations = agent.class.max_iterations || 10
96
+
97
+ Phronomy::FSMSession.new(
98
+ id: agent_invocation.id,
99
+ context: agent_invocation,
100
+ entry_point: :idle,
101
+ phase_machine_class: phase_machine,
102
+ entry_actions: {},
103
+ auto_state_set: AUTO_STATE_SET,
104
+ declared_states: DECLARED_STATES,
105
+ wait_state_names: WAIT_STATES,
106
+ external_events: external_events,
107
+ recursion_limit: 12 + (iterations * 8),
108
+ event_loop: runtime.event_loop,
109
+ resume_event: resume_event,
110
+ resume_phase: resume_phase
111
+ )
112
+ end
113
+ private_class_method :build_session
114
+
115
+ def self.external_events
116
+ tool_transitions = TOOL_EVENTS.to_h do |event_name|
117
+ [
118
+ event_name,
119
+ [{from: :waiting_for_tools, to: :evaluating_tools, guard: nil}]
120
+ ]
121
+ end
122
+
123
+ tool_transitions.merge(
124
+ llm_completed: [
125
+ {from: :calling_llm, to: :starting_tools, guard: ->(ctx) {
126
+ ctx.tool_call_pending?
127
+ }},
128
+ {from: :calling_llm, to: :output_filtering, guard: nil}
129
+ ],
130
+ llm_failed: [
131
+ {from: :calling_llm, to: :failed, guard: nil}
132
+ ],
133
+ resume: [
134
+ {from: :suspended, to: :waiting_for_tools, guard: nil}
135
+ ]
136
+ )
137
+ end
138
+ private_class_method :external_events
139
+
140
+ def self.build_entry_actions(agent, runtime, mode:)
141
+ calling_action = if mode.to_sym == :stream
142
+ method(:calling_llm_stream_action).curry.call(agent, runtime)
143
+ else
144
+ method(:calling_llm_action).curry.call(agent, runtime)
145
+ end
146
+
147
+ {
148
+ filtering_input: [
149
+ method(:filtering_input_action).curry.call(agent)
150
+ ],
151
+ building_context: [
152
+ method(:building_context_action).curry.call(agent)
153
+ ],
154
+ calling_llm: [calling_action],
155
+ starting_tools: [
156
+ method(:starting_tools_action).curry.call(runtime)
157
+ ],
158
+ dispatching_tools: [
159
+ method(:dispatching_tools_action).curry.call(runtime)
160
+ ],
161
+ recording_tool_results: [
162
+ method(:recording_tool_results_action)
163
+ ],
164
+ suspended: [method(:suspended_action)],
165
+ output_filtering: [
166
+ method(:output_filtering_action).curry.call(agent)
167
+ ],
168
+ failed: [method(:failed_action)]
169
+ }
170
+ end
171
+ private_class_method :build_entry_actions
172
+
173
+ def self.filtering_input_action(agent, invocation)
174
+ agent.send(
175
+ :check_cancellation!,
176
+ invocation.config,
177
+ "invocation cancelled before input filtering"
178
+ )
179
+ invocation.input = agent.send(
180
+ :run_input_filters!,
181
+ invocation.input
182
+ )
183
+ invocation
184
+ rescue Phronomy::FilterBlockError => error
185
+ invocation.input_blocked = true
186
+ invocation.block_error = error
187
+ invocation
188
+ end
189
+ private_class_method :filtering_input_action
190
+
191
+ def self.building_context_action(agent, invocation)
192
+ invocation.chat = agent.send(:build_chat)
193
+ context = agent.send(
194
+ :build_context,
195
+ invocation.input,
196
+ messages: invocation.messages,
197
+ thread_id: invocation.thread_id,
198
+ config: invocation.config,
199
+ budget: agent.send(:build_token_budget),
200
+ instruction: agent.send(:build_instructions, invocation.input),
201
+ tools: agent.class.tools + agent.send(:_handoff_tools)
202
+ )
203
+ agent.send(:_apply_context_to_chat, invocation.chat, context)
204
+ agent.send(
205
+ :run_before_completion_hooks!,
206
+ invocation.chat,
207
+ invocation.config
208
+ )
209
+ install_tool_interceptors(invocation.chat)
210
+ invocation
211
+ end
212
+ private_class_method :building_context_action
213
+
214
+ def self.install_tool_interceptors(chat)
215
+ if chat.respond_to?(:on_tool_call_batch)
216
+ chat.on_tool_call_batch do |tool_calls|
217
+ raise Phronomy::Agent::ToolCallIntercepted.new(tool_calls)
218
+ end
219
+ end
220
+
221
+ if chat.respond_to?(:before_tool_call)
222
+ chat.before_tool_call do |tool_call|
223
+ raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
224
+ end
225
+ else
226
+ chat.on_tool_call do |tool_call|
227
+ raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
228
+ end
229
+ end
230
+ end
231
+ private_class_method :install_tool_interceptors
232
+
233
+ def self.calling_llm_action(agent, runtime, invocation)
234
+ user_message = invocation.user_message_sent ?
235
+ nil :
236
+ agent.send(:extract_message, invocation.input)
237
+ agent.send(
238
+ :check_cancellation!,
239
+ invocation.config,
240
+ "invocation cancelled before LLM call"
241
+ )
242
+ operation = Phronomy.configuration.llm_adapter.complete_async(
243
+ invocation.chat,
244
+ user_message,
245
+ config: invocation.config
246
+ )
247
+ observe_llm_operation(
248
+ operation,
249
+ invocation,
250
+ runtime: runtime,
251
+ streaming: false
252
+ )
253
+ invocation
254
+ end
255
+ private_class_method :calling_llm_action
256
+
257
+ def self.calling_llm_stream_action(agent, runtime, invocation)
258
+ user_message = invocation.user_message_sent ?
259
+ nil :
260
+ agent.send(:extract_message, invocation.input)
261
+ agent.send(
262
+ :check_cancellation!,
263
+ invocation.config,
264
+ "invocation cancelled before LLM call"
265
+ )
266
+
267
+ operation = Phronomy.configuration.llm_adapter.stream_async(
268
+ invocation.chat,
269
+ user_message,
270
+ config: invocation.config
271
+ ) do |chunk|
272
+ agent.send(
273
+ :check_cancellation!,
274
+ invocation.config,
275
+ "invocation cancelled during streaming"
276
+ )
277
+ post_to_invocation!(
278
+ runtime,
279
+ invocation.id,
280
+ :llm_stream_chunk,
281
+ {content: chunk.content}
282
+ )
283
+ end
284
+
285
+ observe_llm_operation(
286
+ operation,
287
+ invocation,
288
+ runtime: runtime,
289
+ streaming: true
290
+ )
291
+ invocation
292
+ end
293
+ private_class_method :calling_llm_stream_action
294
+
295
+ def self.observe_llm_operation(
296
+ operation,
297
+ invocation,
298
+ runtime:,
299
+ streaming:
300
+ )
301
+ operation.on_complete do |response, error|
302
+ result = LLMOperationResult.new(
303
+ response: response,
304
+ error: error,
305
+ streaming: streaming
306
+ )
307
+ event_type =
308
+ if error && !error.is_a?(ToolCallIntercepted)
309
+ :llm_failed
310
+ else
311
+ :llm_completed
312
+ end
313
+ post_to_invocation!(
314
+ runtime,
315
+ invocation.id,
316
+ event_type,
317
+ result
318
+ )
319
+ end
320
+ end
321
+ private_class_method :observe_llm_operation
322
+
323
+ def self.post_to_invocation!(
324
+ runtime,
325
+ invocation_id,
326
+ event_type,
327
+ payload
328
+ )
329
+ accepted = runtime.event_loop.post_to_session(
330
+ Phronomy::Event.new(
331
+ type: event_type,
332
+ target_id: invocation_id,
333
+ payload: payload
334
+ )
335
+ )
336
+ return if accepted
337
+
338
+ Phronomy.configuration.logger&.warn(
339
+ "[Phronomy] Dropped late #{event_type.inspect} for " \
340
+ "AgentInvocation #{invocation_id}"
341
+ )
342
+ end
343
+ private_class_method :post_to_invocation!
344
+
345
+ def self.starting_tools_action(runtime, invocation)
346
+ children = invocation.pending_tool_calls.map do |tool_call|
347
+ tool = invocation.chat.tools[tool_call.name.to_sym]
348
+ if tool
349
+ ToolInvocation.new(
350
+ parent_agent_invocation_id: invocation.id,
351
+ agent: invocation.agent,
352
+ tool: tool,
353
+ tool_call: tool_call,
354
+ config: invocation.config,
355
+ approval_policy: invocation.approval_policy,
356
+ approval_context: invocation.approval_context
357
+ )
358
+ else
359
+ ToolInvocation.missing(
360
+ parent_agent_invocation_id: invocation.id,
361
+ agent: invocation.agent,
362
+ tool_call: tool_call,
363
+ config: invocation.config
364
+ )
365
+ end
366
+ end
367
+ invocation.tool_invocations = children
368
+
369
+ children.reject(&:terminal?).each do |child|
370
+ session = ToolInvocationSessionBuilder.build(
371
+ tool_invocation: child,
372
+ runtime: runtime
373
+ )
374
+ register_child_session(runtime, child, session)
375
+ end
376
+ invocation
377
+ end
378
+ private_class_method :starting_tools_action
379
+
380
+ def self.dispatching_tools_action(runtime, invocation)
381
+ invocation.tool_invocations
382
+ .select(&:authorized?)
383
+ .each do |child|
384
+ session = ToolInvocationSessionBuilder.build_for_resume(
385
+ tool_invocation: child,
386
+ resume_event: :dispatch,
387
+ resume_phase: :authorized,
388
+ runtime: runtime
389
+ )
390
+ register_child_session(runtime, child, session)
391
+ end
392
+ invocation
393
+ end
394
+ private_class_method :dispatching_tools_action
395
+
396
+ def self.register_child_session(runtime, child, session)
397
+ completion = Phronomy::Task.deferred(
398
+ name: "tool-session:#{child.id}"
399
+ )
400
+ completion.on_complete do |_result, error|
401
+ next unless error
402
+
403
+ child.mark_framework_failed!(error)
404
+ runtime.event_loop.post_to_session(
405
+ Phronomy::Event.new(
406
+ type: :tool_failed,
407
+ target_id: child.parent_agent_invocation_id,
408
+ payload: {tool_invocation_id: child.id}
409
+ )
410
+ )
411
+ end
412
+ runtime.event_loop.register(session, completion: completion)
413
+ end
414
+ private_class_method :register_child_session
415
+
416
+ def self.recording_tool_results_action(invocation)
417
+ invocation.record_tool_results!
418
+ end
419
+ private_class_method :recording_tool_results_action
420
+
421
+ def self.suspended_action(invocation)
422
+ invocation.prepare_approval_request!
423
+ end
424
+ private_class_method :suspended_action
425
+
426
+ def self.failed_action(invocation)
427
+ raise(
428
+ invocation.error ||
429
+ Phronomy::ToolError.new("Agent invocation failed")
430
+ )
431
+ end
432
+ private_class_method :failed_action
433
+
434
+ def self.output_filtering_action(agent, invocation)
435
+ invocation.output = agent.send(
436
+ :run_output_filters!,
437
+ invocation.output
438
+ )
439
+ invocation
440
+ rescue Phronomy::FilterBlockError => error
441
+ invocation.output_blocked = true
442
+ invocation.block_error = error
443
+ invocation
444
+ end
445
+ private_class_method :output_filtering_action
446
+ end
447
+ end
448
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Immutable input passed to Tool approval policy callables.
6
+ #
7
+ # This object intentionally contains the internal, validated arguments used
8
+ # for policy evaluation. It must not be exposed directly to Application UI.
9
+ # Use {ToolApprovalRequest} for notifications.
10
+ #
11
+ # @api public
12
+ class ApprovalEvaluationRequest
13
+ attr_reader :agent,
14
+ :agent_invocation_id,
15
+ :tool,
16
+ :tool_name,
17
+ :tool_schema,
18
+ :tool_invocation_id,
19
+ :tool_call_id,
20
+ :arguments,
21
+ :facts,
22
+ :invocation_context,
23
+ :origin,
24
+ :metadata,
25
+ :default_decision
26
+
27
+ VALID_DECISIONS = %i[allow require_approval reject].freeze
28
+
29
+ def initialize(
30
+ agent:,
31
+ agent_invocation_id:,
32
+ tool:,
33
+ tool_name:,
34
+ tool_schema:,
35
+ tool_invocation_id:,
36
+ tool_call_id:,
37
+ arguments:,
38
+ facts: {},
39
+ invocation_context: {},
40
+ origin: :local,
41
+ metadata: {},
42
+ default_decision: nil
43
+ )
44
+ @agent = agent
45
+ @agent_invocation_id = agent_invocation_id
46
+ @tool = tool
47
+ @tool_name = tool_name.to_s.freeze
48
+ @tool_schema = immutable_copy(tool_schema)
49
+ @tool_invocation_id = tool_invocation_id.to_s.freeze
50
+ @tool_call_id = tool_call_id&.to_s&.freeze
51
+ @arguments = immutable_copy(arguments)
52
+ @facts = immutable_copy(facts || {})
53
+ @invocation_context = immutable_copy(invocation_context || {})
54
+ @origin = origin.to_sym
55
+ @metadata = immutable_copy(metadata || {})
56
+ @default_decision = default_decision&.to_sym
57
+ freeze
58
+ end
59
+
60
+ # Returns a new request with selected fields replaced.
61
+ # @api private
62
+ def with(facts: @facts, default_decision: @default_decision)
63
+ self.class.new(
64
+ agent: @agent,
65
+ agent_invocation_id: @agent_invocation_id,
66
+ tool: @tool,
67
+ tool_name: @tool_name,
68
+ tool_schema: @tool_schema,
69
+ tool_invocation_id: @tool_invocation_id,
70
+ tool_call_id: @tool_call_id,
71
+ arguments: @arguments,
72
+ facts: facts,
73
+ invocation_context: @invocation_context,
74
+ origin: @origin,
75
+ metadata: @metadata,
76
+ default_decision: default_decision
77
+ )
78
+ end
79
+
80
+ private
81
+
82
+ def immutable_copy(value)
83
+ case value
84
+ when Hash
85
+ value.each_with_object({}) do |(key, item), result|
86
+ result[immutable_copy(key)] = immutable_copy(item)
87
+ end.freeze
88
+ when Array
89
+ value.map { |item| immutable_copy(item) }.freeze
90
+ when String
91
+ value.dup.freeze
92
+ else
93
+ begin
94
+ value.frozen? ? value : value.dup.freeze
95
+ rescue TypeError
96
+ value
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end