phronomy 0.15.0 → 0.16.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 +107 -18
- data/README.md +300 -75
- data/benchmark/bench_agent_invoke.rb +3 -0
- data/benchmark/bench_regression.rb +2 -18
- data/benchmark/bench_tool_schema.rb +1 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +42 -10
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
- data/lib/phronomy/agent/agent_root.rb +67 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -393
- data/lib/phronomy/agent/base.rb +301 -641
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context_assembler.rb +321 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +167 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +34 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
- data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent.rb +14 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +9 -4
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/assembler.rb +8 -8
- data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +11 -0
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -47
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module ContentStore
|
|
7
|
+
class IntegrityError < Phronomy::Error; end
|
|
8
|
+
|
|
9
|
+
class Base
|
|
10
|
+
def put(_bytes, canonicalization_version:) = raise(NotImplementedError)
|
|
11
|
+
def fetch(_content_id) = raise(NotImplementedError)
|
|
12
|
+
def exist?(_content_id) = raise(NotImplementedError)
|
|
13
|
+
|
|
14
|
+
def put_text(text)
|
|
15
|
+
value = String(text).encode(Encoding::UTF_8)
|
|
16
|
+
raise ArgumentError, "invalid UTF-8 text" unless value.valid_encoding?
|
|
17
|
+
|
|
18
|
+
put(value, canonicalization_version: 1)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def put_json(value)
|
|
22
|
+
put(
|
|
23
|
+
Phronomy::CanonicalJSON.dump(value),
|
|
24
|
+
canonicalization_version: Phronomy::CanonicalJSON::VERSION
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fetch_text(content_id)
|
|
29
|
+
value = fetch(content_id).force_encoding(Encoding::UTF_8)
|
|
30
|
+
unless value.valid_encoding?
|
|
31
|
+
raise IntegrityError, "content is not UTF-8: #{content_id}"
|
|
32
|
+
end
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch_json(content_id)
|
|
37
|
+
Phronomy::CanonicalJSON.load(fetch(content_id))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fetch_many(content_ids)
|
|
41
|
+
Array(content_ids).uniq.to_h { |content_id| [content_id, fetch(content_id)] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def content_id_for(bytes)
|
|
47
|
+
"sha256:#{Digest::SHA256.hexdigest(bytes)}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Raised when mandatory context content (system instructions, tool definitions,
|
|
5
|
+
# current input) exhausts the model's context window, leaving no room for
|
|
6
|
+
# prior conversation history.
|
|
7
|
+
class ContextBudgetExceededError < Error; end
|
|
8
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Raised when output token reserve cannot be determined at budget construction
|
|
5
|
+
# time (e.g. no explicit max_output_tokens set and registry value equals the
|
|
6
|
+
# context window).
|
|
7
|
+
class InvalidContextBudgetConfigurationError < Error; end
|
|
8
|
+
end
|
|
@@ -14,9 +14,10 @@ module Phronomy
|
|
|
14
14
|
# 4. Conversation — historical messages added via #add_messages
|
|
15
15
|
#
|
|
16
16
|
# Token budgeting:
|
|
17
|
-
# When a budget is given,
|
|
18
|
-
#
|
|
19
|
-
#
|
|
17
|
+
# When a budget is given, Assembler validates that the supplied conversation
|
|
18
|
+
# messages fit after instruction, knowledge, and capability costs are
|
|
19
|
+
# accounted for. It does not prune messages; Manifest-first Context Policy
|
|
20
|
+
# is responsible for context selection.
|
|
20
21
|
# Knowledge chunks are always included in full (they are assumed to be
|
|
21
22
|
# pre-screened by the caller). When no budget is given all messages are
|
|
22
23
|
# passed through unchanged.
|
|
@@ -44,7 +45,7 @@ module Phronomy
|
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
# @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
|
|
47
|
-
# when nil no
|
|
48
|
+
# when nil no budget validation is performed
|
|
48
49
|
# @api private
|
|
49
50
|
# mutant:disable - @instruction = nil deletion is a genuine equivalent (uninitialized Ruby instance variables return nil)
|
|
50
51
|
def initialize(budget: nil)
|
|
@@ -57,7 +58,7 @@ module Phronomy
|
|
|
57
58
|
|
|
58
59
|
# Register tool classes (Region 2).
|
|
59
60
|
# Estimates their token cost and deducts it from the budget so that
|
|
60
|
-
#
|
|
61
|
+
# budget estimation accounts for tool definition overhead.
|
|
61
62
|
#
|
|
62
63
|
# @param tool_classes [Array<Class, Object>] tool classes or instances
|
|
63
64
|
# @return [self]
|
|
@@ -131,8 +132,7 @@ module Phronomy
|
|
|
131
132
|
# Raises {Phronomy::ContextLengthError} when a budget is set and the
|
|
132
133
|
# conversation messages do not fit within the remaining token allowance.
|
|
133
134
|
# No automatic trimming is performed — callers must pre-process messages
|
|
134
|
-
#
|
|
135
|
-
# passing them to the Assembler.
|
|
135
|
+
# before passing them to the Assembler.
|
|
136
136
|
#
|
|
137
137
|
# mutant:disable - multiple genuine equivalent mutations: map{}.join("\n\n") → map{} is genuine; `unless knowledge_text.empty?` vs ternary is genuine; `{ system: unless system_text.empty? }` vs ternary is genuine; `messages:` shorthand vs `messages: messages` is genuine
|
|
138
138
|
def build
|
|
@@ -149,7 +149,7 @@ module Phronomy
|
|
|
149
149
|
raise Phronomy::ContextLengthError,
|
|
150
150
|
"Context exceeds token budget: messages require #{msg_tokens} tokens but " \
|
|
151
151
|
"only #{remaining} available (context_window=#{@budget.context_window}, " \
|
|
152
|
-
"used_by_system=#{used}).
|
|
152
|
+
"used_by_system=#{used}). Use the Context Policy path to manage message budget."
|
|
153
153
|
end
|
|
154
154
|
end
|
|
155
155
|
|
|
@@ -42,6 +42,7 @@ module Phronomy
|
|
|
42
42
|
# @example Fan-out (same agent, multiple inputs)
|
|
43
43
|
# results = fan_out(agent: TranslationAgent, inputs: ["Hello", "World"])
|
|
44
44
|
class Orchestrator < Agent::Base
|
|
45
|
+
agent_definition id: "orchestrator", version: 1
|
|
45
46
|
# Declares a named subagent and registers it as a tool accessible to the
|
|
46
47
|
# LLM during an +invoke+ call.
|
|
47
48
|
#
|
|
@@ -31,16 +31,18 @@ module Phronomy
|
|
|
31
31
|
return super if tool_calls.size <= 1
|
|
32
32
|
|
|
33
33
|
if @on[:tool_call_batch]
|
|
34
|
-
tool_calls.each {
|
|
34
|
+
tool_calls.each { run_callbacks(:before_message, :new_message) }
|
|
35
35
|
@on[:tool_call_batch].call(tool_calls)
|
|
36
36
|
return
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Direct ParallelToolChat fallback. Agent execution never reaches this
|
|
40
40
|
# branch because AgentInvocation installs the batch interceptor first.
|
|
41
|
+
# RubyLLM >= 1.15 additive callbacks are dispatched together with their
|
|
42
|
+
# legacy equivalents, matching RubyLLM::Chat semantics.
|
|
41
43
|
tool_calls.each do |tool_call|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
run_callbacks(:before_message, :new_message)
|
|
45
|
+
run_callbacks(:before_tool_call, :tool_call, tool_call)
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
cancellation_token = @cancellation_token
|
|
@@ -78,7 +80,7 @@ module Phronomy
|
|
|
78
80
|
halt_result = nil
|
|
79
81
|
tool_results.each do |item|
|
|
80
82
|
result = item[:result]
|
|
81
|
-
|
|
83
|
+
run_callbacks(:after_tool_result, :tool_result, result)
|
|
82
84
|
tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
|
|
83
85
|
content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
|
|
84
86
|
message = add_message(
|
|
@@ -86,7 +88,7 @@ module Phronomy
|
|
|
86
88
|
content: content,
|
|
87
89
|
tool_call_id: item[:tool_call].id
|
|
88
90
|
)
|
|
89
|
-
|
|
91
|
+
run_callbacks(:after_message, :end_message, message)
|
|
90
92
|
halt_result = result if result.is_a?(RubyLLM::Tool::Halt)
|
|
91
93
|
end
|
|
92
94
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Phronomy
|
|
4
6
|
module MultiAgent
|
|
5
7
|
# Implements the "Agent teams" coordination pattern (Anthropic blog, Pattern 3).
|
|
@@ -212,8 +214,9 @@ module Phronomy
|
|
|
212
214
|
worker = scheduler ? scheduler.call(available) : default_scheduler(available)
|
|
213
215
|
|
|
214
216
|
begin
|
|
215
|
-
|
|
216
|
-
|
|
217
|
+
# Worker agent retains conversation history in its Journal.
|
|
218
|
+
result = worker.agent.invoke(task[:description])
|
|
219
|
+
worker.messages = Array(result[:messages])
|
|
217
220
|
worker.status = :available
|
|
218
221
|
entry = {task: task, result: result[:output], worker: worker.index, error: nil}
|
|
219
222
|
assignments << entry
|
|
@@ -253,6 +256,7 @@ module Phronomy
|
|
|
253
256
|
finalize_tool = build_finalize_tool(task_queue)
|
|
254
257
|
|
|
255
258
|
coordinator_class = Class.new(Phronomy::Agent::Base) do
|
|
259
|
+
agent_definition id: "team-coordinator-#{SecureRandom.hex(4)}", version: 1
|
|
256
260
|
model coordinator_model_val
|
|
257
261
|
provider coordinator_provider_val if coordinator_provider_val
|
|
258
262
|
instructions coordinator_instructions_val
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Phronomy
|
|
7
|
+
class Persistence
|
|
8
|
+
class InMemory < Persistence
|
|
9
|
+
class Contents < Phronomy::ContentStore::Base
|
|
10
|
+
def initialize(owner)
|
|
11
|
+
@owner = owner
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def put(bytes, canonicalization_version:)
|
|
15
|
+
value = String(bytes).b.freeze
|
|
16
|
+
id = content_id_for(value)
|
|
17
|
+
@owner.synchronize do
|
|
18
|
+
current = @owner.state[:contents][id]
|
|
19
|
+
if current && current[:bytes] != value
|
|
20
|
+
raise Phronomy::ContentStore::IntegrityError, "digest collision for #{id}"
|
|
21
|
+
end
|
|
22
|
+
@owner.state[:contents][id] ||= {
|
|
23
|
+
bytes: value,
|
|
24
|
+
canonicalization_version: Integer(canonicalization_version)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
id
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch(content_id)
|
|
31
|
+
@owner.synchronize do
|
|
32
|
+
record = @owner.state[:contents].fetch(content_id.to_s) do
|
|
33
|
+
raise NotFoundError, "content not found: #{content_id}"
|
|
34
|
+
end
|
|
35
|
+
bytes = record[:bytes]
|
|
36
|
+
unless content_id_for(bytes) == content_id.to_s
|
|
37
|
+
raise Phronomy::ContentStore::IntegrityError, "content digest mismatch: #{content_id}"
|
|
38
|
+
end
|
|
39
|
+
bytes.dup
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def exist?(content_id)
|
|
44
|
+
@owner.synchronize { @owner.state[:contents].key?(content_id.to_s) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class Agents
|
|
49
|
+
def initialize(owner) = @owner = owner
|
|
50
|
+
|
|
51
|
+
def create(root)
|
|
52
|
+
@owner.synchronize do
|
|
53
|
+
key = root.agent_id.to_s
|
|
54
|
+
raise ConflictError, "agent_id must not be empty" if key.empty?
|
|
55
|
+
raise ConflictError, "agent already exists: #{key}" if @owner.state[:agents].key?(key)
|
|
56
|
+
@owner.state[:agents][key] = root
|
|
57
|
+
end
|
|
58
|
+
root
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load(agent_id)
|
|
62
|
+
@owner.synchronize do
|
|
63
|
+
@owner.state[:agents].fetch(agent_id.to_s) { raise NotFoundError, "agent not found: #{agent_id}" }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def save(agent_id, expected_revision:, root:)
|
|
68
|
+
@owner.synchronize do
|
|
69
|
+
current = load(agent_id)
|
|
70
|
+
unless current.agent_revision == expected_revision
|
|
71
|
+
raise ConflictError,
|
|
72
|
+
"agent revision conflict: expected #{expected_revision}, actual #{current.agent_revision}"
|
|
73
|
+
end
|
|
74
|
+
unless root.agent_id.to_s == agent_id.to_s
|
|
75
|
+
raise ConflictError, "Agent root identity mismatch: #{root.agent_id} != #{agent_id}"
|
|
76
|
+
end
|
|
77
|
+
unless root.agent_revision == expected_revision + 1
|
|
78
|
+
raise ConflictError,
|
|
79
|
+
"agent save must advance revision exactly once: " \
|
|
80
|
+
"expected #{expected_revision + 1}, got #{root.agent_revision}"
|
|
81
|
+
end
|
|
82
|
+
@owner.state[:agents][agent_id.to_s] = root
|
|
83
|
+
end
|
|
84
|
+
root
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def delete(agent_id)
|
|
88
|
+
@owner.synchronize { @owner.state[:agents].delete(agent_id.to_s) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class Journals
|
|
93
|
+
def initialize(owner) = @owner = owner
|
|
94
|
+
|
|
95
|
+
def append(agent_id, expected_position:, records:)
|
|
96
|
+
@owner.synchronize do
|
|
97
|
+
target = (@owner.state[:journals][agent_id.to_s] ||= [])
|
|
98
|
+
unless target.length == expected_position
|
|
99
|
+
raise ConflictError,
|
|
100
|
+
"journal position conflict: expected #{expected_position}, actual #{target.length}"
|
|
101
|
+
end
|
|
102
|
+
existing_ids = target.to_h { |record| [record.record_id, true] }
|
|
103
|
+
incoming_ids = {}
|
|
104
|
+
appended = Array(records).each_with_index.map do |record, index|
|
|
105
|
+
unless record.agent_id.to_s == agent_id.to_s
|
|
106
|
+
raise ConflictError,
|
|
107
|
+
"Journal record Agent mismatch: #{record.agent_id} != #{agent_id}"
|
|
108
|
+
end
|
|
109
|
+
if existing_ids[record.record_id] || incoming_ids[record.record_id]
|
|
110
|
+
raise ConflictError, "duplicate Journal record_id: #{record.record_id}"
|
|
111
|
+
end
|
|
112
|
+
incoming_ids[record.record_id] = true
|
|
113
|
+
record.with_sequence(expected_position + index + 1)
|
|
114
|
+
end
|
|
115
|
+
target.concat(appended)
|
|
116
|
+
appended.freeze
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def read(agent_id, after: nil, limit: nil)
|
|
121
|
+
@owner.synchronize do
|
|
122
|
+
result = Array(@owner.state[:journals][agent_id.to_s])
|
|
123
|
+
result = result.drop(Integer(after)) if after
|
|
124
|
+
result = result.first(limit) if limit
|
|
125
|
+
result.dup.freeze
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def head(agent_id)
|
|
130
|
+
@owner.synchronize { Array(@owner.state[:journals][agent_id.to_s]).length }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def delete(agent_id)
|
|
134
|
+
@owner.synchronize { @owner.state[:journals].delete(agent_id.to_s) }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
class Executions
|
|
139
|
+
def initialize(owner) = @owner = owner
|
|
140
|
+
|
|
141
|
+
def create_active(execution)
|
|
142
|
+
@owner.synchronize do
|
|
143
|
+
if @owner.state[:executions].key?(execution.execution_id.to_s)
|
|
144
|
+
raise ConflictError, "execution already exists: #{execution.execution_id}"
|
|
145
|
+
end
|
|
146
|
+
active = @owner.state[:executions].values.find do |candidate|
|
|
147
|
+
candidate.agent_id == execution.agent_id && candidate.active?
|
|
148
|
+
end
|
|
149
|
+
raise Phronomy::AgentBusyError, "agent is busy: #{execution.agent_id}" if active
|
|
150
|
+
@owner.state[:executions][execution.execution_id] = execution
|
|
151
|
+
end
|
|
152
|
+
execution
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def load(execution_id)
|
|
156
|
+
@owner.synchronize do
|
|
157
|
+
@owner.state[:executions].fetch(execution_id.to_s) do
|
|
158
|
+
raise NotFoundError, "execution not found: #{execution_id}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def save(execution_id, expected_revision:, execution:)
|
|
164
|
+
@owner.synchronize do
|
|
165
|
+
current = load(execution_id)
|
|
166
|
+
unless current.execution_revision == expected_revision
|
|
167
|
+
raise ConflictError,
|
|
168
|
+
"execution revision conflict: expected #{expected_revision}, actual #{current.execution_revision}"
|
|
169
|
+
end
|
|
170
|
+
unless execution.execution_id.to_s == execution_id.to_s
|
|
171
|
+
raise ConflictError,
|
|
172
|
+
"Execution identity mismatch: #{execution.execution_id} != #{execution_id}"
|
|
173
|
+
end
|
|
174
|
+
unless execution.execution_revision == expected_revision + 1
|
|
175
|
+
raise ConflictError,
|
|
176
|
+
"execution save must advance revision exactly once: " \
|
|
177
|
+
"expected #{expected_revision + 1}, got #{execution.execution_revision}"
|
|
178
|
+
end
|
|
179
|
+
@owner.state[:executions][execution_id.to_s] = execution
|
|
180
|
+
end
|
|
181
|
+
execution
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def list_active(agent_id)
|
|
185
|
+
@owner.synchronize do
|
|
186
|
+
@owner.state[:executions].values.select do |execution|
|
|
187
|
+
execution.agent_id == agent_id.to_s && execution.active?
|
|
188
|
+
end.freeze
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def delete(execution_id)
|
|
193
|
+
@owner.synchronize { @owner.state[:executions].delete(execution_id.to_s) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def delete_for_agent(agent_id)
|
|
197
|
+
@owner.synchronize do
|
|
198
|
+
@owner.state[:executions].delete_if { |_id, execution| execution.agent_id == agent_id.to_s }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Raises AgentBusyError if there is an active execution for agent_id.
|
|
203
|
+
# Must be called from within a transaction (monitor already held).
|
|
204
|
+
def assert_idle!(agent_id)
|
|
205
|
+
active = @owner.state[:executions].values.find do |candidate|
|
|
206
|
+
candidate.agent_id == agent_id.to_s && candidate.active?
|
|
207
|
+
end
|
|
208
|
+
raise Phronomy::AgentBusyError, "agent has an active or suspended execution: #{agent_id}" if active
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
attr_reader :state
|
|
213
|
+
|
|
214
|
+
def initialize
|
|
215
|
+
@monitor = Monitor.new
|
|
216
|
+
@state = {contents: {}, agents: {}, journals: {}, executions: {}}
|
|
217
|
+
@contents = Contents.new(self)
|
|
218
|
+
@agents = Agents.new(self)
|
|
219
|
+
@journals = Journals.new(self)
|
|
220
|
+
@executions = Executions.new(self)
|
|
221
|
+
@activations = Phronomy::Agent::ActivationRegistry.new
|
|
222
|
+
super(contents: @contents, agents: @agents, journals: @journals,
|
|
223
|
+
executions: @executions, activations: @activations)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def capabilities
|
|
227
|
+
{atomic_all: true, atomic_admission: true, optimistic_revision: true}.freeze
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def transaction
|
|
231
|
+
synchronize do
|
|
232
|
+
snapshot = Marshal.load(Marshal.dump(@state))
|
|
233
|
+
begin
|
|
234
|
+
yield self
|
|
235
|
+
rescue
|
|
236
|
+
@state = snapshot
|
|
237
|
+
raise
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def synchronize(&block)
|
|
243
|
+
@monitor.synchronize(&block)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Persistence
|
|
5
|
+
class ConflictError < Phronomy::Error; end
|
|
6
|
+
class NotFoundError < Phronomy::Error; end
|
|
7
|
+
class UnsupportedBackendError < Phronomy::Error; end
|
|
8
|
+
|
|
9
|
+
attr_reader :contents, :agents, :journals, :executions, :activations
|
|
10
|
+
|
|
11
|
+
def initialize(contents:, agents:, journals:, executions:, activations:)
|
|
12
|
+
@contents = contents
|
|
13
|
+
@agents = agents
|
|
14
|
+
@journals = journals
|
|
15
|
+
@executions = executions
|
|
16
|
+
@activations = activations
|
|
17
|
+
validate_capabilities!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def capabilities
|
|
21
|
+
{atomic_all: false, atomic_admission: false}.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def transaction
|
|
25
|
+
raise UnsupportedBackendError, "#{self.class} does not provide atomic_all"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_capabilities!
|
|
31
|
+
required = {atomic_all: true, atomic_admission: true}
|
|
32
|
+
missing = required.reject { |key, value| capabilities[key] == value }
|
|
33
|
+
return if missing.empty?
|
|
34
|
+
|
|
35
|
+
raise UnsupportedBackendError,
|
|
36
|
+
"Persistence backend lacks required capabilities: #{missing.keys.join(", ")}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/phronomy/tools/agent.rb
CHANGED
|
@@ -3,69 +3,47 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Tools
|
|
5
5
|
# Wraps a Phronomy::Agent::Base subclass as a callable tool so that a parent
|
|
6
|
-
# agent can delegate sub-
|
|
7
|
-
#
|
|
8
|
-
# Use Agent.from_agent to generate a concrete tool class. The generated
|
|
9
|
-
# class is anonymous; assign it to a constant when you need a stable name.
|
|
10
|
-
#
|
|
11
|
-
# @example Wrap an existing agent
|
|
12
|
-
# SummarizerTool = Phronomy::Tools::Agent.from_agent(
|
|
13
|
-
# SummarizerAgent,
|
|
14
|
-
# tool_name: "summarize",
|
|
15
|
-
# description: "Summarizes a long text and returns a brief summary"
|
|
16
|
-
# )
|
|
17
|
-
#
|
|
18
|
-
# class OrchestratorAgent < Phronomy::Agent::Base
|
|
19
|
-
# model "openai/gpt-4o-mini"
|
|
20
|
-
# instructions "You are an orchestrator that delegates to specialist agents."
|
|
21
|
-
# tools SummarizerTool
|
|
22
|
-
# end
|
|
6
|
+
# agent can delegate a one-shot sub-task through the same stateful pipeline.
|
|
23
7
|
class Agent < Phronomy::Agent::Context::Capability::Base
|
|
24
8
|
description "Wraps an agent as a tool"
|
|
25
9
|
param :input, type: :string, desc: "The input to forward to the wrapped agent"
|
|
26
10
|
|
|
27
11
|
class << self
|
|
28
|
-
# Generates a Phronomy::Tools::Agent subclass that delegates #execute to
|
|
29
|
-
# an instance of +agent_class+.
|
|
30
|
-
#
|
|
31
|
-
# @param agent_class [Class] a Phronomy::Agent::Base subclass
|
|
32
|
-
# @param tool_name [String, nil] function name exposed to the LLM;
|
|
33
|
-
# defaults to a snake_case derivation of the agent class name
|
|
34
|
-
# @param description [String, nil] description exposed to the LLM;
|
|
35
|
-
# defaults to "Delegates to <AgentClassName>"
|
|
36
|
-
# @return [Class] an anonymous Phronomy::Tools::Agent subclass
|
|
37
|
-
# @api public
|
|
38
12
|
def from_agent(agent_class, tool_name: nil, description: nil)
|
|
39
13
|
raise ArgumentError, "agent_class must be a Class" unless agent_class.is_a?(Class)
|
|
14
|
+
unless agent_class <= Phronomy::Agent::Base
|
|
15
|
+
raise ArgumentError,
|
|
16
|
+
"agent_class must inherit from Phronomy::Agent::Base"
|
|
17
|
+
end
|
|
40
18
|
|
|
41
|
-
|
|
19
|
+
# Fail at Tool definition time rather than on the first Tool call.
|
|
20
|
+
agent_class.agent_definition
|
|
42
21
|
|
|
22
|
+
klass = Class.new(self)
|
|
43
23
|
effective_name = tool_name || derive_name(agent_class)
|
|
44
24
|
effective_desc = description || "Delegates to #{agent_class.name || "an agent"}"
|
|
45
25
|
|
|
46
26
|
klass.tool_name(effective_name)
|
|
47
27
|
klass.description(effective_desc)
|
|
48
|
-
|
|
49
28
|
klass.define_method(:execute) do |input:|
|
|
50
|
-
result =
|
|
29
|
+
result = Phronomy::Agent.run_once(
|
|
30
|
+
definition: agent_class,
|
|
31
|
+
input: input
|
|
32
|
+
)
|
|
51
33
|
result[:output].to_s
|
|
52
34
|
end
|
|
53
|
-
|
|
54
35
|
klass
|
|
55
36
|
end
|
|
56
37
|
|
|
57
38
|
private
|
|
58
39
|
|
|
59
|
-
# Derives a snake_case tool name from the agent class name.
|
|
60
|
-
# e.g. "My::SummarizerAgent" → "summarizer"
|
|
61
|
-
# "TranslatorAgent" → "translator"
|
|
62
40
|
def derive_name(agent_class)
|
|
63
41
|
return "agent_tool" unless agent_class.name
|
|
64
42
|
|
|
65
43
|
agent_class.name
|
|
66
44
|
.split("::").last
|
|
67
|
-
.gsub(/([A-Z]+)([A-Z][a-z])/, '
|
|
68
|
-
.gsub(/([a-z
|
|
45
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
|
|
46
|
+
.gsub(/([a-z\\d])([A-Z])/, '\\1_\\2')
|
|
69
47
|
.downcase
|
|
70
48
|
.sub(/_agent$/, "")
|
|
71
49
|
.sub(/_tool$/, "")
|
data/lib/phronomy/version.rb
CHANGED
data/lib/phronomy.rb
CHANGED
|
@@ -20,6 +20,17 @@ loader.inflector.inflect("llm_adapter" => "LLMAdapter")
|
|
|
20
20
|
loader.inflector.inflect("llm_operation_result" => "LLMOperationResult")
|
|
21
21
|
# LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
|
|
22
22
|
loader.inflector.inflect("ruby_llm" => "RubyLLM")
|
|
23
|
+
# CanonicalJSON: preserve uppercase JSON acronym.
|
|
24
|
+
loader.inflector.inflect("canonical_json" => "CanonicalJSON")
|
|
25
|
+
# RubyLLMMaterializer: preserve the double-uppercase LLM acronym.
|
|
26
|
+
loader.inflector.inflect("ruby_llm_materializer" => "RubyLLMMaterializer")
|
|
27
|
+
# LLMCallRecord: preserve uppercase LLM acronym.
|
|
28
|
+
loader.inflector.inflect("llm_call_record" => "LLMCallRecord")
|
|
29
|
+
# LLMInputManifest: preserve uppercase LLM acronym.
|
|
30
|
+
loader.inflector.inflect("llm_input_manifest" => "LLMInputManifest")
|
|
31
|
+
# LLMInputBuildContext / LLMInputPatch: preserve uppercase LLM acronym.
|
|
32
|
+
loader.inflector.inflect("llm_input_build_context" => "LLMInputBuildContext")
|
|
33
|
+
loader.inflector.inflect("llm_input_patch" => "LLMInputPatch")
|
|
23
34
|
# Collapse engine/ so that its contents autoload directly under Phronomy::
|
|
24
35
|
# (no Engine:: prefix). e.g. engine/event_loop.rb => Phronomy::EventLoop.
|
|
25
36
|
# This allows the execution engine to be organised in its own subdirectory
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Adds to_h to all Tokens-like doubles so ExecutionCoordinator can serialize usage.
|
|
5
|
+
|
|
6
|
+
files = Dir.glob("spec/**/*.rb").sort
|
|
7
|
+
|
|
8
|
+
total = 0
|
|
9
|
+
files.each do |f|
|
|
10
|
+
content = File.read(f)
|
|
11
|
+
modified = content.gsub(
|
|
12
|
+
/double\(("[^"]+"),\s*(input: \d+, output: \d+, cached: \d+, cache_creation: \d+)\)/
|
|
13
|
+
) do
|
|
14
|
+
name = $1
|
|
15
|
+
attrs = $2
|
|
16
|
+
next $& if $&.include?("to_h")
|
|
17
|
+
|
|
18
|
+
h_entries = attrs.split(",").map(&:strip).map do |pair|
|
|
19
|
+
k, v = pair.split(":").map(&:strip)
|
|
20
|
+
"\"#{k}\" => #{v}"
|
|
21
|
+
end.join(", ")
|
|
22
|
+
|
|
23
|
+
total += 1
|
|
24
|
+
"double(#{name}, #{attrs}, to_h: {#{h_entries}})"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if modified != content
|
|
28
|
+
File.write(f, modified)
|
|
29
|
+
puts " patched: #{f}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
puts "\nAdded to_h to #{total} Tokens doubles."
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Adds to_h to unnamed (no-string-name) Tokens doubles like double(input: 5, ...)
|
|
5
|
+
|
|
6
|
+
files = Dir.glob("spec/**/*.rb").sort
|
|
7
|
+
|
|
8
|
+
total = 0
|
|
9
|
+
files.each do |f|
|
|
10
|
+
content = File.read(f)
|
|
11
|
+
modified = content.gsub(
|
|
12
|
+
/double\((input: (\d+), output: (\d+), cached: (\d+), cache_creation: (\d+))\)/
|
|
13
|
+
) do
|
|
14
|
+
attrs, a, b, c, d = $1, $2, $3, $4, $5
|
|
15
|
+
next $& if $&.include?("to_h")
|
|
16
|
+
|
|
17
|
+
total += 1
|
|
18
|
+
"double(#{attrs}, to_h: {\"input\" => #{a}, \"output\" => #{b}, \"cached\" => #{c}, \"cache_creation\" => #{d}})"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if modified != content
|
|
22
|
+
File.write(f, modified)
|
|
23
|
+
puts " patched: #{f}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
puts "\nAdded to_h to #{total} unnamed Tokens doubles."
|