phronomy 0.16.0 → 0.18.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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -2,79 +2,24 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
- # Implements the "Shared state" coordination pattern (Anthropic blog, Pattern 5).
6
- #
7
- # @see https://claude.com/blog/multi-agent-coordination-patterns
8
- #
9
- # Multiple peer agents collaborate through a shared {KnowledgeStore}.
10
- # There is no central coordinator. Each agent reads the store, acts on what it
11
- # finds, and writes new findings back. Later agents in a cycle immediately see
12
- # findings written by earlier agents in the same cycle.
13
- #
14
- # Two tools are automatically injected into every member agent at runtime:
15
- # - +read_store+ — returns all current findings as a JSON string
16
- # - +write_finding+ — appends a Hash finding to the store
17
- #
18
- # Use +member+ to register agents and optionally provide per-agent coordination
19
- # instructions. Use +coordination+ to define the team-level protocol that all
20
- # members receive instead of the built-in default guide.
21
- #
22
- # @example Basic usage with per-agent instructions
23
- # class CodeReviewTeam < Phronomy::Agent::SharedState
24
- # member StructureAnalyst
25
- # member SecurityAuditor, instruction: "Focus on authentication and injection risks."
26
- # member QualityReviewer, instruction: "Flag methods longer than 10 lines."
27
- # max_cycles 3
28
- # aggregate { |store| { findings: store.read_all, total: store.size } }
29
- # end
30
- #
31
- # result = CodeReviewTeam.new.invoke("Review the files in ./src")
32
- # # => { output: { findings: [...], total: N }, cycles: 3, terminated_by: :max_cycles }
33
- #
34
- # @example With custom team-level coordination protocol
35
- # class ResearchTeam < Phronomy::Agent::SharedState
36
- # coordination <<~TEXT
37
- # Shared store tools: read_store (no params), write_finding(content:).
38
- # Workflow: read first, then write one finding per insight.
39
- # TEXT
40
- # member LiteratureAgent
41
- # member IndustryAgent
42
- # max_cycles 10
43
- # terminate_when { |store| store.size >= 20 }
44
- # end
5
+ # Implements peer coordination through a shared KnowledgeStore.
45
6
  class SharedState
46
- # Thread-safe (serialised by sequential execution) knowledge store shared
47
- # across all researcher agents within a single {SharedState#invoke} call.
48
- #
49
- # Each finding is stored as a Hash with the keys:
50
- # :agent — Symbol derived from the researcher class name
51
- # :content — String written by the researcher
52
- # :cycle — Integer cycle number in which the finding was recorded
53
7
  class KnowledgeStore
54
8
  def initialize
55
9
  @findings = []
56
10
  end
57
11
 
58
- # Returns a shallow copy of all findings in insertion order.
59
- # @return [Array<Hash>]
60
12
  # @api public
61
13
  def read_all
62
14
  @findings.dup
63
15
  end
64
16
 
65
- # Appends a new finding to the store.
66
- # @param agent [Symbol] researcher identifier
67
- # @param content [String] the finding text
68
- # @param cycle [Integer] the current cycle number
69
- # @return [nil]
70
17
  # @api public
71
18
  def write(agent:, content:, cycle:)
72
19
  @findings << {agent: agent, content: content, cycle: cycle}
73
20
  nil
74
21
  end
75
22
 
76
- # Returns the number of findings recorded so far.
77
- # @return [Integer]
78
23
  # @api public
79
24
  def size
80
25
  @findings.size
@@ -82,96 +27,45 @@ module Phronomy
82
27
  end
83
28
 
84
29
  class << self
85
- # Registers a member agent class that will collaborate via the shared store.
86
- # Members are invoked sequentially within each cycle in declaration order.
87
- #
88
- # @param klass [Class] an Agent::Base subclass
89
- # @param instruction [String, nil] optional per-agent coordination instruction
90
- # appended to the team coordination text in this agent's prompt
91
30
  # @api public
92
31
  def member(klass, instruction: nil)
93
32
  @members ||= []
94
33
  @members << {klass: klass, instruction: instruction}
95
34
  end
96
35
 
97
- # Backward-compatible alias. Registers each class as a member without a
98
- # per-agent instruction. Prefer {.member} for new code.
99
- #
100
- # @param classes [Array<Class>] Agent::Base subclasses
101
- # @api public
102
- def researchers(*classes)
103
- classes.flatten.each { |klass| member(klass) }
104
- end
105
-
106
- # Defines the team-level coordination protocol text injected into every
107
- # member's prompt. When omitted the built-in default guide is used, which
108
- # explains +read_store+ / +write_finding+ usage and enforces the standard
109
- # workflow. Override this when you need a different protocol or tone.
110
- #
111
- # @param text [String, nil] the coordination instructions
112
36
  # @api public
113
37
  def coordination(text = nil)
114
38
  text ? @coordination = text : @coordination
115
39
  end
116
40
 
117
- # Sets the maximum number of cycles to run.
118
- # At least one of +max_cycles+ or +timeout+ must be configured.
119
- #
120
- # @param value [Integer, nil]
121
41
  # @api public
122
42
  def max_cycles(value = nil)
123
43
  value ? @max_cycles = Integer(value) : @max_cycles
124
44
  end
125
45
 
126
- # Sets the maximum wall-clock seconds for the entire invocation.
127
- # At least one of +max_cycles+ or +timeout+ must be configured.
128
- #
129
- # @param value [Numeric, nil]
130
46
  # @api public
131
47
  def timeout(value = nil)
132
48
  value ? @timeout = value.to_f : @timeout
133
49
  end
134
50
 
135
- # Registers an optional convergence block. Evaluated after each completed
136
- # cycle; when it returns +true+ the loop terminates early.
137
- #
138
- # @yield [KnowledgeStore] receives the store; return +true+ to stop
139
51
  # @api public
140
52
  def terminate_when(&block)
141
53
  block ? @terminate_when = block : @terminate_when
142
54
  end
143
55
 
144
- # Defines how the final store is converted into the +:output+ of the result.
145
- # When omitted, +store.read_all+ is used as-is.
146
- #
147
- # @yield [KnowledgeStore] receives the final store; return value becomes +:output+
148
56
  # @api public
149
57
  def aggregate(&block)
150
58
  block ? @aggregator = block : @aggregator
151
59
  end
152
60
 
153
- # @!visibility private
154
61
  def _members = Array(@members)
155
- # @!visibility private — derives class list from _members for backward compat
156
- def _researchers = _members.map { |m| m[:klass] }
157
- # @!visibility private
158
62
  def _coordination = @coordination
159
- # @!visibility private
160
63
  def _max_cycles = @max_cycles
161
- # @!visibility private
162
64
  def _timeout = @timeout
163
- # @!visibility private
164
65
  def _terminate_when = @terminate_when
165
- # @!visibility private
166
66
  def _aggregator = @aggregator
167
67
  end
168
68
 
169
- # Runs the shared-state coordination loop.
170
- #
171
- # @param input [String] the seed question or task description
172
- # @param config [Hash] reserved for future use
173
- # @return [Hash] +:output+, +:cycles+, +:terminated_by+
174
- # @raise [ArgumentError] when neither +max_cycles+ nor +timeout+ is configured
175
69
  # @api public
176
70
  def invoke(input, config: {})
177
71
  validate_termination!
@@ -181,12 +75,17 @@ module Phronomy
181
75
  deadline = self.class._timeout ? Time.now + self.class._timeout : nil
182
76
  terminated_by = :max_cycles
183
77
  completed_cycles = 0
184
-
185
78
  cycle_limit = max_cycles || Float::INFINITY
186
79
 
187
80
  (1..cycle_limit).each do |cycle|
188
81
  self.class._members.each do |member_config|
189
- invoke_researcher(member_config[:klass], store, cycle, input, member_config[:instruction])
82
+ invoke_researcher(
83
+ member_config[:klass],
84
+ store,
85
+ cycle,
86
+ input,
87
+ member_config[:instruction]
88
+ )
190
89
  end
191
90
  completed_cycles = cycle
192
91
 
@@ -199,8 +98,6 @@ module Phronomy
199
98
  terminated_by = :timeout
200
99
  break
201
100
  end
202
-
203
- terminated_by = :max_cycles
204
101
  end
205
102
 
206
103
  output = if self.class._aggregator
@@ -216,33 +113,38 @@ module Phronomy
216
113
 
217
114
  def validate_termination!
218
115
  return if self.class._max_cycles || self.class._timeout
116
+
219
117
  raise ArgumentError,
220
118
  "max_cycles or timeout must be configured before invoking SharedState"
221
119
  end
222
120
 
223
- # Invokes a single member agent for one cycle.
224
- # Builds an anonymous subclass with +read_store+ and +write_finding+ injected,
225
- # then calls +invoke+ with a prompt that includes the coordination guide,
226
- # any per-agent instruction, and the current store contents.
227
- def invoke_researcher(researcher_class, store, cycle, original_input, per_agent_instruction = nil)
121
+ def invoke_researcher(
122
+ researcher_class,
123
+ store,
124
+ cycle,
125
+ original_input,
126
+ per_agent_instruction = nil
127
+ )
228
128
  instrumented = build_instrumented_researcher(researcher_class, store, cycle)
229
129
  extra_tools = researcher_class.tools
230
- prompt = build_prompt(original_input, store, cycle,
130
+ prompt = build_prompt(
131
+ original_input,
132
+ store,
133
+ cycle,
231
134
  extra_tools: extra_tools,
232
- per_agent_instruction: per_agent_instruction)
135
+ per_agent_instruction: per_agent_instruction
136
+ )
233
137
  instrumented.new.invoke(prompt)
234
138
  end
235
139
 
236
- # Builds an anonymous subclass of +researcher_class+ with two store tools
237
- # injected. The tools close over the +store+ instance so that writes and
238
- # reads are reflected in the live store.
239
140
  def build_instrumented_researcher(researcher_class, store, cycle)
240
141
  agent_key = researcher_class.name&.to_sym || researcher_class.object_id.to_s.to_sym
241
142
 
242
143
  read_tool = Class.new(Phronomy::Agent::Context::Capability::Base) do
243
144
  tool_name "read_store"
244
145
  description "Read all current findings from the shared knowledge store. " \
245
- "Call this to see what other researchers have discovered."
146
+ "Call this to see what other researchers have discovered."
147
+ execution_mode :cooperative
246
148
 
247
149
  define_method(:execute) { store.read_all.to_json }
248
150
  end
@@ -250,7 +152,8 @@ module Phronomy
250
152
  write_tool = Class.new(Phronomy::Agent::Context::Capability::Base) do
251
153
  tool_name "write_finding"
252
154
  description "Record a new finding into the shared knowledge store so " \
253
- "that other researchers can build on your discovery."
155
+ "that other researchers can build on your discovery."
156
+ execution_mode :cooperative
254
157
  param :content, type: :string, desc: "The finding to record"
255
158
 
256
159
  define_method(:execute) do |content:|
@@ -259,16 +162,22 @@ module Phronomy
259
162
  end
260
163
  end
261
164
 
262
- parent_tools = researcher_class.tools
263
- Class.new(researcher_class) { tools(*parent_tools, read_tool, write_tool) }
165
+ definitions = researcher_class.tools.to_h do |tool_class|
166
+ [tool_class, researcher_class.tool_aliases[tool_class]]
167
+ end
168
+ definitions[read_tool] = nil
169
+ definitions[write_tool] = nil
170
+
171
+ Class.new(researcher_class) { tools(definitions) }
264
172
  end
265
173
 
266
- # Builds the invocation prompt for a member agent.
267
- # Uses the team-level coordination text when defined via {.coordination},
268
- # otherwise falls back to the built-in default guide. Appends any per-agent
269
- # instruction after the coordination text. Subsequent cycles also include
270
- # the current store contents so agents can build on prior findings.
271
- def build_prompt(original_input, store, cycle, extra_tools: [], per_agent_instruction: nil)
174
+ def build_prompt(
175
+ original_input,
176
+ store,
177
+ cycle,
178
+ extra_tools: [],
179
+ per_agent_instruction: nil
180
+ )
272
181
  guide = self.class._coordination || default_coordination_guide(extra_tools)
273
182
 
274
183
  prompt_parts = [guide]
@@ -281,20 +190,21 @@ module Phronomy
281
190
  return base if store.size == 0
282
191
 
283
192
  findings_text = store.read_all
284
- .map { |f| "- [#{f[:agent]} / cycle #{f[:cycle]}] #{f[:content]}" }
193
+ .map { |finding|
194
+ "- [#{finding[:agent]} / cycle #{finding[:cycle]}] #{finding[:content]}"
195
+ }
285
196
  .join("\n")
286
197
 
287
198
  "#{header}\n\nTask: #{original_input}\n\nFindings so far:\n#{findings_text}"
288
199
  end
289
200
 
290
- # Builds the default tool-usage guide for member agents.
291
- # Describes +read_store+ / +write_finding+ and the required workflow.
292
- # When extra_tools are present, lists their names so the agent knows
293
- # what additional tools are available.
294
201
  def default_coordination_guide(extra_tools)
295
202
  extra_line = if extra_tools.any?
296
- tool_names = extra_tools.map { |t| t.respond_to?(:tool_name) ? t.tool_name : t.name.to_s }.join(", ")
297
- " You also have access to additional tools (#{tool_names}) use them to gather information before writing findings.\n"
203
+ tool_names = extra_tools.map do |tool|
204
+ tool.respond_to?(:tool_name) ? tool.tool_name : tool.name.to_s
205
+ end.join(", ")
206
+ " You also have access to additional tools (#{tool_names}) — " \
207
+ "use them to gather information before writing findings.\n"
298
208
  else
299
209
  ""
300
210
  end
@@ -43,9 +43,8 @@ module Phronomy
43
43
  "set max_output_tokens or Phronomy.configuration.default_output_reserve"
44
44
  end
45
45
 
46
- # ContextAssembler passes the actual mandatory Manifest content to
47
- # ContextSelector. Reserving context_overhead here would count system
48
- # instructions and tool definitions a second time.
46
+ # Mandatory Manifest content is accounted separately by ContextAssembler
47
+ # and the final budget validator. TokenBudget reserves output capacity only.
49
48
  Phronomy::LlmContextWindow::TokenBudget.new(
50
49
  context_window: context_window,
51
50
  max_output_tokens: reserve
@@ -62,7 +61,9 @@ module Phronomy
62
61
  end
63
62
 
64
63
  def stringify_keys(hash)
65
- hash.to_h.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
64
+ hash.to_h.each_with_object({}) do |(key, value), result|
65
+ result[key.to_s] = value
66
+ end
66
67
  end
67
68
  end
68
69
  end
@@ -2,90 +2,48 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
- # Centralises Tool execution routing based on execution_mode.
5
+ # Routes Tool work according to the Tool execution contract.
6
6
  #
7
- # Tool-specific timeout and retry belong to the Tool implementation or its
8
- # underlying client. This executor only chooses the Phronomy execution
9
- # resource and propagates cooperative cancellation.
10
- # @api private
7
+ # :cooperative Tool calls execute inline and must return quickly. call_async
8
+ # wraps their result in an already-settled Task and never consumes an
9
+ # OffloadPool worker.
10
+ #
11
+ # :offloaded Tool calls route synchronous work through OffloadPool. Phronomy
12
+ # does not distinguish whether the reason is blocking I/O, CPU-bound work, or
13
+ # another long synchronous operation.
11
14
  module ToolExecutor
12
- WARNED_MODES = Set.new
13
- WARNED_MODES_MUTEX = Mutex.new
14
- private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
15
-
16
- # Agent-owned execution boundary. Only a ToolInvocation that has consumed
17
- # authorization may enter this method.
18
- def self.call_invocation_async(
19
- tool_invocation:,
20
- cancellation_token: nil,
21
- config: {},
22
- runtime: Phronomy::Runtime.instance
23
- )
24
- unless tool_invocation.dispatchable?
25
- raise Phronomy::ToolError,
26
- "ToolInvocation #{tool_invocation.id} is not authorized for dispatch"
27
- end
28
-
29
- call_async(
30
- tool: tool_invocation.tool,
31
- args: tool_invocation.arguments,
32
- cancellation_token: cancellation_token,
33
- config: config,
34
- runtime: runtime
35
- )
36
- end
37
-
38
- # Low-level Tool API used by direct Tool#call_async callers. Agent execution
39
- # must use .call_invocation_async so authorization cannot be bypassed.
40
- #
41
- # +config+ remains available for invocation metadata, but Phronomy does not
42
- # interpret it as a Tool timeout or retry policy.
43
15
  def self.call_async(
44
16
  tool:,
45
17
  args:,
46
18
  cancellation_token: nil,
47
19
  config: {},
48
- runtime: Phronomy::Runtime.instance
20
+ runtime: nil,
21
+ on_full: :raise
49
22
  )
50
- ct = cancellation_token
51
23
  mode = tool.class.execution_mode
52
24
 
53
- if mode == :cpu_bound || mode == :external_process
54
- warn_key = [tool.class.name, mode]
55
- newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
56
- if newly_warned
57
- message = if mode == :cpu_bound
58
- "[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
59
- "which has no dedicated executor. Falling back to blocking_io " \
60
- "(BlockingAdapterPool). Use :blocking_io explicitly to suppress this warning."
61
- else
62
- "[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
63
- "which has no dedicated process manager. Falling back to blocking_io " \
64
- "(BlockingAdapterPool)."
65
- end
66
- if Phronomy.configuration.logger
67
- Phronomy.configuration.logger.warn(message)
68
- else
69
- warn message
70
- end
25
+ case mode
26
+ when :cooperative
27
+ task = Phronomy::Task.deferred(name: "tool-#{tool.name}")
28
+ begin
29
+ task.complete(
30
+ tool.call(args, cancellation_token: cancellation_token)
31
+ )
32
+ rescue => error
33
+ task.fail(error)
71
34
  end
72
- mode = :blocking_io
73
- end
74
-
75
- pool = begin
76
- runtime&.blocking_io
77
- rescue
78
- nil
79
- end
80
-
81
- if mode == :cooperative || pool.nil?
82
- runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
83
- tool.call(args, cancellation_token: ct)
35
+ task
36
+ when :offloaded
37
+ runtime ||= Phronomy::Runtime.instance
38
+ runtime.offload.submit(
39
+ cancellation_token: cancellation_token,
40
+ on_full: on_full
41
+ ) do
42
+ tool.call(args, cancellation_token: cancellation_token)
84
43
  end
85
44
  else
86
- pool.submit(cancellation_token: ct) do
87
- tool.call(args, cancellation_token: ct)
88
- end
45
+ raise Phronomy::ConfigurationError,
46
+ "unknown Tool execution_mode: #{mode.inspect}"
89
47
  end
90
48
  end
91
49
  end