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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -18
  3. data/README.md +300 -75
  4. data/benchmark/bench_agent_invoke.rb +3 -0
  5. data/benchmark/bench_regression.rb +2 -18
  6. data/benchmark/bench_tool_schema.rb +1 -0
  7. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  8. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  9. data/lib/phronomy/agent/activation_registry.rb +28 -0
  10. data/lib/phronomy/agent/agent_execution.rb +97 -0
  11. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  12. data/lib/phronomy/agent/agent_invocation.rb +42 -10
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
  14. data/lib/phronomy/agent/agent_root.rb +67 -0
  15. data/lib/phronomy/agent/async_event_api.rb +55 -393
  16. data/lib/phronomy/agent/base.rb +301 -641
  17. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  18. data/lib/phronomy/agent/context_assembler.rb +321 -0
  19. data/lib/phronomy/agent/context_candidate.rb +47 -0
  20. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  21. data/lib/phronomy/agent/context_importer.rb +217 -0
  22. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  23. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  24. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  25. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
  26. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  27. data/lib/phronomy/agent/context_plan.rb +25 -0
  28. data/lib/phronomy/agent/context_plan_validator.rb +167 -0
  29. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  30. data/lib/phronomy/agent/context_policy.rb +15 -0
  31. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  32. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  33. data/lib/phronomy/agent/context_request.rb +35 -0
  34. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  35. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  36. data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
  37. data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
  38. data/lib/phronomy/agent/immutable.rb +31 -0
  39. data/lib/phronomy/agent/journal_projection.rb +34 -0
  40. data/lib/phronomy/agent/journal_record.rb +67 -0
  41. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  42. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  43. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  44. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  45. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  46. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  47. data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
  48. data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
  49. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  50. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  51. data/lib/phronomy/agent.rb +14 -16
  52. data/lib/phronomy/agent_busy_error.rb +5 -0
  53. data/lib/phronomy/canonical_json.rb +136 -0
  54. data/lib/phronomy/configuration.rb +9 -4
  55. data/lib/phronomy/content_store/base.rb +51 -0
  56. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  57. data/lib/phronomy/engine/event_loop.rb +3 -0
  58. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  59. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  60. data/lib/phronomy/llm_context_window/assembler.rb +8 -8
  61. data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
  62. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
  64. data/lib/phronomy/persistence/in_memory.rb +247 -0
  65. data/lib/phronomy/persistence.rb +39 -0
  66. data/lib/phronomy/tools/agent.rb +14 -36
  67. data/lib/phronomy/version.rb +1 -1
  68. data/lib/phronomy.rb +11 -0
  69. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  70. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  71. data/scripts/migrate_spec_agent_definition.rb +108 -0
  72. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  73. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  74. metadata +54 -47
  75. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  76. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  77. 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
- # Raised by the Agent-owned RubyLLM ToolCall interceptor before execution.
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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class AgentBusyError < Phronomy::Error; end
5
+ 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 before_completion hook callable (Proc / lambda).
25
+ # Global before_llm_input hook callable (Proc / lambda).
26
26
  # Called before every LLM request across all agents.
27
- # Receives a {Phronomy::Agent::BeforeCompletionContext}; must return a Hash
28
- # of params to merge, or nil to pass through unchanged.
29
- attr_accessor :before_completion
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