phronomy 0.15.1 → 0.17.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +159 -28
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +400 -143
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +7 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +3 -19
  10. data/benchmark/bench_tool_schema.rb +2 -34
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  14. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  15. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  16. data/lib/phronomy/agent/activation_registry.rb +28 -0
  17. data/lib/phronomy/agent/agent_execution.rb +97 -0
  18. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  19. data/lib/phronomy/agent/agent_invocation.rb +44 -46
  20. data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
  21. data/lib/phronomy/agent/agent_root.rb +66 -0
  22. data/lib/phronomy/agent/async_event_api.rb +55 -475
  23. data/lib/phronomy/agent/base.rb +351 -514
  24. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  25. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  26. data/lib/phronomy/agent/context_assembler.rb +357 -0
  27. data/lib/phronomy/agent/context_candidate.rb +47 -0
  28. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  29. data/lib/phronomy/agent/context_importer.rb +217 -0
  30. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  31. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  32. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  33. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
  34. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  35. data/lib/phronomy/agent/context_plan.rb +25 -0
  36. data/lib/phronomy/agent/context_plan_validator.rb +134 -0
  37. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  38. data/lib/phronomy/agent/context_policy.rb +15 -0
  39. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  40. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  41. data/lib/phronomy/agent/context_request.rb +35 -0
  42. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  43. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  44. data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
  45. data/lib/phronomy/agent/immutable.rb +31 -0
  46. data/lib/phronomy/agent/journal_projection.rb +60 -0
  47. data/lib/phronomy/agent/journal_record.rb +67 -0
  48. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  49. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  50. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  51. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  52. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  53. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  54. data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
  55. data/lib/phronomy/agent/shared_state.rb +46 -138
  56. data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
  57. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  58. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  59. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  60. data/lib/phronomy/agent.rb +10 -16
  61. data/lib/phronomy/agent_busy_error.rb +5 -0
  62. data/lib/phronomy/canonical_json.rb +136 -0
  63. data/lib/phronomy/configuration.rb +17 -155
  64. data/lib/phronomy/content_store/base.rb +51 -0
  65. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  66. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  67. data/lib/phronomy/engine/event_loop.rb +3 -0
  68. data/lib/phronomy/engine/runtime.rb +15 -230
  69. data/lib/phronomy/engine/task_group.rb +30 -102
  70. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  71. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  72. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  73. data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
  74. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  75. data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
  76. data/lib/phronomy/persistence/in_memory.rb +247 -0
  77. data/lib/phronomy/persistence.rb +39 -0
  78. data/lib/phronomy/tools/agent.rb +14 -36
  79. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  80. data/lib/phronomy/version.rb +1 -1
  81. data/lib/phronomy.rb +9 -115
  82. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  83. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  84. data/scripts/api_snapshot.rb +1 -12
  85. data/scripts/migrate_spec_agent_definition.rb +108 -0
  86. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  87. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  88. metadata +54 -13
  89. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  90. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  91. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
  92. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  93. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  94. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  95. data/lib/phronomy/knowledge_source.rb +0 -12
  96. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  97. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
@@ -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,37 @@ 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."
246
147
 
247
148
  define_method(:execute) { store.read_all.to_json }
248
149
  end
@@ -250,7 +151,7 @@ module Phronomy
250
151
  write_tool = Class.new(Phronomy::Agent::Context::Capability::Base) do
251
152
  tool_name "write_finding"
252
153
  description "Record a new finding into the shared knowledge store so " \
253
- "that other researchers can build on your discovery."
154
+ "that other researchers can build on your discovery."
254
155
  param :content, type: :string, desc: "The finding to record"
255
156
 
256
157
  define_method(:execute) do |content:|
@@ -259,16 +160,22 @@ module Phronomy
259
160
  end
260
161
  end
261
162
 
262
- parent_tools = researcher_class.tools
263
- Class.new(researcher_class) { tools(*parent_tools, read_tool, write_tool) }
163
+ definitions = researcher_class.tools.to_h do |tool_class|
164
+ [tool_class, researcher_class.tool_aliases[tool_class]]
165
+ end
166
+ definitions[read_tool] = nil
167
+ definitions[write_tool] = nil
168
+
169
+ Class.new(researcher_class) { tools(definitions) }
264
170
  end
265
171
 
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)
172
+ def build_prompt(
173
+ original_input,
174
+ store,
175
+ cycle,
176
+ extra_tools: [],
177
+ per_agent_instruction: nil
178
+ )
272
179
  guide = self.class._coordination || default_coordination_guide(extra_tools)
273
180
 
274
181
  prompt_parts = [guide]
@@ -281,20 +188,21 @@ module Phronomy
281
188
  return base if store.size == 0
282
189
 
283
190
  findings_text = store.read_all
284
- .map { |f| "- [#{f[:agent]} / cycle #{f[:cycle]}] #{f[:content]}" }
191
+ .map { |finding|
192
+ "- [#{finding[:agent]} / cycle #{finding[:cycle]}] #{finding[:content]}"
193
+ }
285
194
  .join("\n")
286
195
 
287
196
  "#{header}\n\nTask: #{original_input}\n\nFindings so far:\n#{findings_text}"
288
197
  end
289
198
 
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
199
  def default_coordination_guide(extra_tools)
295
200
  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"
201
+ tool_names = extra_tools.map do |tool|
202
+ tool.respond_to?(:tool_name) ? tool.tool_name : tool.name.to_s
203
+ end.join(", ")
204
+ " You also have access to additional tools (#{tool_names}) — " \
205
+ "use them to gather information before writing findings.\n"
298
206
  else
299
207
  ""
300
208
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Resolves a TokenBudget from the effective model configuration that will
6
+ # actually be materialized for one LLM call.
7
+ class TokenBudgetResolver
8
+ def initialize(agent:)
9
+ @agent = agent
10
+ end
11
+
12
+ def resolve(model_config)
13
+ config = stringify_keys(model_config)
14
+ model_name = config["model"]
15
+ explicit_window = integer_or_nil(config["context_window"])
16
+ explicit_reserve = integer_or_nil(config["max_output_tokens"])
17
+
18
+ if explicit_window
19
+ reserve = explicit_reserve || configured_default_reserve
20
+ return build_budget(explicit_window, reserve, model_name)
21
+ end
22
+
23
+ return nil unless model_name
24
+
25
+ model = RubyLLM.models.find(model_name)
26
+ return nil unless model
27
+
28
+ context_window = model.context_window.to_i
29
+ registry_max = model.max_output_tokens.to_i
30
+ reserve = explicit_reserve || configured_default_reserve
31
+ reserve ||= registry_max if registry_max.positive? && registry_max < context_window
32
+ build_budget(context_window, reserve, model_name)
33
+ rescue RubyLLM::ModelNotFoundError
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ def build_budget(context_window, reserve, model_name)
40
+ unless reserve&.positive? && reserve < context_window
41
+ raise Phronomy::InvalidContextBudgetConfigurationError,
42
+ "Cannot determine a valid output reserve for model #{model_name.inspect}; " \
43
+ "set max_output_tokens or Phronomy.configuration.default_output_reserve"
44
+ end
45
+
46
+ # Mandatory Manifest content is accounted separately by ContextAssembler
47
+ # and the final budget validator. TokenBudget reserves output capacity only.
48
+ Phronomy::LlmContextWindow::TokenBudget.new(
49
+ context_window: context_window,
50
+ max_output_tokens: reserve
51
+ )
52
+ end
53
+
54
+ def configured_default_reserve
55
+ integer_or_nil(Phronomy.configuration.default_output_reserve)
56
+ end
57
+
58
+ def integer_or_nil(value)
59
+ return nil if value.nil?
60
+ Integer(value)
61
+ end
62
+
63
+ def stringify_keys(hash)
64
+ hash.to_h.each_with_object({}) do |(key, value), result|
65
+ result[key.to_s] = value
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -2,18 +2,25 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
- # Raised by the Agent-owned RubyLLM ToolCall interceptor before execution.
5
+ # Normal control-transfer signal raised before RubyLLM executes a Tool Call.
6
+ #
7
+ # RubyLLM >= 1.15 guarantees that the complete assistant message has already
8
+ # been added to Chat#messages before before_tool_call runs. Keeping that
9
+ # message here allows Phronomy to persist the complete Provider outcome even
10
+ # though the RubyLLM call itself unwinds through this exception.
6
11
  # @api private
7
12
  class ToolCallIntercepted < StandardError
8
- attr_reader :tool_calls
13
+ attr_reader :tool_calls, :assistant_message, :assistant_outcome, :llm_call_id
9
14
 
10
- def initialize(tool_calls)
15
+ def initialize(tool_calls, assistant_message: nil, assistant_outcome: nil, llm_call_id: nil)
11
16
  @tool_calls = Array(tool_calls).freeze
17
+ @assistant_message = assistant_message
18
+ @assistant_outcome = assistant_outcome || ProviderCallOutcome.capture(assistant_message)
19
+ @llm_call_id = llm_call_id&.to_s&.freeze
12
20
  names = @tool_calls.map(&:name).join(", ")
13
21
  super("Tool call intercepted: #{names}")
14
22
  end
15
23
 
16
- # Convenience accessor for callers that only support one ToolCall.
17
24
  def tool_call
18
25
  @tool_calls.first
19
26
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class ToolDefinitionSet
6
+ attr_reader :runtime_tools, :definitions
7
+
8
+ def self.build(agent)
9
+ runtime_tools = (agent.class.tools + agent.send(:_handoff_tools)).freeze
10
+ definitions = runtime_tools.map do |tool_class|
11
+ prepared = agent.send(:prepare_tool_class, tool_class)
12
+ tool = prepared.is_a?(Class) ? prepared.new : prepared
13
+ {
14
+ "name" => tool.name.to_s,
15
+ "description" => tool.description.to_s,
16
+ "parameters_schema" => normalize(
17
+ tool.respond_to?(:parameters_schema) ? tool.parameters_schema : {}
18
+ ),
19
+ "provider_options" => normalize(
20
+ tool.respond_to?(:provider_options) ? tool.provider_options : {}
21
+ )
22
+ }
23
+ end.freeze
24
+ new(runtime_tools: runtime_tools, definitions: definitions)
25
+ end
26
+
27
+ def self.normalize(value)
28
+ case value
29
+ when Hash
30
+ value.to_h { |key, child| [key.to_s, normalize(child)] }
31
+ when Array
32
+ value.map { |child| normalize(child) }
33
+ when Symbol
34
+ value.to_s
35
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
36
+ value
37
+ else
38
+ if value.respond_to?(:to_json_schema)
39
+ normalize(value.to_json_schema)
40
+ elsif value.respond_to?(:to_h)
41
+ normalize(value.to_h)
42
+ else
43
+ raise ArgumentError, "unsupported tool definition value: #{value.class}"
44
+ end
45
+ end
46
+ end
47
+
48
+ def initialize(runtime_tools:, definitions:)
49
+ @runtime_tools = runtime_tools
50
+ @definitions = Immutable.copy(definitions)
51
+ freeze
52
+ end
53
+ end
54
+ end
55
+ end