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,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class RubyLLMMaterializer
|
|
6
|
+
RuntimeProjection = Data.define(
|
|
7
|
+
:system, :messages, :tool_classes, :ask_message,
|
|
8
|
+
:model_config, :manifest, :manifest_ref
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
def initialize(agent:, persistence:)
|
|
12
|
+
@agent = agent
|
|
13
|
+
@persistence = persistence
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def materialize(manifest:, manifest_ref:)
|
|
17
|
+
verify_manifest_identity!(manifest, manifest_ref)
|
|
18
|
+
verify_adapter!(manifest)
|
|
19
|
+
tool_set = verify_tool_definitions!(manifest)
|
|
20
|
+
model_config = fetch_json(manifest.model_config_ref)
|
|
21
|
+
system_parts = []
|
|
22
|
+
message_segments = []
|
|
23
|
+
ask_message = nil
|
|
24
|
+
|
|
25
|
+
manifest.segments.each do |segment|
|
|
26
|
+
case segment.delivery
|
|
27
|
+
when :ask_argument
|
|
28
|
+
ask_message = @persistence.contents.fetch_text(segment.content_ref)
|
|
29
|
+
when :chat_message
|
|
30
|
+
if segment.role == :system
|
|
31
|
+
system_parts << @persistence.contents.fetch_text(segment.content_ref)
|
|
32
|
+
else
|
|
33
|
+
message_segments << segment
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
raise ArgumentError, "unknown manifest delivery: #{segment.delivery.inspect}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
RuntimeProjection.new(
|
|
41
|
+
system: system_parts.empty? ? nil : system_parts.join("\n\n"),
|
|
42
|
+
messages: materialize_message_segments(message_segments).freeze,
|
|
43
|
+
tool_classes: tool_set.runtime_tools,
|
|
44
|
+
ask_message: ask_message,
|
|
45
|
+
model_config: Immutable.copy(model_config),
|
|
46
|
+
manifest: manifest,
|
|
47
|
+
manifest_ref: manifest_ref
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def materialize_journal_record(record)
|
|
52
|
+
materialize_segment(segment_from_record(record))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def materialize_journal_records(records)
|
|
56
|
+
segments = Array(records).each_with_index.map do |record, index|
|
|
57
|
+
segment_from_record(record, position: index)
|
|
58
|
+
end
|
|
59
|
+
materialize_message_segments(segments).freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def segment_from_record(record, position: 0)
|
|
65
|
+
LLMInputManifest::Segment.new(
|
|
66
|
+
position: position,
|
|
67
|
+
category: record.kind,
|
|
68
|
+
role: record.role,
|
|
69
|
+
content_ref: record.content_ref,
|
|
70
|
+
delivery: :chat_message,
|
|
71
|
+
tool_call_id: record.metadata["tool_call_id"] || record.metadata[:tool_call_id],
|
|
72
|
+
metadata: record.metadata.merge(
|
|
73
|
+
"journal_record_id" => record.record_id,
|
|
74
|
+
"journal_sequence" => record.sequence,
|
|
75
|
+
"llm_call_id" => record.llm_call_id
|
|
76
|
+
).compact
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def verify_manifest_identity!(manifest, manifest_ref)
|
|
81
|
+
expected = @persistence.contents.fetch(manifest_ref)
|
|
82
|
+
actual = Phronomy::CanonicalJSON.dump(manifest.to_h)
|
|
83
|
+
return if expected == actual
|
|
84
|
+
|
|
85
|
+
raise Phronomy::ContentStore::IntegrityError,
|
|
86
|
+
"manifest content does not match manifest_ref: #{manifest_ref}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def verify_adapter!(manifest)
|
|
90
|
+
actual = Phronomy.configuration.llm_adapter.class.name
|
|
91
|
+
return if manifest.adapter_name.nil? || manifest.adapter_name == actual
|
|
92
|
+
|
|
93
|
+
raise Phronomy::ConfigurationError,
|
|
94
|
+
"LLM adapter changed after manifest creation: #{manifest.adapter_name} -> #{actual}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def verify_tool_definitions!(manifest)
|
|
98
|
+
tool_set = ToolDefinitionSet.build(@agent)
|
|
99
|
+
return tool_set unless manifest.tool_definitions_ref
|
|
100
|
+
|
|
101
|
+
expected = @persistence.contents.fetch(manifest.tool_definitions_ref)
|
|
102
|
+
actual = Phronomy::CanonicalJSON.dump(tool_set.definitions)
|
|
103
|
+
unless expected == actual
|
|
104
|
+
raise Phronomy::ConfigurationError,
|
|
105
|
+
"Agent tool definitions changed after manifest creation"
|
|
106
|
+
end
|
|
107
|
+
tool_set
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fetch_json(content_ref)
|
|
111
|
+
Phronomy::CanonicalJSON.load(@persistence.contents.fetch(content_ref))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def materialize_message_segments(segments)
|
|
115
|
+
result = []
|
|
116
|
+
index = 0
|
|
117
|
+
while index < segments.length
|
|
118
|
+
segment = segments[index]
|
|
119
|
+
if legacy_assistant_segment?(segment)
|
|
120
|
+
grouped, consumed = legacy_assistant_group(segments, index)
|
|
121
|
+
result << materialize_legacy_assistant_group(grouped)
|
|
122
|
+
index += consumed
|
|
123
|
+
else
|
|
124
|
+
result << materialize_segment(segment)
|
|
125
|
+
index += 1
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# New canonical records preserve one logical assistant/tool message per
|
|
132
|
+
# record. These legacy helpers remain only so already-persisted state from
|
|
133
|
+
# the preceding stateful refactor can still be read during migration.
|
|
134
|
+
def legacy_assistant_segment?(segment)
|
|
135
|
+
segment.role&.to_sym == :assistant &&
|
|
136
|
+
%i[llm_message tool_call].include?(segment.category.to_sym)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def legacy_assistant_group(segments, start_index)
|
|
140
|
+
first = segments.fetch(start_index)
|
|
141
|
+
llm_call_id = metadata_value(first, "llm_call_id")
|
|
142
|
+
return legacy_runtime_assistant_group(segments, start_index, llm_call_id) if llm_call_id
|
|
143
|
+
|
|
144
|
+
legacy_import_assistant_group(segments, start_index)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def legacy_runtime_assistant_group(segments, start_index, llm_call_id)
|
|
148
|
+
group = []
|
|
149
|
+
index = start_index
|
|
150
|
+
while (segment = segments[index]) && legacy_assistant_segment?(segment) &&
|
|
151
|
+
metadata_value(segment, "llm_call_id").to_s == llm_call_id.to_s
|
|
152
|
+
group << segment
|
|
153
|
+
index += 1
|
|
154
|
+
end
|
|
155
|
+
[group, group.length]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def legacy_import_assistant_group(segments, start_index)
|
|
159
|
+
first = segments.fetch(start_index)
|
|
160
|
+
group = [first]
|
|
161
|
+
index = start_index + 1
|
|
162
|
+
last = first
|
|
163
|
+
|
|
164
|
+
while (segment = segments[index]) && legacy_assistant_segment?(segment)
|
|
165
|
+
break unless segment.category.to_sym == :tool_call
|
|
166
|
+
break unless contiguous_source?(last, segment)
|
|
167
|
+
break if metadata_value(segment, "llm_call_id")
|
|
168
|
+
|
|
169
|
+
group << segment
|
|
170
|
+
last = segment
|
|
171
|
+
index += 1
|
|
172
|
+
end
|
|
173
|
+
[group, group.length]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def contiguous_source?(left, right)
|
|
177
|
+
left_sequence = metadata_value(left, "journal_sequence")
|
|
178
|
+
right_sequence = metadata_value(right, "journal_sequence")
|
|
179
|
+
return false unless left_sequence && right_sequence
|
|
180
|
+
|
|
181
|
+
Integer(right_sequence) == Integer(left_sequence) + 1
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def materialize_legacy_assistant_group(segments)
|
|
185
|
+
content_segments = segments.select { |segment| segment.category.to_sym == :llm_message }
|
|
186
|
+
if content_segments.length > 1
|
|
187
|
+
raise ArgumentError, "ambiguous assistant message group contains multiple content records"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
tool_calls = {}
|
|
191
|
+
segments.select { |segment| segment.category.to_sym == :tool_call }.each do |segment|
|
|
192
|
+
payload = fetch_json(segment.content_ref)
|
|
193
|
+
tool_call_id = payload.fetch("id").to_s
|
|
194
|
+
if tool_calls.key?(tool_call_id)
|
|
195
|
+
raise ArgumentError, "duplicate Tool Call in assistant message: #{tool_call_id}"
|
|
196
|
+
end
|
|
197
|
+
tool_calls[tool_call_id] = materialize_tool_call(payload)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
content = if content_segments.empty?
|
|
201
|
+
""
|
|
202
|
+
else
|
|
203
|
+
@persistence.contents.fetch_text(content_segments.first.content_ref)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
RubyLLM::Message.new(
|
|
207
|
+
role: :assistant,
|
|
208
|
+
content: content,
|
|
209
|
+
tool_calls: tool_calls.empty? ? nil : tool_calls
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def materialize_segment(segment)
|
|
214
|
+
case segment.category.to_sym
|
|
215
|
+
when :assistant_message
|
|
216
|
+
materialize_canonical_message(segment, expected_role: :assistant)
|
|
217
|
+
when :tool_message
|
|
218
|
+
materialize_canonical_message(segment, expected_role: :tool)
|
|
219
|
+
when :tool_call
|
|
220
|
+
materialize_legacy_assistant_group([segment])
|
|
221
|
+
when :tool_result
|
|
222
|
+
unless segment.role&.to_sym == :tool
|
|
223
|
+
raise ArgumentError, "raw Tool result is not an LLM message"
|
|
224
|
+
end
|
|
225
|
+
RubyLLM::Message.new(
|
|
226
|
+
role: :tool,
|
|
227
|
+
content: @persistence.contents.fetch_text(segment.content_ref),
|
|
228
|
+
tool_call_id: segment.tool_call_id ||
|
|
229
|
+
segment.metadata["tool_call_id"] || segment.metadata[:tool_call_id]
|
|
230
|
+
)
|
|
231
|
+
else
|
|
232
|
+
RubyLLM::Message.new(
|
|
233
|
+
role: segment.role,
|
|
234
|
+
content: @persistence.contents.fetch_text(segment.content_ref),
|
|
235
|
+
tool_call_id: segment.tool_call_id
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def materialize_canonical_message(segment, expected_role:)
|
|
241
|
+
payload = fetch_json(segment.content_ref)
|
|
242
|
+
role = payload.fetch("role").to_sym
|
|
243
|
+
unless role == expected_role
|
|
244
|
+
raise ArgumentError,
|
|
245
|
+
"canonical #{segment.category} role mismatch: #{role.inspect}"
|
|
246
|
+
end
|
|
247
|
+
if segment.role && segment.role.to_sym != role
|
|
248
|
+
raise ArgumentError,
|
|
249
|
+
"manifest role does not match canonical message: #{segment.role.inspect} != #{role.inspect}"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
tool_calls = materialize_tool_calls(payload["tool_calls"])
|
|
253
|
+
message = RubyLLM::Message.new(
|
|
254
|
+
role: role,
|
|
255
|
+
content: initial_content_for(role, payload.fetch("content", nil), tool_calls),
|
|
256
|
+
tool_calls: tool_calls.empty? ? nil : tool_calls,
|
|
257
|
+
tool_call_id: payload["tool_call_id"],
|
|
258
|
+
model_id: payload["model_id"]
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# RubyLLM normalizes Hash content during Message initialization. A
|
|
262
|
+
# canonical message may legitimately contain structured assistant
|
|
263
|
+
# content, so restore the captured logical value after construction.
|
|
264
|
+
message.content = payload["content"] if payload.key?("content")
|
|
265
|
+
message
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def initial_content_for(role, content, tool_calls)
|
|
269
|
+
return "" if role == :assistant && content.nil? && !tool_calls.empty?
|
|
270
|
+
|
|
271
|
+
content.nil? ? "" : content
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def materialize_tool_calls(payloads)
|
|
275
|
+
Array(payloads).each_with_object({}) do |payload, result|
|
|
276
|
+
tool_call_id = payload.fetch("id").to_s
|
|
277
|
+
if result.key?(tool_call_id)
|
|
278
|
+
raise ArgumentError, "duplicate Tool Call in assistant message: #{tool_call_id}"
|
|
279
|
+
end
|
|
280
|
+
result[tool_call_id] = materialize_tool_call(payload)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def materialize_tool_call(payload)
|
|
285
|
+
RubyLLM::ToolCall.new(
|
|
286
|
+
id: payload.fetch("id").to_s,
|
|
287
|
+
name: payload.fetch("name"),
|
|
288
|
+
arguments: payload.fetch("arguments", {}),
|
|
289
|
+
thought_signature: payload["thought_signature"]
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def metadata_value(segment, name)
|
|
294
|
+
segment.metadata[name] || segment.metadata[name.to_sym]
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Resolves a TokenBudget from the effective model configuration that will
|
|
6
|
+
# actually be materialized for one LLM call.
|
|
7
|
+
class TokenBudgetResolver
|
|
8
|
+
def initialize(agent:)
|
|
9
|
+
@agent = agent
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def resolve(model_config)
|
|
13
|
+
config = stringify_keys(model_config)
|
|
14
|
+
model_name = config["model"]
|
|
15
|
+
explicit_window = integer_or_nil(config["context_window"])
|
|
16
|
+
explicit_reserve = integer_or_nil(config["max_output_tokens"])
|
|
17
|
+
|
|
18
|
+
if explicit_window
|
|
19
|
+
reserve = explicit_reserve || configured_default_reserve
|
|
20
|
+
return build_budget(explicit_window, reserve, model_name)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
return nil unless model_name
|
|
24
|
+
|
|
25
|
+
model = RubyLLM.models.find(model_name)
|
|
26
|
+
return nil unless model
|
|
27
|
+
|
|
28
|
+
context_window = model.context_window.to_i
|
|
29
|
+
registry_max = model.max_output_tokens.to_i
|
|
30
|
+
reserve = explicit_reserve || configured_default_reserve
|
|
31
|
+
reserve ||= registry_max if registry_max.positive? && registry_max < context_window
|
|
32
|
+
build_budget(context_window, reserve, model_name)
|
|
33
|
+
rescue RubyLLM::ModelNotFoundError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def build_budget(context_window, reserve, model_name)
|
|
40
|
+
unless reserve&.positive? && reserve < context_window
|
|
41
|
+
raise Phronomy::InvalidContextBudgetConfigurationError,
|
|
42
|
+
"Cannot determine a valid output reserve for model #{model_name.inspect}; " \
|
|
43
|
+
"set max_output_tokens or Phronomy.configuration.default_output_reserve"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ContextAssembler passes the actual mandatory Manifest content to
|
|
47
|
+
# ContextSelector. Reserving context_overhead here would count system
|
|
48
|
+
# instructions and tool definitions a second time.
|
|
49
|
+
Phronomy::LlmContextWindow::TokenBudget.new(
|
|
50
|
+
context_window: context_window,
|
|
51
|
+
max_output_tokens: reserve
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def configured_default_reserve
|
|
56
|
+
integer_or_nil(Phronomy.configuration.default_output_reserve)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def integer_or_nil(value)
|
|
60
|
+
return nil if value.nil?
|
|
61
|
+
Integer(value)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stringify_keys(hash)
|
|
65
|
+
hash.to_h.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -2,18 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Agent
|
|
5
|
-
#
|
|
5
|
+
# Normal control-transfer signal raised before RubyLLM executes a Tool Call.
|
|
6
|
+
#
|
|
7
|
+
# RubyLLM >= 1.15 guarantees that the complete assistant message has already
|
|
8
|
+
# been added to Chat#messages before before_tool_call runs. Keeping that
|
|
9
|
+
# message here allows Phronomy to persist the complete Provider outcome even
|
|
10
|
+
# though the RubyLLM call itself unwinds through this exception.
|
|
6
11
|
# @api private
|
|
7
12
|
class ToolCallIntercepted < StandardError
|
|
8
|
-
attr_reader :tool_calls
|
|
13
|
+
attr_reader :tool_calls, :assistant_message, :assistant_outcome, :llm_call_id
|
|
9
14
|
|
|
10
|
-
def initialize(tool_calls)
|
|
15
|
+
def initialize(tool_calls, assistant_message: nil, assistant_outcome: nil, llm_call_id: nil)
|
|
11
16
|
@tool_calls = Array(tool_calls).freeze
|
|
17
|
+
@assistant_message = assistant_message
|
|
18
|
+
@assistant_outcome = assistant_outcome || ProviderCallOutcome.capture(assistant_message)
|
|
19
|
+
@llm_call_id = llm_call_id&.to_s&.freeze
|
|
12
20
|
names = @tool_calls.map(&:name).join(", ")
|
|
13
21
|
super("Tool call intercepted: #{names}")
|
|
14
22
|
end
|
|
15
23
|
|
|
16
|
-
# Convenience accessor for callers that only support one ToolCall.
|
|
17
24
|
def tool_call
|
|
18
25
|
@tool_calls.first
|
|
19
26
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class ToolDefinitionSet
|
|
6
|
+
attr_reader :runtime_tools, :definitions
|
|
7
|
+
|
|
8
|
+
def self.build(agent)
|
|
9
|
+
runtime_tools = (agent.class.tools + agent.send(:_handoff_tools)).freeze
|
|
10
|
+
definitions = runtime_tools.map do |tool_class|
|
|
11
|
+
prepared = agent.send(:prepare_tool_class, tool_class)
|
|
12
|
+
tool = prepared.is_a?(Class) ? prepared.new : prepared
|
|
13
|
+
{
|
|
14
|
+
"name" => tool.name.to_s,
|
|
15
|
+
"description" => tool.description.to_s,
|
|
16
|
+
"parameters_schema" => normalize(
|
|
17
|
+
tool.respond_to?(:parameters_schema) ? tool.parameters_schema : {}
|
|
18
|
+
),
|
|
19
|
+
"provider_options" => normalize(
|
|
20
|
+
tool.respond_to?(:provider_options) ? tool.provider_options : {}
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
end.freeze
|
|
24
|
+
new(runtime_tools: runtime_tools, definitions: definitions)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.normalize(value)
|
|
28
|
+
case value
|
|
29
|
+
when Hash
|
|
30
|
+
value.to_h { |key, child| [key.to_s, normalize(child)] }
|
|
31
|
+
when Array
|
|
32
|
+
value.map { |child| normalize(child) }
|
|
33
|
+
when Symbol
|
|
34
|
+
value.to_s
|
|
35
|
+
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
|
36
|
+
value
|
|
37
|
+
else
|
|
38
|
+
if value.respond_to?(:to_json_schema)
|
|
39
|
+
normalize(value.to_json_schema)
|
|
40
|
+
elsif value.respond_to?(:to_h)
|
|
41
|
+
normalize(value.to_h)
|
|
42
|
+
else
|
|
43
|
+
raise ArgumentError, "unsupported tool definition value: #{value.class}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(runtime_tools:, definitions:)
|
|
49
|
+
@runtime_tools = runtime_tools
|
|
50
|
+
@definitions = Immutable.copy(definitions)
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/phronomy/agent.rb
CHANGED
|
@@ -2,28 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Agent
|
|
5
|
-
# Immutable event emitted by Agent async APIs.
|
|
6
|
-
#
|
|
7
|
-
# invoke_async and stream_async share lifecycle and Tool events. Streaming
|
|
8
|
-
# additionally emits :token events.
|
|
9
|
-
#
|
|
10
|
-
# Common event types:
|
|
11
|
-
# :tool_call
|
|
12
|
-
# :tool_result
|
|
13
|
-
# :approval_required
|
|
14
|
-
# :done
|
|
15
|
-
# :error
|
|
16
|
-
# :timeout
|
|
17
|
-
# :cancelled
|
|
18
|
-
#
|
|
19
|
-
# Streaming-only event type:
|
|
20
|
-
# :token
|
|
21
5
|
StreamEvent = Data.define(:type, :payload)
|
|
6
|
+
|
|
7
|
+
def self.run_once(definition:, input:, context: nil, **invoke_options)
|
|
8
|
+
persistence = Phronomy::Persistence::InMemory.new
|
|
9
|
+
agent = definition.create(context: context, persistence: persistence)
|
|
10
|
+
agent.invoke(input, **invoke_options)
|
|
11
|
+
end
|
|
22
12
|
end
|
|
23
13
|
end
|
|
24
14
|
|
|
15
|
+
require_relative "agent/fsm_runtime_adapter"
|
|
25
16
|
require_relative "agent/async_event_api"
|
|
26
17
|
|
|
18
|
+
unless Phronomy::Agent::AgentInvocationSessionBuilder.singleton_class <
|
|
19
|
+
Phronomy::Agent::FsmRuntimeAdapter
|
|
20
|
+
Phronomy::Agent::AgentInvocationSessionBuilder.singleton_class.prepend(
|
|
21
|
+
Phronomy::Agent::FsmRuntimeAdapter
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
27
25
|
unless Phronomy::Agent::Base < Phronomy::Agent::AsyncEventApi
|
|
28
26
|
Phronomy::Agent::Base.prepend(Phronomy::Agent::AsyncEventApi)
|
|
29
27
|
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
# Phronomy Canonical JSON v1.
|
|
7
|
+
#
|
|
8
|
+
# v1 accepts JSON-native values only, orders object names by UTF-16 code
|
|
9
|
+
# units, and emits ECMAScript/JCS-compatible number forms for IEEE-754
|
|
10
|
+
# doubles. Ruby-specific and non-interoperable numeric values must be
|
|
11
|
+
# converted by a domain codec before serialization.
|
|
12
|
+
class CanonicalJSON
|
|
13
|
+
VERSION = 1
|
|
14
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def dump(value)
|
|
18
|
+
serialize(value)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def load(bytes)
|
|
22
|
+
JSON.parse(bytes)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def serialize(value)
|
|
28
|
+
case value
|
|
29
|
+
when Hash
|
|
30
|
+
serialize_hash(value)
|
|
31
|
+
when Array
|
|
32
|
+
"[#{value.map { |child| serialize(child) }.join(",")}]"
|
|
33
|
+
when String
|
|
34
|
+
JSON.generate(ensure_utf8(value))
|
|
35
|
+
when Integer
|
|
36
|
+
serialize_integer(value)
|
|
37
|
+
when Float
|
|
38
|
+
serialize_float(value)
|
|
39
|
+
when TrueClass then "true"
|
|
40
|
+
when FalseClass then "false"
|
|
41
|
+
when NilClass then "null"
|
|
42
|
+
else
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"unsupported Phronomy Canonical JSON v1 value: #{value.class}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def serialize_hash(value)
|
|
49
|
+
normalized = {}
|
|
50
|
+
value.each do |key, child|
|
|
51
|
+
unless key.is_a?(String)
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"canonical JSON object keys must be String, got #{key.class}"
|
|
54
|
+
end
|
|
55
|
+
canonical_key = ensure_utf8(key)
|
|
56
|
+
if normalized.key?(canonical_key)
|
|
57
|
+
raise ArgumentError,
|
|
58
|
+
"duplicate canonical JSON key: #{canonical_key.inspect}"
|
|
59
|
+
end
|
|
60
|
+
normalized[canonical_key] = child
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
members = normalized.sort_by { |key, _| utf16_sort_key(key) }.map do |key, child|
|
|
64
|
+
"#{JSON.generate(key)}:#{serialize(child)}"
|
|
65
|
+
end
|
|
66
|
+
"{#{members.join(",")}}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def serialize_integer(value)
|
|
70
|
+
if value.abs > MAX_SAFE_INTEGER
|
|
71
|
+
raise ArgumentError,
|
|
72
|
+
"integer exceeds canonical JSON safe range; encode it as a String: #{value}"
|
|
73
|
+
end
|
|
74
|
+
value.to_s
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def serialize_float(value)
|
|
78
|
+
raise ArgumentError, "non-finite number is not canonical JSON" unless value.finite?
|
|
79
|
+
raise ArgumentError, "-0.0 is not canonical JSON v1" if negative_zero?(value)
|
|
80
|
+
return "0" if value.zero?
|
|
81
|
+
|
|
82
|
+
raw = value.to_s.downcase
|
|
83
|
+
return normalize_plain_decimal(raw) unless raw.include?("e")
|
|
84
|
+
|
|
85
|
+
sign = raw.start_with?("-") ? "-" : ""
|
|
86
|
+
raw = raw.delete_prefix("-")
|
|
87
|
+
mantissa, exponent_text = raw.split("e", 2)
|
|
88
|
+
exponent = Integer(exponent_text, 10)
|
|
89
|
+
integer_part, fractional_part = mantissa.split(".", 2)
|
|
90
|
+
fractional_part ||= ""
|
|
91
|
+
digits = (integer_part + fractional_part).sub(/0+\z/, "")
|
|
92
|
+
digits = "0" if digits.empty?
|
|
93
|
+
decimal_position = integer_part.length + exponent
|
|
94
|
+
|
|
95
|
+
body = if decimal_position > 0 && decimal_position <= 21
|
|
96
|
+
if decimal_position >= digits.length
|
|
97
|
+
digits + ("0" * (decimal_position - digits.length))
|
|
98
|
+
else
|
|
99
|
+
"#{digits[0, decimal_position]}.#{digits[decimal_position..]}"
|
|
100
|
+
end
|
|
101
|
+
elsif decimal_position <= 0 && decimal_position > -6
|
|
102
|
+
"0.#{"0" * -decimal_position}#{digits}"
|
|
103
|
+
else
|
|
104
|
+
scientific_exponent = decimal_position - 1
|
|
105
|
+
fraction = digits[1..]
|
|
106
|
+
coefficient = (fraction.nil? || fraction.empty?) ? digits[0] : "#{digits[0]}.#{fraction}"
|
|
107
|
+
exponent_sign = scientific_exponent.negative? ? "" : "+"
|
|
108
|
+
"#{coefficient}e#{exponent_sign}#{scientific_exponent}"
|
|
109
|
+
end
|
|
110
|
+
"#{sign}#{body}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def normalize_plain_decimal(raw)
|
|
114
|
+
raw = raw.delete_suffix(".0")
|
|
115
|
+
(raw == "-0") ? "0" : raw
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def negative_zero?(value)
|
|
119
|
+
value.zero? && (1.0 / value).negative?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def utf16_sort_key(value)
|
|
123
|
+
value.encode(Encoding::UTF_16BE).bytes
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def ensure_utf8(value)
|
|
127
|
+
text = value.dup.encode(Encoding::UTF_8)
|
|
128
|
+
raise ArgumentError, "invalid UTF-8 string" unless text.valid_encoding?
|
|
129
|
+
|
|
130
|
+
text
|
|
131
|
+
rescue EncodingError => error
|
|
132
|
+
raise ArgumentError, "invalid UTF-8 string: #{error.message}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -22,11 +22,16 @@ module Phronomy
|
|
|
22
22
|
# Tracer instance
|
|
23
23
|
attr_accessor :tracer
|
|
24
24
|
|
|
25
|
-
# Global
|
|
25
|
+
# Global before_llm_input hook callable (Proc / lambda).
|
|
26
26
|
# Called before every LLM request across all agents.
|
|
27
|
-
# Receives a {Phronomy::Agent::
|
|
28
|
-
#
|
|
29
|
-
attr_accessor :
|
|
27
|
+
# Receives a {Phronomy::Agent::LLMInputBuildContext}; must return a
|
|
28
|
+
# {Phronomy::Agent::LLMInputPatch} or nil to pass through unchanged.
|
|
29
|
+
attr_accessor :before_llm_input
|
|
30
|
+
|
|
31
|
+
# Default output token reservation when an agent does not set max_output_tokens
|
|
32
|
+
# and the model registry value equals the context window (making it unusable
|
|
33
|
+
# as a per-request output reserve). Integer or nil.
|
|
34
|
+
attr_accessor :default_output_reserve
|
|
30
35
|
|
|
31
36
|
# Recursion limit for graph execution (default: 25)
|
|
32
37
|
attr_accessor :recursion_limit
|