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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +54 -0
- data/CONTRIBUTING.md +28 -16
- data/README.md +124 -92
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +4 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +1 -1
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/base.rb +135 -314
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +65 -29
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
- data/lib/phronomy/agent/context_plan_validator.rb +0 -33
- data/lib/phronomy/agent/execution_coordinator.rb +0 -1
- data/lib/phronomy/agent/journal_projection.rb +28 -2
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +15 -158
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
- data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +3 -120
- data/scripts/api_snapshot.rb +1 -12
- metadata +3 -9
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- 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
|
|
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(
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 { |
|
|
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
|
|
297
|
-
|
|
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
|
-
#
|
|
47
|
-
#
|
|
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({})
|
|
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
|