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 +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +80 -12
- data/lib/phronomy/agent/base.rb +8 -3
- data/lib/phronomy/agent/orchestrator.rb +119 -0
- data/lib/phronomy/agent/shared_state.rb +303 -0
- data/lib/phronomy/agent/team_coordinator.rb +285 -0
- data/lib/phronomy/{trust_pipeline.rb → generator_verifier.rb} +95 -108
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +41 -22
- data/lib/phronomy.rb +17 -0
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5d8907f1d87037c09f78f63dd8ddbd21605536b1aa53c2d43e52be4a6ab6791
|
|
4
|
+
data.tar.gz: 37260258f4ece53e6e5b748cb2236f9c99f6f7e5c754fb0c2d72c415b0137386
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
| **
|
|
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
|
-
###
|
|
232
|
+
### GeneratorVerifier — Generator-Verifier loop with custom prompt builders
|
|
230
233
|
|
|
231
234
|
```ruby
|
|
232
|
-
pipeline = Phronomy::
|
|
233
|
-
draft_agent:
|
|
234
|
-
review_agent:
|
|
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
|
|
241
|
-
puts result.trusted?
|
|
242
|
-
puts result.confidence
|
|
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
|
-
|
|
245
|
-
|
|
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/` |
|
|
503
|
+
| 19 | `19_trust_pipeline/` | Generator-Verifier pattern with citation tracking, self-review loop and confidence gate |
|
|
436
504
|
|
|
437
505
|
## Development
|
|
438
506
|
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -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
|
-
|
|
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
|