phronomy 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bb874213c4687c9021be3c78d8972218ed56980cfff777a624311ce476d7314
4
- data.tar.gz: ab3017e56357b057943d31a557e9e1cd12555ec13924fbee92c6f0f7791c9bd1
3
+ metadata.gz: a5d8907f1d87037c09f78f63dd8ddbd21605536b1aa53c2d43e52be4a6ab6791
4
+ data.tar.gz: 37260258f4ece53e6e5b748cb2236f9c99f6f7e5c754fb0c2d72c415b0137386
5
5
  SHA512:
6
- metadata.gz: e3d71a750858fda7910addd2ea8de1a3b907e746a247635d0b7467b4ffb5cf1ca970e74a08118b58e950c5843f756462d87a324331d23f50720067a83bb87590
7
- data.tar.gz: 5ce1868de692cd6807c910f3d4669791307564c5f3dc58055c82c4c0737e3696c0d5b1050e7b85f1aba30b1e6309c11e66fcbf7ffc4f9f6c63f3970b5bce2d52
6
+ metadata.gz: 1533e2e0d82283066bab5f80b6583497330b274396640b91a6b27b1706533e4d25842600eb023c0fd469c8da076101f43e5691218703faedce194cd74476c590
7
+ data.tar.gz: af0513115bfcd23f786dfbedb7bfa7947346bb36168cc62175bd900f8536d984789469afc1fd929de90fca9a6b8cd8496451a054ea3ca86af401ef142b1c3e95
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.4.0] - 2026-05-19
11
+
12
+ ### Removed
13
+
14
+ - **`Phronomy::TrustPipeline` removed**: The `TrustPipeline` class and its inner
15
+ `TrustResult` value object have been deleted. Use `Phronomy::GeneratorVerifier`
16
+ instead, which provides the same generator-verifier pattern with a cleaner,
17
+ fully injectable API.
18
+
19
+ ### Added
20
+
21
+ - **`Phronomy::GeneratorVerifier`** — Generator-Verifier coordination loop
22
+ (Anthropic blog, Pattern 1). Wraps a generator agent and a verifier agent with
23
+ fully injectable prompt builders, response parsers, a configurable iteration
24
+ limit, and an approval-outcome raise policy.
25
+ - **`Phronomy::Agent::Orchestrator`** — Base class for orchestrator agents
26
+ (Anthropic blog, Pattern 2). Extends `Agent::Base` with a `subagent` DSL for
27
+ declarative subagent registration as LLM-callable tools, plus `dispatch_parallel`
28
+ and `fan_out` for programmatic parallel invocation.
29
+ - **`Phronomy::Agent::TeamCoordinator`** — Agent teams coordination pattern
30
+ (Anthropic blog, Pattern 3). An LLM-powered coordinator with a shared task
31
+ queue and a pool of worker agents that carry conversation history across task
32
+ assignments. Adds `coordinator_provider` DSL for independent LLM routing.
33
+ - **`Phronomy::Agent::SharedState`** — Shared-state coordination pattern
34
+ (Anthropic blog, Pattern 5). Peer agents collaborate via a `KnowledgeStore`;
35
+ the `member` DSL registers agents with per-agent instructions; `coordination`
36
+ sets the team protocol; `build_prompt` injects a tool-usage guide automatically.
37
+ - **`Phronomy::LowConfidenceError`** — Exception raised by `GeneratorVerifier`
38
+ when `raise_policy: :raise` and verification fails after exhausting the
39
+ iteration limit.
40
+
41
+ ### Changed
42
+
43
+ - **`Phronomy::Graph::StateGraph` event system refactored**: Per-node `advance`
44
+ events replaced with a unified `node_completed` event queue, reducing
45
+ event-handler registration overhead and simplifying listener registration.
46
+
47
+ ---
48
+
10
49
  ## [0.3.0] - 2026-05-18
11
50
 
12
51
  ### Removed
data/README.md CHANGED
@@ -18,7 +18,10 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
18
18
  | **Context Management** — Token budget calculation, estimation, and pruning | Stable |
19
19
  | **Knowledge/RAG** — Retrieval sources with pluggable loaders, splitters, and vector stores | Beta |
20
20
  | **Multi-agent** — Agent-as-Tool pattern and hub-and-spoke handoff routing | Beta |
21
- | **TrustPipeline** — Self-review loop and confidence gate (citations are LLM-self-reported) | Experimental |
21
+ | **GeneratorVerifier** — Generator-Verifier loop with injectable prompt builders/parsers | Beta |
22
+ | **Agent::Orchestrator** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
23
+ | **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + persistent worker pool with task queue | Beta |
24
+ | **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
22
25
  | **Guardrails** — Input/output validation; built-in PII and prompt-injection detectors | Beta |
23
26
  | **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
24
27
  | **Eval Framework** — Dataset-driven evaluation with multiple scorer types | Beta |
@@ -226,23 +229,88 @@ end
226
229
 
227
230
  Hooks are called in order — global → class → instance — and deep-merged.
228
231
 
229
- ### TrustPipelineTrustworthy outputs with citations and review
232
+ ### GeneratorVerifierGenerator-Verifier loop with custom prompt builders
230
233
 
231
234
  ```ruby
232
- pipeline = Phronomy::TrustPipeline.new(
233
- draft_agent: PolicyDraftAgent,
234
- review_agent: PolicyReviewAgent,
235
+ pipeline = Phronomy::GeneratorVerifier.new(
236
+ draft_agent: PolicyDraftAgent,
237
+ review_agent: PolicyReviewAgent,
238
+
239
+ # Full control over the LLM dialogue — supply your own prompts.
240
+ draft_prompt_builder: ->(input, feedback) {
241
+ base = "Answer precisely: #{input}"
242
+ feedback ? "#{base}\n\nPrevious feedback: #{feedback}" : base
243
+ },
244
+ review_prompt_builder: ->(input, draft, citations) {
245
+ "Is this draft accurate? Draft: #{draft}"
246
+ },
247
+
235
248
  confidence_threshold: 0.7,
236
- max_iterations: 3
249
+ max_iterations: 3,
250
+ raise_if_untrusted: false # set true to raise LowConfidenceError
237
251
  )
238
252
 
239
253
  result = pipeline.invoke("What is the refund policy?")
240
- puts result.output # final answer
241
- puts result.trusted? # true when confidence >= 0.7
242
- puts result.confidence # Float 0.0–1.0
254
+ puts result.output # final answer
255
+ puts result.trusted? # true when confidence >= 0.7
256
+ puts result.confidence # Float 0.0–1.0
257
+ result.citations.each { |c| puts "#{c[:source]}: #{c[:excerpt]}" }
258
+ ```
259
+
260
+ Optionally inject a custom result parser to decode non-JSON LLM output:
261
+
262
+ ```ruby
263
+ pipeline = Phronomy::GeneratorVerifier.new(
264
+ # ... (required params as shown above)
265
+ draft_result_parser: ->(text) { my_custom_draft_parser(text) },
266
+ review_result_parser: ->(text) { my_custom_review_parser(text) }
267
+ )
268
+ ```
243
269
 
244
- result.citations.each do |c|
245
- puts "#{c[:source]}: #{c[:excerpt]}"
270
+ Raise on low confidence:
271
+
272
+ ```ruby
273
+ begin
274
+ result = pipeline.invoke("question")
275
+ rescue Phronomy::LowConfidenceError => e
276
+ puts "Untrusted (confidence #{e.result.confidence}): #{e.result.output}"
277
+ end
278
+ ```
279
+
280
+ ### Agent::Orchestrator — Parallel subagent dispatch
281
+
282
+ ```ruby
283
+ class ResearchOrchestrator < Phronomy::Agent::Orchestrator
284
+ model "gpt-4o"
285
+ instructions "Coordinate research tasks by dispatching to specialised agents."
286
+
287
+ # Each subagent is automatically exposed as an LLM-callable tool.
288
+ subagent :searcher, SearchAgent
289
+ subagent :summarizer, SummaryAgent, on_error: :skip
290
+ end
291
+
292
+ result = ResearchOrchestrator.new.invoke("Research the latest AI news.")
293
+ ```
294
+
295
+ Programmatic parallel dispatch (no LLM loop):
296
+
297
+ ```ruby
298
+ class MyOrchestrator < Phronomy::Agent::Orchestrator
299
+ model "gpt-4o"
300
+ instructions "Orchestrate."
301
+
302
+ def run(query)
303
+ # Heterogeneous agents in parallel
304
+ results = dispatch_parallel(
305
+ {agent: SearchAgent, input: "topic A"},
306
+ {agent: AnalysisAgent, input: query}
307
+ )
308
+
309
+ # Fan-out — same agent, multiple inputs
310
+ translations = fan_out(agent: TranslationAgent, inputs: %w[Hello World])
311
+
312
+ results.map { |r| r[:output] }.join("\n")
313
+ end
246
314
  end
247
315
  ```
248
316
 
@@ -432,7 +500,7 @@ bundle exec ruby NN_example_name/run.rb
432
500
  | 16 | `16_before_completion_hook/` | Global/class/instance before_completion hooks |
433
501
  | 17 | `17_multi_agent_handoff/` | Hub-and-spoke agent routing via Runner |
434
502
  | 18 | `18_rails_agent_job/` | Rails app with AgentJob + ActionCable streaming |
435
- | 19 | `19_trust_pipeline/` | Trustworthy output via Citation Tracking + Self-Review + Confidence Gate |
503
+ | 19 | `19_trust_pipeline/` | Generator-Verifier pattern with citation tracking, self-review loop and confidence gate |
436
504
 
437
505
  ## Development
438
506
 
@@ -66,7 +66,8 @@ module Phronomy
66
66
  if text || block_given?
67
67
  @instructions = text || block
68
68
  else
69
- @instructions
69
+ return @instructions if instance_variable_defined?(:@instructions)
70
+ superclass.respond_to?(:instructions) ? superclass.instructions : nil
70
71
  end
71
72
  end
72
73
 
@@ -88,7 +89,10 @@ module Phronomy
88
89
  # )
89
90
  def tools(*args)
90
91
  if args.empty?
91
- return @tools || []
92
+ if instance_variable_defined?(:@tools)
93
+ return @tools
94
+ end
95
+ return superclass.respond_to?(:tools) ? superclass.tools : []
92
96
  end
93
97
 
94
98
  if args.length == 1 && args.first.is_a?(Hash)
@@ -122,7 +126,8 @@ module Phronomy
122
126
  if name
123
127
  @provider = name
124
128
  else
125
- @provider
129
+ return @provider if instance_variable_defined?(:@provider)
130
+ superclass.respond_to?(:provider) ? superclass.provider : nil
126
131
  end
127
132
  end
128
133
 
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Base class for orchestrator agents that coordinate multiple subagents.
6
+ # Implements the Orchestrator-Subagent multi-agent coordination pattern
7
+ # (Anthropic blog, Pattern 2).
8
+ #
9
+ # @see https://claude.com/blog/multi-agent-coordination-patterns
10
+ #
11
+ # Extends {Phronomy::Agent::Base} with:
12
+ # - A +subagent+ class-level DSL for declarative subagent registration. Each
13
+ # declared subagent is automatically exposed as an LLM-callable tool.
14
+ # - +dispatch_parallel+ for programmatic parallel invocation of heterogeneous
15
+ # agents.
16
+ # - +fan_out+ for parallel invocation of the same agent across multiple inputs.
17
+ #
18
+ # @example Declarative DSL
19
+ # class ResearchOrchestrator < Phronomy::Agent::Orchestrator
20
+ # model "gpt-4o"
21
+ # instructions "You coordinate research tasks."
22
+ # subagent :searcher, SearchAgent
23
+ # subagent :summarizer, SummaryAgent
24
+ # end
25
+ #
26
+ # result = ResearchOrchestrator.new.invoke("Research the latest AI news.")
27
+ #
28
+ # @example Programmatic parallel dispatch
29
+ # class MyOrchestrator < Phronomy::Agent::Orchestrator
30
+ # model "gpt-4o"
31
+ # instructions "Dispatch tasks in parallel."
32
+ #
33
+ # def run(input)
34
+ # results = dispatch_parallel(
35
+ # { agent: SearchAgent, input: "topic A" },
36
+ # { agent: AnalysisAgent, input: input }
37
+ # )
38
+ # results.map { |r| r[:output] }.join("\n")
39
+ # end
40
+ # end
41
+ #
42
+ # @example Fan-out (same agent, multiple inputs)
43
+ # results = fan_out(agent: TranslationAgent, inputs: ["Hello", "World"])
44
+ class Orchestrator < Base
45
+ # Declares a named subagent and registers it as a tool accessible to the
46
+ # LLM during an +invoke+ call.
47
+ #
48
+ # Each call appends a new tool to this class's tool list. The generated
49
+ # tool's function name is +dispatch_to_<name>+. When the LLM calls the
50
+ # tool, a fresh instance of +agent_class+ is created and +invoke+ is called
51
+ # with the provided input string.
52
+ #
53
+ # @param name [Symbol] logical name that identifies the subagent
54
+ # @param agent_class [Class] subclass of {Phronomy::Agent::Base}
55
+ # @param on_error [Symbol] +:raise+ (default) re-raises any exception
56
+ # from the subagent; +:skip+ returns +nil+ so the LLM can decide how to
57
+ # proceed
58
+ def self.subagent(name, agent_class, on_error: :raise)
59
+ tool_class = Class.new(Phronomy::Tool::Base) do
60
+ tool_name "dispatch_to_#{name}"
61
+ description "Dispatch work to the #{name} subagent (#{agent_class.name})"
62
+ param :input, type: :string, desc: "The task or question for the subagent"
63
+
64
+ define_method(:execute) do |input:|
65
+ result = agent_class.new.invoke(input)
66
+ result[:output]
67
+ rescue
68
+ raise if on_error == :raise
69
+ nil
70
+ end
71
+ end
72
+
73
+ # Append without clobbering previously registered tools or aliases.
74
+ @tools = (@tools || []) + [tool_class]
75
+ @tool_aliases ||= {}
76
+
77
+ registered_subagents[name] = {agent_class: agent_class, on_error: on_error}
78
+ end
79
+
80
+ # Returns the subagent registry for this specific class (not inherited).
81
+ #
82
+ # @return [Hash{Symbol => Hash}]
83
+ def self.registered_subagents
84
+ @registered_subagents ||= {}
85
+ end
86
+
87
+ # Dispatches multiple heterogeneous agent tasks in parallel using Ruby
88
+ # threads. Each task is a Hash describing one agent invocation.
89
+ #
90
+ # Results are returned in the same order as the input +tasks+ array.
91
+ # If any thread raises an exception, the exception is re-raised in the
92
+ # calling thread after all threads have completed (via +Thread#value+).
93
+ #
94
+ # @param tasks [Array<Hash>]
95
+ # @option task [Class] :agent agent class to invoke (required)
96
+ # @option task [String] :input input string for the agent (required)
97
+ # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
98
+ # @return [Array<Hash>] agent results in the same order as +tasks+
99
+ def dispatch_parallel(*tasks)
100
+ threads = tasks.map do |task|
101
+ Thread.new do
102
+ task[:agent].new.invoke(task[:input], config: task.fetch(:config, {}))
103
+ end
104
+ end
105
+ threads.map(&:value)
106
+ end
107
+
108
+ # Runs the same agent against multiple inputs in parallel (fan-out pattern).
109
+ #
110
+ # @param agent [Class] agent class to invoke for every input
111
+ # @param inputs [Array<String>] list of input strings
112
+ # @param config [Hash] forwarded to every +agent#invoke+ call
113
+ # @return [Array<Hash>] results in the same order as +inputs+
114
+ def fan_out(agent:, inputs:, config: {})
115
+ dispatch_parallel(*inputs.map { |input| {agent: agent, input: input, config: config} })
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
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
45
+ 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
+ class KnowledgeStore
54
+ def initialize
55
+ @findings = []
56
+ end
57
+
58
+ # Returns a shallow copy of all findings in insertion order.
59
+ # @return [Array<Hash>]
60
+ def read_all
61
+ @findings.dup
62
+ end
63
+
64
+ # Appends a new finding to the store.
65
+ # @param agent [Symbol] researcher identifier
66
+ # @param content [String] the finding text
67
+ # @param cycle [Integer] the current cycle number
68
+ # @return [nil]
69
+ def write(agent:, content:, cycle:)
70
+ @findings << {agent: agent, content: content, cycle: cycle}
71
+ nil
72
+ end
73
+
74
+ # Returns the number of findings recorded so far.
75
+ # @return [Integer]
76
+ def size
77
+ @findings.size
78
+ end
79
+ end
80
+
81
+ class << self
82
+ # Registers a member agent class that will collaborate via the shared store.
83
+ # Members are invoked sequentially within each cycle in declaration order.
84
+ #
85
+ # @param klass [Class] an Agent::Base subclass
86
+ # @param instruction [String, nil] optional per-agent coordination instruction
87
+ # appended to the team coordination text in this agent's prompt
88
+ def member(klass, instruction: nil)
89
+ @members ||= []
90
+ @members << {klass: klass, instruction: instruction}
91
+ end
92
+
93
+ # Backward-compatible alias. Registers each class as a member without a
94
+ # per-agent instruction. Prefer {.member} for new code.
95
+ #
96
+ # @param classes [Array<Class>] Agent::Base subclasses
97
+ def researchers(*classes)
98
+ classes.flatten.each { |klass| member(klass) }
99
+ end
100
+
101
+ # Defines the team-level coordination protocol text injected into every
102
+ # member's prompt. When omitted the built-in default guide is used, which
103
+ # explains +read_store+ / +write_finding+ usage and enforces the standard
104
+ # workflow. Override this when you need a different protocol or tone.
105
+ #
106
+ # @param text [String, nil] the coordination instructions
107
+ def coordination(text = nil)
108
+ text ? @coordination = text : @coordination
109
+ end
110
+
111
+ # Sets the maximum number of cycles to run.
112
+ # At least one of +max_cycles+ or +timeout+ must be configured.
113
+ #
114
+ # @param value [Integer, nil]
115
+ def max_cycles(value = nil)
116
+ value ? @max_cycles = Integer(value) : @max_cycles
117
+ end
118
+
119
+ # Sets the maximum wall-clock seconds for the entire invocation.
120
+ # At least one of +max_cycles+ or +timeout+ must be configured.
121
+ #
122
+ # @param value [Numeric, nil]
123
+ def timeout(value = nil)
124
+ value ? @timeout = value.to_f : @timeout
125
+ end
126
+
127
+ # Registers an optional convergence block. Evaluated after each completed
128
+ # cycle; when it returns +true+ the loop terminates early.
129
+ #
130
+ # @yield [KnowledgeStore] receives the store; return +true+ to stop
131
+ def terminate_when(&block)
132
+ block ? @terminate_when = block : @terminate_when
133
+ end
134
+
135
+ # Defines how the final store is converted into the +:output+ of the result.
136
+ # When omitted, +store.read_all+ is used as-is.
137
+ #
138
+ # @yield [KnowledgeStore] receives the final store; return value becomes +:output+
139
+ def aggregate(&block)
140
+ block ? @aggregator = block : @aggregator
141
+ end
142
+
143
+ # @!visibility private
144
+ def _members = Array(@members)
145
+ # @!visibility private — derives class list from _members for backward compat
146
+ def _researchers = _members.map { |m| m[:klass] }
147
+ # @!visibility private
148
+ def _coordination = @coordination
149
+ # @!visibility private
150
+ def _max_cycles = @max_cycles
151
+ # @!visibility private
152
+ def _timeout = @timeout
153
+ # @!visibility private
154
+ def _terminate_when = @terminate_when
155
+ # @!visibility private
156
+ def _aggregator = @aggregator
157
+ end
158
+
159
+ # Runs the shared-state coordination loop.
160
+ #
161
+ # @param input [String] the seed question or task description
162
+ # @param config [Hash] reserved for future use
163
+ # @return [Hash] +:output+, +:cycles+, +:terminated_by+
164
+ # @raise [ArgumentError] when neither +max_cycles+ nor +timeout+ is configured
165
+ def invoke(input, config: {})
166
+ validate_termination!
167
+
168
+ store = KnowledgeStore.new
169
+ max_cycles = self.class._max_cycles
170
+ deadline = self.class._timeout ? Time.now + self.class._timeout : nil
171
+ terminated_by = :max_cycles
172
+ completed_cycles = 0
173
+
174
+ cycle_limit = max_cycles || Float::INFINITY
175
+
176
+ (1..cycle_limit).each do |cycle|
177
+ self.class._members.each do |member_config|
178
+ invoke_researcher(member_config[:klass], store, cycle, input, member_config[:instruction])
179
+ end
180
+ completed_cycles = cycle
181
+
182
+ if self.class._terminate_when&.call(store)
183
+ terminated_by = :terminate_when
184
+ break
185
+ end
186
+
187
+ if deadline && Time.now >= deadline
188
+ terminated_by = :timeout
189
+ break
190
+ end
191
+
192
+ terminated_by = :max_cycles
193
+ end
194
+
195
+ output = if self.class._aggregator
196
+ self.class._aggregator.call(store)
197
+ else
198
+ store.read_all
199
+ end
200
+
201
+ {output: output, cycles: completed_cycles, terminated_by: terminated_by}
202
+ end
203
+
204
+ private
205
+
206
+ def validate_termination!
207
+ return if self.class._max_cycles || self.class._timeout
208
+ raise ArgumentError,
209
+ "max_cycles or timeout must be configured before invoking SharedState"
210
+ end
211
+
212
+ # Invokes a single member agent for one cycle.
213
+ # Builds an anonymous subclass with +read_store+ and +write_finding+ injected,
214
+ # then calls +invoke+ with a prompt that includes the coordination guide,
215
+ # any per-agent instruction, and the current store contents.
216
+ def invoke_researcher(researcher_class, store, cycle, original_input, per_agent_instruction = nil)
217
+ instrumented = build_instrumented_researcher(researcher_class, store, cycle)
218
+ extra_tools = researcher_class.tools
219
+ prompt = build_prompt(original_input, store, cycle,
220
+ extra_tools: extra_tools,
221
+ per_agent_instruction: per_agent_instruction)
222
+ instrumented.new.invoke(prompt)
223
+ end
224
+
225
+ # Builds an anonymous subclass of +researcher_class+ with two store tools
226
+ # injected. The tools close over the +store+ instance so that writes and
227
+ # reads are reflected in the live store.
228
+ def build_instrumented_researcher(researcher_class, store, cycle)
229
+ agent_key = researcher_class.name&.to_sym || researcher_class.object_id.to_s.to_sym
230
+
231
+ read_tool = Class.new(Phronomy::Tool::Base) do
232
+ tool_name "read_store"
233
+ description "Read all current findings from the shared knowledge store. " \
234
+ "Call this to see what other researchers have discovered."
235
+
236
+ define_method(:execute) { store.read_all.to_json }
237
+ end
238
+
239
+ write_tool = Class.new(Phronomy::Tool::Base) do
240
+ tool_name "write_finding"
241
+ description "Record a new finding into the shared knowledge store so " \
242
+ "that other researchers can build on your discovery."
243
+ param :content, type: :string, desc: "The finding to record"
244
+
245
+ define_method(:execute) do |content:|
246
+ store.write(agent: agent_key, content: content, cycle: cycle)
247
+ "Finding recorded."
248
+ end
249
+ end
250
+
251
+ parent_tools = researcher_class.tools
252
+ Class.new(researcher_class) { tools(*parent_tools, read_tool, write_tool) }
253
+ end
254
+
255
+ # Builds the invocation prompt for a member agent.
256
+ # Uses the team-level coordination text when defined via {.coordination},
257
+ # otherwise falls back to the built-in default guide. Appends any per-agent
258
+ # instruction after the coordination text. Subsequent cycles also include
259
+ # the current store contents so agents can build on prior findings.
260
+ def build_prompt(original_input, store, cycle, extra_tools: [], per_agent_instruction: nil)
261
+ guide = self.class._coordination || default_coordination_guide(extra_tools)
262
+
263
+ prompt_parts = [guide]
264
+ if per_agent_instruction
265
+ prompt_parts << "\nYour specific focus for this session: #{per_agent_instruction}"
266
+ end
267
+ header = prompt_parts.join
268
+
269
+ base = "#{header}\n\nTask: #{original_input}"
270
+ return base if store.size == 0
271
+
272
+ findings_text = store.read_all
273
+ .map { |f| "- [#{f[:agent]} / cycle #{f[:cycle]}] #{f[:content]}" }
274
+ .join("\n")
275
+
276
+ "#{header}\n\nTask: #{original_input}\n\nFindings so far:\n#{findings_text}"
277
+ end
278
+
279
+ # Builds the default tool-usage guide for member agents.
280
+ # Describes +read_store+ / +write_finding+ and the required workflow.
281
+ # When extra_tools are present, lists their names so the agent knows
282
+ # what additional tools are available.
283
+ def default_coordination_guide(extra_tools)
284
+ extra_line = if extra_tools.any?
285
+ tool_names = extra_tools.map { |t| t.respond_to?(:tool_name) ? t.tool_name : t.name.to_s }.join(", ")
286
+ " You also have access to additional tools (#{tool_names}) — use them to gather information before writing findings.\n"
287
+ else
288
+ ""
289
+ end
290
+
291
+ <<~TEXT.chomp
292
+ You have access to a shared knowledge store via two tools:
293
+ read_store — returns all current findings as JSON (no parameters)
294
+ write_finding — records one finding to the store (param: content)
295
+ #{extra_line}Required workflow: first call read_store, then call write_finding once per insight.
296
+ Each call to write_finding must contain exactly one unique insight — do not call it twice with the same content.
297
+ If you have no new insights to contribute, call write_finding exactly once with: "No new findings in this cycle."
298
+ Do not output plain text — every insight must be submitted via write_finding.
299
+ TEXT
300
+ end
301
+ end
302
+ end
303
+ end