phronomy 0.16.0 → 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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +54 -0
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +124 -92
  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 +1 -1
  10. data/benchmark/bench_tool_schema.rb +2 -35
  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 +2 -2
  14. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  15. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  16. data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
  17. data/lib/phronomy/agent/agent_root.rb +1 -2
  18. data/lib/phronomy/agent/base.rb +135 -314
  19. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  20. data/lib/phronomy/agent/context_assembler.rb +65 -29
  21. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  22. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  23. data/lib/phronomy/agent/execution_coordinator.rb +0 -1
  24. data/lib/phronomy/agent/journal_projection.rb +28 -2
  25. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  26. data/lib/phronomy/agent/shared_state.rb +46 -138
  27. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  28. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  29. data/lib/phronomy/agent.rb +6 -10
  30. data/lib/phronomy/configuration.rb +15 -158
  31. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  32. data/lib/phronomy/engine/runtime.rb +15 -230
  33. data/lib/phronomy/engine/task_group.rb +30 -102
  34. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  35. data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
  36. data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
  37. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  38. data/lib/phronomy/version.rb +1 -1
  39. data/lib/phronomy.rb +3 -120
  40. data/scripts/api_snapshot.rb +1 -12
  41. metadata +3 -9
  42. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  43. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  44. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  45. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  46. data/lib/phronomy/knowledge_source.rb +0 -12
  47. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  48. 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
@@ -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