phronomy 0.15.1 → 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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +159 -28
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +400 -143
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +7 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +3 -19
  10. data/benchmark/bench_tool_schema.rb +2 -34
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  14. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  15. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  16. data/lib/phronomy/agent/activation_registry.rb +28 -0
  17. data/lib/phronomy/agent/agent_execution.rb +97 -0
  18. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  19. data/lib/phronomy/agent/agent_invocation.rb +44 -46
  20. data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
  21. data/lib/phronomy/agent/agent_root.rb +66 -0
  22. data/lib/phronomy/agent/async_event_api.rb +55 -475
  23. data/lib/phronomy/agent/base.rb +351 -514
  24. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  25. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  26. data/lib/phronomy/agent/context_assembler.rb +357 -0
  27. data/lib/phronomy/agent/context_candidate.rb +47 -0
  28. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  29. data/lib/phronomy/agent/context_importer.rb +217 -0
  30. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  31. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  32. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  33. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
  34. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  35. data/lib/phronomy/agent/context_plan.rb +25 -0
  36. data/lib/phronomy/agent/context_plan_validator.rb +134 -0
  37. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  38. data/lib/phronomy/agent/context_policy.rb +15 -0
  39. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  40. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  41. data/lib/phronomy/agent/context_request.rb +35 -0
  42. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  43. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  44. data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
  45. data/lib/phronomy/agent/immutable.rb +31 -0
  46. data/lib/phronomy/agent/journal_projection.rb +60 -0
  47. data/lib/phronomy/agent/journal_record.rb +67 -0
  48. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  49. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  50. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  51. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  52. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  53. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  54. data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
  55. data/lib/phronomy/agent/shared_state.rb +46 -138
  56. data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
  57. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  58. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  59. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  60. data/lib/phronomy/agent.rb +10 -16
  61. data/lib/phronomy/agent_busy_error.rb +5 -0
  62. data/lib/phronomy/canonical_json.rb +136 -0
  63. data/lib/phronomy/configuration.rb +17 -155
  64. data/lib/phronomy/content_store/base.rb +51 -0
  65. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  66. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  67. data/lib/phronomy/engine/event_loop.rb +3 -0
  68. data/lib/phronomy/engine/runtime.rb +15 -230
  69. data/lib/phronomy/engine/task_group.rb +30 -102
  70. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  71. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  72. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  73. data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
  74. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  75. data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
  76. data/lib/phronomy/persistence/in_memory.rb +247 -0
  77. data/lib/phronomy/persistence.rb +39 -0
  78. data/lib/phronomy/tools/agent.rb +14 -36
  79. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  80. data/lib/phronomy/version.rb +1 -1
  81. data/lib/phronomy.rb +9 -115
  82. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  83. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  84. data/scripts/api_snapshot.rb +1 -12
  85. data/scripts/migrate_spec_agent_definition.rb +108 -0
  86. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  87. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  88. metadata +54 -13
  89. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  90. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  91. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
  92. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  93. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  94. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  95. data/lib/phronomy/knowledge_source.rb +0 -12
  96. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  97. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "securerandom"
4
4
  require_relative "concerns/filterable"
5
- require_relative "concerns/before_completion"
5
+ require_relative "concerns/before_llm_input"
6
6
  require_relative "concerns/error_translation"
7
7
 
8
8
  module Phronomy
@@ -10,11 +10,12 @@ module Phronomy
10
10
  # Base class for all Phronomy agents.
11
11
  #
12
12
  # Subclass this to create a conversational agent powered by an LLM.
13
- # DSL class methods configure the model, instructions, tools, memory,
13
+ # DSL class methods configure the model, instructions, tools,
14
14
  # and execution hooks. Instance methods handle invocation.
15
15
  #
16
16
  # @example Minimal agent
17
17
  # class GreetingAgent < Phronomy::Agent::Base
18
+ # agent_definition id: "greeting-agent", version: 1
18
19
  # model "gpt-4o-mini"
19
20
  # instructions "You are a friendly greeter."
20
21
  # end
@@ -23,15 +24,16 @@ module Phronomy
23
24
  #
24
25
  # @example Agent with tools
25
26
  # class ResearchAgent < Phronomy::Agent::Base
27
+ # agent_definition id: "research-agent", version: 1
26
28
  # model "gpt-4o"
27
29
  # instructions "You are a research assistant."
28
- # tools WebSearchTool, CalculatorTool
30
+ # tools(WebSearchTool => nil, CalculatorTool => nil)
29
31
  # max_iterations 15
30
32
  # end
31
33
  class Base
32
34
  include Phronomy::Runnable
33
35
  include Concerns::Filterable
34
- include Concerns::BeforeCompletion
36
+ include Concerns::BeforeLLMInput
35
37
  include Concerns::ErrorTranslation
36
38
 
37
39
  APPROVAL_CONFIGURATION_INIT_MUTEX = Mutex.new
@@ -84,40 +86,34 @@ module Phronomy
84
86
 
85
87
  # Registers tool classes for this agent.
86
88
  #
87
- # Accepts either a splat of classes (backward-compatible) or a Hash mapping
88
- # each class to an explicit alias name (String) or nil (use tool's own name).
89
- # The alias form is useful when two tools share the same auto-generated name
90
- # (e.g. two SearchTool classes from different modules).
89
+ # The setter accepts one Hash mapping each Tool class to an explicit alias
90
+ # name (String) or nil (use the Tool's own name). Calling without an
91
+ # argument returns the registered Tool classes.
91
92
  #
92
- # @example Splat form (no alias)
93
- # tools WeatherTool, TimeTool
94
- #
95
- # @example Hash form (with optional per-tool alias)
93
+ # @example
96
94
  # tools(
97
95
  # Weather::SearchTool => "weather_search",
98
96
  # Places::SearchTool => "places_search",
99
97
  # CurrentTimeTool => nil
100
98
  # )
101
99
  # @api public
102
- def tools(*args)
103
- if args.empty?
104
- if instance_variable_defined?(:@tools)
105
- return @tools
106
- end
100
+ def tools(definitions = nil)
101
+ if definitions.nil?
102
+ return @tools if instance_variable_defined?(:@tools)
107
103
  return superclass.respond_to?(:tools) ? superclass.tools : []
108
104
  end
109
105
 
110
- if args.length == 1 && args.first.is_a?(Hash)
111
- hash = args.first
112
- @tools = hash.keys
113
- @tool_aliases = hash.transform_values { |v| v&.to_s }.reject { |_, v| v.nil? }
114
- else
115
- @tools = args
116
- @tool_aliases = {}
106
+ unless definitions.is_a?(Hash)
107
+ raise ArgumentError,
108
+ "tools expects a Hash of ToolClass => alias_or_nil"
117
109
  end
110
+
111
+ @tools = definitions.keys
112
+ @tool_aliases = definitions.transform_values { |value| value&.to_s }
113
+ .reject { |_, value| value.nil? }
118
114
  end
119
115
 
120
- # Returns the alias map registered via the hash form of .tools.
116
+ # Returns the alias map registered via .tools.
121
117
  # Merges parent class aliases so subclasses inherit their parent's mappings.
122
118
  # Subclass-specific aliases take precedence over parent aliases.
123
119
  # @return [Hash{Class => String}]
@@ -188,73 +184,9 @@ module Phronomy
188
184
  end
189
185
  end
190
186
 
191
- # Registers one or more static knowledge sources on the agent class.
192
- # Static source content is fetched and memoized at the **class** level
193
- # the first time +invoke+ is called. The cache persists for the lifetime
194
- # of the process; call {.static_knowledge_refresh!} to force a reload.
195
- #
196
- # @param sources [Array<Phronomy::Agent::Context::Knowledge::Base>]
197
- # @example
198
- # class PolicyAgent < Phronomy::Agent::Base
199
- # static_knowledge Phronomy::Agent::Context::Knowledge::StaticKnowledge.new(POLICY_TEXT)
200
- # end
201
- # @api public
202
- def static_knowledge(*sources)
203
- @static_knowledge_sources = sources.flatten
204
- # Invalidate the cached chunks so the new sources are fetched on
205
- # the next call to static_knowledge_chunks.
206
- @static_knowledge_chunks = nil
207
- end
208
-
209
- # Returns the registered static knowledge sources.
210
- # @return [Array<Phronomy::Agent::Context::Knowledge::Base>]
211
- # @api public
212
- def static_knowledge_sources
213
- @static_knowledge_sources || []
214
- end
215
-
216
- # Returns the fetched content from all static knowledge sources.
217
- # Results are cached at the class level so that each source is fetched
218
- # only once regardless of how many times the agent is invoked.
219
- # @return [Array<Hash>]
220
- # @api public
221
- def static_knowledge_chunks
222
- @static_knowledge_chunks ||= static_knowledge_sources.flat_map { |ks|
223
- ks.fetch(query: nil)
224
- }
225
- end
226
-
227
- # Clears the class-level knowledge cache so that the next +invoke+ call
228
- # re-fetches content from all registered static knowledge sources.
229
- #
230
- # Call this method when the underlying knowledge source has been updated
231
- # at runtime (e.g. a file was rewritten, a DB record changed) and you
232
- # want the agent to pick up the new content without restarting the
233
- # process.
234
- #
235
- # @return [nil]
236
- # @example Refresh after updating a knowledge file
237
- # MyAgent.static_knowledge_refresh!
238
- # @api public
239
- def static_knowledge_refresh!
240
- @static_knowledge_chunks = nil
241
- end
242
-
243
187
  # When enabled, attaches Anthropic prompt-cache markers to the system
244
188
  # message so that the fixed instructions are served from cache on
245
189
  # subsequent turns, reducing input-token costs.
246
- #
247
- # Only has an effect when the agent also declares `provider :anthropic`.
248
- # The cache_control field is provider-specific (the format differs
249
- # between Anthropic direct, Bedrock, etc.), so the agent must explicitly
250
- # declare its provider via the DSL rather than having it inferred from
251
- # the model name.
252
- #
253
- # @example
254
- # class MyAgent < Phronomy::Agent::Base
255
- # provider :anthropic
256
- # cache_instructions true
257
- # end
258
190
  # @api public
259
191
  def cache_instructions(enabled = nil)
260
192
  if enabled.nil?
@@ -266,11 +198,6 @@ module Phronomy
266
198
 
267
199
  # Tokens to reserve for the model's output.
268
200
  # When nil, the model's max_output_tokens from the registry is used.
269
- #
270
- # @example
271
- # class MyAgent < Phronomy::Agent::Base
272
- # max_output_tokens 4096
273
- # end
274
201
  # @api public
275
202
  def max_output_tokens(val = nil)
276
203
  if val.nil?
@@ -281,14 +208,6 @@ module Phronomy
281
208
  end
282
209
 
283
210
  # Overrides the context window size used for token budget calculations.
284
- # When set, this value takes precedence over the RubyLLM model registry,
285
- # which is useful for locally-hosted models (e.g. LM Studio) where the
286
- # actually-loaded context length may differ from the catalogue value.
287
- #
288
- # @example
289
- # class MyAgent < Phronomy::Agent::Base
290
- # context_window 4096
291
- # end
292
211
  # @api public
293
212
  def context_window(val = nil)
294
213
  if val.nil?
@@ -298,47 +217,55 @@ module Phronomy
298
217
  end
299
218
  end
300
219
 
301
- # Tokens reserved for the system prompt + tool definitions overhead.
302
- # Subtract this from the context window before computing the memory budget.
303
- #
304
- # @example
305
- # class MyAgent < Phronomy::Agent::Base
306
- # context_overhead 500
307
- # end
308
- # @api public
309
- def context_overhead(val = nil)
310
- if val.nil?
311
- @context_overhead || 0
312
- else
313
- @context_overhead = val.to_i
220
+ # Defines or reads the stable Agent definition identity.
221
+ # Subclass with no explicit declaration inherits the parent's definition.
222
+ def agent_definition(id: nil, version: nil)
223
+ if id || version
224
+ raise ArgumentError, "agent_definition requires id: and version:" unless id && version
225
+ @agent_definition = {id: id.to_s.freeze, version: Integer(version)}.freeze
226
+ end
227
+ return @agent_definition if @agent_definition
228
+
229
+ klass = superclass
230
+ while klass.respond_to?(:agent_definition, true) &&
231
+ klass < Phronomy::Agent::Base
232
+ defn = klass.instance_variable_get(:@agent_definition)
233
+ return defn if defn
234
+ klass = klass.superclass
314
235
  end
236
+
237
+ raise Phronomy::ConfigurationError,
238
+ "#{name || self} must declare agent_definition id: ..., version: ..."
315
239
  end
316
240
 
317
- # Continues a suspended AgentInvocation.
318
- # @param agent_invocation_id [String]
319
- # @param approval_request_id [String]
320
- # @param approved [Boolean]
321
- # @param config [Hash]
322
- # @api public
323
- def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
324
- new.approve(
325
- agent_invocation_id,
241
+ def create(agent_id: SecureRandom.uuid, context: nil, knowledge: [], persistence: nil, metadata: {})
242
+ new(
243
+ agent_id: agent_id,
244
+ context: context,
245
+ knowledge: knowledge,
246
+ persistence: persistence,
247
+ metadata: metadata
248
+ )
249
+ end
250
+
251
+ def load(agent_id, persistence:)
252
+ new(agent_id: agent_id, persistence: persistence, load_existing: true)
253
+ end
254
+
255
+ def approve(execution_id, approval_request_id:, persistence:, approved: true, config: {})
256
+ approve_async(
257
+ execution_id,
326
258
  approval_request_id: approval_request_id,
327
259
  approved: approved,
328
- config: config
329
- )
260
+ config: config,
261
+ persistence: persistence
262
+ ).wait_result
330
263
  end
331
264
 
332
- # Continues a suspended AgentInvocation without blocking the caller.
333
- # @param agent_invocation_id [String]
334
- # @param approval_request_id [String]
335
- # @param approved [Boolean]
336
- # @param config [Hash]
337
- # @return [Phronomy::Task]
338
- # @api public
339
- def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
340
- new.approve_async(
341
- agent_invocation_id,
265
+ def approve_async(execution_id, approval_request_id:, persistence:, approved: true, config: {})
266
+ execution = persistence.executions.load(execution_id)
267
+ load(execution.agent_id, persistence: persistence).approve_async(
268
+ execution_id,
342
269
  approval_request_id: approval_request_id,
343
270
  approved: approved,
344
271
  config: config
@@ -346,29 +273,256 @@ module Phronomy
346
273
  end
347
274
  end
348
275
 
349
- # Registers an anonymous handoff tool class on this agent instance.
350
- # Called by Runner during construction when routes are configured.
351
- # @param tool_class [Class<Phronomy::Agent::Context::Capability::Base>]
352
- # @return [self]
353
- # @api private
276
+ attr_reader :agent_id, :persistence
277
+
278
+ def initialize(
279
+ agent_id: SecureRandom.uuid,
280
+ context: nil,
281
+ knowledge: [],
282
+ persistence: nil,
283
+ metadata: {},
284
+ load_existing: false
285
+ )
286
+ @persistence = persistence || Phronomy::Persistence::InMemory.new
287
+ @agent_id = agent_id.to_s.freeze
288
+ @root = if load_existing
289
+ loaded = @persistence.agents.load(@agent_id)
290
+ definition = self.class.agent_definition
291
+ unless loaded.agent_definition_id == definition.fetch(:id) &&
292
+ loaded.definition_version == definition.fetch(:version)
293
+ raise Phronomy::ConfigurationError,
294
+ "Agent definition mismatch for #{@agent_id}: stored " \
295
+ "#{loaded.agent_definition_id}@#{loaded.definition_version}, runtime " \
296
+ "#{definition.fetch(:id)}@#{definition.fetch(:version)}"
297
+ end
298
+ loaded
299
+ else
300
+ create_agent_root!(context: context, knowledge: knowledge, metadata: metadata)
301
+ end
302
+ end
303
+
304
+ def agent_root
305
+ @root
306
+ end
307
+
308
+ def journal_projection
309
+ Agent::JournalProjection.new(persistence: persistence, agent_root: @root)
310
+ end
311
+
312
+ def transcript
313
+ journal_projection.transcript_records
314
+ end
315
+
316
+ def clear_transcript!
317
+ mutate_context!(:transcript_cleared) do |root|
318
+ root.with(
319
+ agent_revision: root.agent_revision + 1,
320
+ context_revision: root.context_revision + 1,
321
+ transcript_generation: root.transcript_generation + 1
322
+ )
323
+ end
324
+ end
325
+
326
+ # Logically clears all persistent Knowledge registered before this point.
327
+ # Raw Journal records remain append-only and are not deleted.
328
+ def clear_knowledge!
329
+ mutate_context!(:knowledge_cleared) do |root|
330
+ root.with(
331
+ agent_revision: root.agent_revision + 1,
332
+ context_revision: root.context_revision + 1
333
+ )
334
+ end
335
+ end
336
+
337
+ # Appends persistent Knowledge to the Agent Journal.
338
+ # Knowledge is an optional Context candidate; it is not part of #transcript.
339
+ def add_knowledge(content, metadata: {})
340
+ next_root = nil
341
+ persistence.transaction do |tx|
342
+ tx.executions.assert_idle!(agent_id)
343
+ current = tx.agents.load(agent_id)
344
+ record = build_knowledge_record(
345
+ tx: tx,
346
+ root: current,
347
+ content: content,
348
+ metadata: metadata
349
+ )
350
+ appended = tx.journals.append(
351
+ agent_id,
352
+ expected_position: current.journal_position,
353
+ records: [record]
354
+ )
355
+ next_root = current.with(
356
+ agent_revision: current.agent_revision + 1,
357
+ context_revision: current.context_revision + 1,
358
+ journal_position: current.journal_position + appended.length
359
+ )
360
+ tx.agents.save(
361
+ agent_id,
362
+ expected_revision: current.agent_revision,
363
+ root: next_root
364
+ )
365
+ end
366
+ @root = next_root
367
+ self
368
+ end
369
+
370
+ def reset_context!
371
+ mutate_context!(:context_reset) do |root|
372
+ root.with(
373
+ agent_revision: root.agent_revision + 1,
374
+ context_revision: root.context_revision + 1,
375
+ transcript_generation: root.transcript_generation + 1
376
+ )
377
+ end
378
+ end
379
+
380
+ def close!
381
+ mutate_context!(:agent_closed, context_affecting: false) do |root|
382
+ root.with(
383
+ agent_revision: root.agent_revision + 1,
384
+ lifecycle_status: :closed
385
+ )
386
+ end
387
+ end
388
+
389
+ def purge!
390
+ persistence.transaction do |tx|
391
+ tx.executions.assert_idle!(agent_id)
392
+ tx.journals.delete(agent_id)
393
+ tx.executions.delete_for_agent(agent_id)
394
+ tx.agents.delete(agent_id)
395
+ end
396
+ @root = nil
397
+ true
398
+ end
399
+
400
+ # Internal hook used after a successful Persistence transaction.
401
+ def __replace_root(root)
402
+ @root = root
403
+ end
404
+
405
+ private
406
+
407
+ def create_agent_root!(context:, knowledge:, metadata:)
408
+ definition = self.class.agent_definition
409
+ root = Agent::AgentRoot.create(
410
+ agent_id: agent_id,
411
+ agent_definition_id: definition.fetch(:id),
412
+ definition_version: definition.fetch(:version),
413
+ metadata: metadata
414
+ )
415
+ persistence.transaction do |tx|
416
+ tx.agents.create(root)
417
+ records = initial_context_records(tx: tx, root: root, context: context)
418
+ records.concat(initial_knowledge_records(tx: tx, root: root, knowledge: knowledge))
419
+ unless records.empty?
420
+ appended = tx.journals.append(agent_id, expected_position: 0, records: records)
421
+ root = root.with(
422
+ agent_revision: 1,
423
+ context_revision: 1,
424
+ journal_position: appended.length
425
+ )
426
+ tx.agents.save(agent_id, expected_revision: 0, root: root)
427
+ end
428
+ end
429
+ root
430
+ end
431
+
432
+ def initial_context_records(tx:, root:, context:)
433
+ return [] unless context
434
+
435
+ imported = context.respond_to?(:records) ? context :
436
+ Agent::ContextImporter.import_messages(context)
437
+ imported.records.map do |record|
438
+ content_ref = case record.content_format
439
+ when :text then tx.contents.put_text(record.content)
440
+ when :json then tx.contents.put_json(record.content)
441
+ else
442
+ raise ArgumentError,
443
+ "unsupported imported content format: #{record.content_format.inspect}"
444
+ end
445
+ Agent::JournalRecord.new(
446
+ agent_id: agent_id,
447
+ kind: record.kind,
448
+ channel: record.channel,
449
+ role: record.role,
450
+ content_ref: content_ref,
451
+ context_generation: root.transcript_generation,
452
+ context_candidate: true,
453
+ metadata: record.metadata
454
+ )
455
+ end
456
+ end
457
+
458
+ def initial_knowledge_records(tx:, root:, knowledge:)
459
+ Array(knowledge).map do |content|
460
+ build_knowledge_record(
461
+ tx: tx,
462
+ root: root,
463
+ content: content,
464
+ metadata: {}
465
+ )
466
+ end
467
+ end
468
+
469
+ def build_knowledge_record(tx:, root:, content:, metadata:)
470
+ Agent::JournalRecord.new(
471
+ agent_id: agent_id,
472
+ kind: :knowledge,
473
+ channel: :context,
474
+ role: :user,
475
+ content_ref: tx.contents.put_text(String(content)),
476
+ context_generation: root.transcript_generation,
477
+ context_candidate: true,
478
+ metadata: metadata || {}
479
+ )
480
+ end
481
+
482
+ def mutate_context!(kind, context_affecting: true)
483
+ next_root = nil
484
+ persistence.transaction do |tx|
485
+ tx.executions.assert_idle!(agent_id)
486
+ current = tx.agents.load(agent_id)
487
+ record = Agent::JournalRecord.new(
488
+ agent_id: agent_id,
489
+ kind: kind,
490
+ channel: :state,
491
+ context_generation: current.transcript_generation,
492
+ context_candidate: false
493
+ )
494
+ appended = tx.journals.append(
495
+ agent_id,
496
+ expected_position: current.journal_position,
497
+ records: [record]
498
+ )
499
+ proposed = yield(current)
500
+ next_root = proposed.with(
501
+ journal_position: current.journal_position + appended.length,
502
+ context_revision: context_affecting ?
503
+ yield_context_revision(current, proposed) : current.context_revision
504
+ )
505
+ tx.agents.save(agent_id, expected_revision: current.agent_revision, root: next_root)
506
+ end
507
+ @root = next_root
508
+ end
509
+
510
+ def yield_context_revision(current, proposed)
511
+ (proposed.context_revision == current.context_revision) ? current.context_revision + 1 : proposed.context_revision
512
+ end
513
+
514
+ public
515
+
354
516
  def _add_handoff_tool(tool_class)
355
517
  @_handoff_tools ||= []
356
518
  @_handoff_tools << tool_class
357
519
  self
358
520
  end
359
521
 
360
- # Returns handoff tool classes registered on this instance by Runner.
361
- # @return [Array<Class>]
362
- # @api private
363
522
  def _handoff_tools
364
523
  @_handoff_tools || []
365
524
  end
366
525
 
367
- # Registers the final Agent/Application authorization policy.
368
- # The block runs on the Runtime authorization pool and must return
369
- # :allow, :require_approval, or :reject.
370
- # @return [self]
371
- # @api public
372
526
  def tool_approval_policy(&block)
373
527
  raise ArgumentError, "tool_approval_policy requires a block" unless block
374
528
 
@@ -376,9 +530,6 @@ module Phronomy
376
530
  self
377
531
  end
378
532
 
379
- # Registers a non-blocking Application notification listener.
380
- # @return [self]
381
- # @api public
382
533
  def on_tool_approval_required(&block)
383
534
  raise ArgumentError, "on_tool_approval_required requires a block" unless block
384
535
 
@@ -386,28 +537,17 @@ module Phronomy
386
537
  self
387
538
  end
388
539
 
389
- # @deprecated The context version cache has been removed. Returns nil.
390
- # Retained for backward compatibility with callers using safe navigation (+&.reset+).
391
- # @api private
392
- def context_version_cache
393
- nil
394
- end
395
-
396
540
  private
397
541
 
398
- # Merges an {InvocationContext} into the +thread_id+ / +config+ pair.
399
- # Returns +[effective_thread_id, effective_config]+.
400
- #
401
- # Precedence rules (existing explicit values always win):
402
- # - +thread_id+ argument > +ic.thread_id+
403
- # - +config[:cancellation_token]+ > +ic.cancellation_token+ > token derived from +ic.deadline+
404
- # - +ic+ is stored in +config[:invocation_context]+ (overwriting any previous value)
405
542
  def _apply_invocation_context(thread_id, config, ic)
406
543
  effective_thread_id = thread_id || ic.thread_id
407
544
  effective_config = config.merge(invocation_context: ic)
408
545
  if effective_config[:cancellation_token].nil?
409
546
  if (tok = ic.effective_timeout_token)
410
- effective_config = effective_config.merge(cancellation_token: tok)
547
+ effective_config = effective_config.merge(
548
+ cancellation_token: tok,
549
+ phronomy_timeout_deadline: ic.deadline
550
+ )
411
551
  end
412
552
  end
413
553
  [effective_thread_id, effective_config]
@@ -434,173 +574,6 @@ module Phronomy
434
574
  end
435
575
  end
436
576
 
437
- # Assembles the LLM context (system prompt + conversation messages)
438
- # for a single invocation. Subclasses may override this method to
439
- # inject custom context editing logic without having to override
440
- # the full #invoke_once pipeline.
441
- #
442
- # The keyword arguments +budget+, +instruction+, +tools+, and +knowledge+
443
- # carry pre-computed values. Override them in a subclass call to +super+
444
- # to inject custom context without recomputing the defaults.
445
- #
446
- # @param input [String, Hash] the user's input for this turn
447
- # @param messages [Array<RubyLLM::Message>] raw conversation history
448
- # @param thread_id [String, nil] conversation thread identifier
449
- # @param config [Hash] the invocation config (see #invoke)
450
- # @param budget [LlmContextWindow::TokenBudget, nil] pre-computed token budget
451
- # @param instruction [String, nil] pre-computed system instruction
452
- # @param tools [Array<Class>] tool classes to expose
453
- # @param knowledge [Array<Hash>] knowledge chunks ({ content:, type:, source: })
454
- # @return [Hash] { system: String|nil, messages: Array, tool_classes: Array }
455
- # @api public
456
- def build_context(
457
- input,
458
- messages: [],
459
- thread_id: nil,
460
- config: {},
461
- budget: build_token_budget,
462
- instruction: build_instructions(input),
463
- tools: self.class.tools + _handoff_tools,
464
- knowledge: self.class.static_knowledge_chunks + instance_knowledge_chunks
465
- )
466
- assembler = LlmContextWindow::Assembler.new(budget: budget)
467
- assembler.add_instruction(instruction) if instruction
468
- assembler.add_capability(tools)
469
- knowledge.each { |chunk| assembler.add_knowledge(chunk[:content], type: chunk[:type] || :static, trusted: true, source: chunk[:source]) }
470
-
471
- msgs = Array(messages)
472
-
473
- if budget && budget_exceeded?(msgs)
474
- # Default strategy when the token budget is tight:
475
- # 1. Compact: keep the most recent half of the messages verbatim and
476
- # replace the older half with a brief omission marker.
477
- # 2. Trim: if the compacted history still exceeds the budget, call
478
- # trim_to_budget with the :safe strategy, which discards the oldest
479
- # message one at a time until the history fits.
480
- # Subclasses can override build_context to apply a different strategy
481
- # (e.g. LLM-based summarisation) before calling super.
482
- keep = [msgs.size / 2, 2].max
483
- msgs = compact_messages(msgs, keep_tail: keep) do |dropped|
484
- "[#{dropped.size} earlier messages omitted]"
485
- end
486
- remaining = assembler.available_for_messages
487
- msgs = trim_to_budget(msgs, remaining: remaining, strategy: :safe)
488
- end
489
-
490
- assembler.add_messages(msgs)
491
- @last_context = assembler.build
492
- end
493
- protected :build_context
494
-
495
- # Keeps the last +keep+ messages from +messages+, discarding older ones.
496
- # Use this inside a +build_context+ override to trim conversation history.
497
- #
498
- # @param messages [Array<RubyLLM::Message>] conversation history
499
- # @param keep [Integer] number of messages to retain (from the tail)
500
- # @return [Array<RubyLLM::Message>]
501
- # @api public
502
- def trim_messages(messages, keep:)
503
- Array(messages).last(keep)
504
- end
505
- protected :trim_messages
506
-
507
- # Removes the oldest messages one at a time until the count is within +limit+.
508
- #
509
- # @param messages [Array<RubyLLM::Message>] conversation history
510
- # @param limit [Integer] maximum number of messages to retain
511
- # @return [Array<RubyLLM::Message>]
512
- # @api public
513
- def drop_messages_over(messages, limit:)
514
- msgs = Array(messages).dup
515
- msgs.shift while msgs.size > limit
516
- msgs
517
- end
518
- protected :drop_messages_over
519
-
520
- # Replaces all but the last +keep_tail+ messages with a single system summary.
521
- # The block receives the dropped messages and must return a summary String.
522
- #
523
- # @param messages [Array<RubyLLM::Message>] conversation history
524
- # @param keep_tail [Integer] number of recent messages to preserve verbatim
525
- # @yield [Array<RubyLLM::Message>] the messages being summarised
526
- # @yieldreturn [String] summary text
527
- # @return [Array<RubyLLM::Message>]
528
- # @api public
529
- def compact_messages(messages, keep_tail:, &summariser)
530
- msgs = Array(messages)
531
- return msgs if msgs.size <= keep_tail
532
- tail = msgs.last(keep_tail)
533
- dropped = msgs.first(msgs.size - keep_tail)
534
- summary_text = summariser.call(dropped)
535
- [RubyLLM::Message.new(role: :system, content: summary_text)] + tail
536
- end
537
- protected :compact_messages
538
-
539
- # Trims +messages+ to fit within +remaining+ tokens using the given
540
- # +strategy+. Returns the trimmed message array without touching the
541
- # assembler. The caller is responsible for passing the result to
542
- # +assembler.add_messages+ and calling +assembler.build+.
543
- #
544
- # Supported strategies:
545
- # +:safe+ — discard the oldest message one at a time (default)
546
- #
547
- # @param messages [Array<RubyLLM::Message>] conversation history
548
- # @param remaining [Integer, nil] token allowance for messages; when +nil+
549
- # the messages are returned unchanged
550
- # @param strategy [Symbol] trim strategy (default +:safe+)
551
- # @return [Array<RubyLLM::Message>]
552
- # @api public
553
- def trim_to_budget(messages, remaining:, strategy: :safe)
554
- return Array(messages) unless remaining
555
- msgs = Array(messages)
556
- loop do
557
- used = msgs.sum { |m| LlmContextWindow::TokenEstimator.estimate(m.content.to_s) }
558
- return msgs if used <= remaining
559
- break if msgs.empty?
560
- msgs = trim_messages(msgs, keep: msgs.size - 1)
561
- end
562
- msgs
563
- end
564
- protected :trim_to_budget
565
-
566
- # Returns +true+ when the estimated token usage of +messages+ exceeds
567
- # +threshold+ times the available context budget.
568
- # Always returns +false+ when no token budget is available.
569
- #
570
- # @param messages [Array<RubyLLM::Message>] conversation history
571
- # @param threshold [Float] fraction of the available budget (default 0.8)
572
- # @return [Boolean]
573
- # @api public
574
- def budget_exceeded?(messages, threshold: 0.8)
575
- return false unless (b = build_token_budget)
576
- total = Array(messages).sum { |m| LlmContextWindow::TokenEstimator.estimate(m.content.to_s) }
577
- limit = b.available(used: 0)
578
- total > limit * threshold
579
- end
580
- protected :budget_exceeded?
581
-
582
- # Registers a per-instance knowledge source. Knowledge chunks from all
583
- # registered sources are included in every LLM call via +build_context+.
584
- #
585
- # @param source [#fetch] any object responding to +fetch(query:)+
586
- # @return [void]
587
- # @api public
588
- def add_knowledge_source(source)
589
- @instance_knowledge_sources ||= []
590
- @instance_knowledge_sources << source
591
- end
592
- protected :add_knowledge_source
593
-
594
- # Returns knowledge chunks fetched from all instance-level knowledge sources.
595
- #
596
- # @return [Array<Hash>]
597
- # @api private
598
- def instance_knowledge_chunks
599
- return [] unless @instance_knowledge_sources
600
- @instance_knowledge_sources.flat_map { |ks| ks.fetch(query: nil) }
601
- end
602
- protected :instance_knowledge_chunks
603
-
604
577
  def _complete_result_task(task, result)
605
578
  task.backend.unblock(result, nil)
606
579
  task.transition!(:completed, value: result)
@@ -617,20 +590,6 @@ module Phronomy
617
590
  translated
618
591
  end
619
592
 
620
- def _build_stream_terminal_event(result)
621
- if result[:suspended]
622
- StreamEvent.new(
623
- type: :approval_required,
624
- payload: {request: result[:approval_request]}
625
- )
626
- else
627
- StreamEvent.new(type: :done, payload: result)
628
- end
629
- end
630
-
631
- # Returns the Application exception instead of allowing it to escape the
632
- # shared EventLoop. A nil return means delivery succeeded or no listener
633
- # was registered.
634
593
  def _deliver_stream_event(listener, event)
635
594
  return unless listener
636
595
 
@@ -695,94 +654,6 @@ module Phronomy
695
654
  nil
696
655
  end
697
656
 
698
- # Continues a suspended AgentInvocation. The parent session is registered
699
- # asynchronously; this method is only the synchronous wrapper.
700
- # @return [Hash]
701
- # @api public
702
- def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
703
- _check_scheduler_reentrancy(:approve, :approve_async)
704
- approve_async(
705
- agent_invocation_id,
706
- approval_request_id: approval_request_id,
707
- approved: approved,
708
- config: config
709
- ).wait_result
710
- end
711
- public :approve
712
-
713
- # Continues a suspended AgentInvocation without blocking the caller.
714
- #
715
- # This method is safe to call from an EventLoop stream callback. The
716
- # returned Task completes when the resumed AgentInvocation finishes,
717
- # suspends again, or fails.
718
- # @return [Phronomy::Task]
719
- # @api public
720
- def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
721
- result_task = Phronomy::Task.deferred(
722
- name: "agent-approval-resume:#{agent_invocation_id}"
723
- )
724
-
725
- begin
726
- entry = Agent::AgentInvocationRegistry.consume_approval(
727
- agent_invocation_id, approval_request_id
728
- )
729
- unless entry
730
- raise ArgumentError,
731
- "No pending approval found for AgentInvocation #{agent_invocation_id}"
732
- end
733
-
734
- _start_approval_resume(
735
- result_task,
736
- entry.invocation,
737
- approved: approved,
738
- config: config
739
- )
740
- rescue => e
741
- _fail_result_task(result_task, e)
742
- end
743
-
744
- result_task
745
- end
746
- public :approve_async
747
-
748
- def _extract_invoke_result(invocation)
749
- if invocation.phase == :suspended
750
- request = invocation.approval_request
751
- Agent::AgentInvocationRegistry.store_suspended(invocation, request)
752
- _dispatch_tool_approval_notification(invocation, request)
753
- {
754
- suspended: true,
755
- agent_invocation_id: invocation.id,
756
- approval_request: request,
757
- messages: invocation.messages
758
- }
759
- elsif invocation.input_blocked? || invocation.output_blocked?
760
- raise invocation.block_error
761
- elsif invocation.error
762
- raise invocation.error
763
- elsif invocation.rejected
764
- {rejected: true, messages: invocation.messages}
765
- else
766
- {output: invocation.output, messages: invocation.messages, usage: invocation.usage}
767
- end
768
- end
769
-
770
- def _dispatch_tool_approval_notification(invocation, request)
771
- listener = invocation.approval_listener
772
- return unless listener
773
-
774
- Phronomy::Runtime.instance.blocking_io.submit(on_full: :raise) do
775
- listener.call(request)
776
- end
777
- rescue => e
778
- message = "[Phronomy] Tool approval notification failed: #{e.class}: #{e.message}"
779
- if Phronomy.configuration.logger
780
- Phronomy.configuration.logger.warn(message)
781
- else
782
- Kernel.warn(message)
783
- end
784
- end
785
-
786
657
  def _approval_configuration_mutex
787
658
  return @approval_configuration_mutex if @approval_configuration_mutex
788
659
 
@@ -811,64 +682,45 @@ module Phronomy
811
682
  meta
812
683
  end
813
684
 
814
- def _apply_context_to_chat(chat, context)
815
- apply_instructions(chat, context[:system]) if context[:system]
816
- (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
817
- context[:messages].each { |msg| chat.messages << msg }
818
- end
819
-
820
- # Builds a TokenBudget for this agent's model if possible.
821
- # When context_window is set at the class level, that value is used directly
822
- # (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
823
- # the loaded context length differs from the catalogue value.
824
- # Returns nil when the model is not registered in RubyLLM (e.g. local/unknown models).
825
- def build_token_budget
826
- model_name = self.class.model
827
- return nil unless model_name
828
-
829
- if (cw = self.class.context_window)
830
- Phronomy::LlmContextWindow::TokenBudget.new(
831
- context_window: cw,
832
- max_output_tokens: self.class.max_output_tokens || 0,
833
- overhead: self.class.context_overhead
834
- )
835
- else
836
- Phronomy::LlmContextWindow::TokenBudget.new(
837
- model: model_name,
838
- max_output_tokens: self.class.max_output_tokens,
839
- overhead: self.class.context_overhead
685
+ def _apply_runtime_projection_to_chat(chat, projection, invocation: nil)
686
+ if projection.system
687
+ apply_instructions(
688
+ chat,
689
+ projection.system,
690
+ cache: projection.model_config["cache_instructions"],
691
+ provider: projection.model_config["provider"]
840
692
  )
841
693
  end
842
- rescue Phronomy::LlmContextWindow::UnknownModelError, RubyLLM::ModelNotFoundError
843
- nil
844
- end
845
-
846
- # Returns the chat class to instantiate for this invocation.
847
- # When {Phronomy.configuration.parallel_tool_execution} is true,
848
- # returns {ParallelToolChat} so that concurrent tool dispatch is enabled.
849
- # Falls back to +nil+ otherwise, signalling {#build_chat} to use the
850
- # standard +RubyLLM.chat+ factory.
851
- def build_chat_class
852
- Phronomy.configuration.parallel_tool_execution ? Phronomy::MultiAgent::ParallelToolChat : nil
694
+ projection.tool_classes.each do |tool_class|
695
+ chat.with_tool(prepare_tool_class(tool_class, invocation: invocation))
696
+ end
697
+ projection.messages.each { |message| chat.messages << message }
698
+ chat
853
699
  end
854
700
 
855
- def build_chat
701
+ def build_chat(model_config: nil)
702
+ config = model_config || {
703
+ "model" => self.class.model,
704
+ "provider" => self.class.provider,
705
+ "temperature" => self.class.temperature,
706
+ "max_output_tokens" => self.class.max_output_tokens,
707
+ "parallel_tool_execution" => Phronomy.configuration.parallel_tool_execution
708
+ }
856
709
  opts = {}
857
- m = self.class.model
858
- opts[:model] = m if m
859
- p = self.class.provider
860
- if p
861
- opts[:provider] = p
710
+ model = config["model"]
711
+ opts[:model] = model if model
712
+ provider = config["provider"]
713
+ if provider
714
+ opts[:provider] = provider.to_sym
862
715
  opts[:assume_model_exists] = true
863
716
  end
864
- t = self.class.temperature
865
- parallel_class = build_chat_class
866
- chat = if parallel_class
867
- parallel_class.new(**opts)
868
- else
869
- RubyLLM.chat(**opts)
717
+ parallel_class = config["parallel_tool_execution"] ?
718
+ Phronomy::MultiAgent::ParallelToolChat : nil
719
+ chat = parallel_class ? parallel_class.new(**opts) : RubyLLM.chat(**opts)
720
+ chat.with_temperature(config["temperature"]) if config["temperature"]
721
+ if config["max_output_tokens"] && chat.respond_to?(:with_max_output_tokens)
722
+ chat.with_max_output_tokens(config["max_output_tokens"])
870
723
  end
871
- chat.with_temperature(t) if t
872
724
  chat
873
725
  end
874
726
 
@@ -884,12 +736,8 @@ module Phronomy
884
736
  end
885
737
  end
886
738
 
887
- # Applies system instructions to a chat object.
888
- # When cache_instructions is enabled and the provider is Anthropic,
889
- # attaches a cache_control marker so that the fixed system prompt is
890
- # eligible for prompt caching.
891
- def apply_instructions(chat, text)
892
- if self.class.cache_instructions && anthropic_provider?
739
+ def apply_instructions(chat, text, cache: false, provider: nil)
740
+ if cache && provider.to_s == "anthropic"
893
741
  content = RubyLLM::Providers::Anthropic::Content.new(text, cache: true)
894
742
  chat.with_instructions(content)
895
743
  else
@@ -897,14 +745,6 @@ module Phronomy
897
745
  end
898
746
  end
899
747
 
900
- # Returns true when this agent explicitly declares `provider :anthropic`.
901
- # Provider is intentionally checked via the DSL value rather than inferred
902
- # from the model name, because cache_control format is API-endpoint-specific
903
- # (Anthropic direct vs. Bedrock vs. OpenRouter all differ).
904
- def anthropic_provider?
905
- self.class.provider == :anthropic
906
- end
907
-
908
748
  def extract_message(input)
909
749
  case input
910
750
  when String then input
@@ -913,29 +753,26 @@ module Phronomy
913
753
  end
914
754
  end
915
755
 
916
- # Raises CancellationError if the cancellation_token in config is cancelled.
917
- # No-op when config has no cancellation_token or the token is not cancelled.
918
- #
919
- # @param config [Hash] the invocation config hash
920
- # @param message [String] the message for the CancellationError
921
- # @raise [Phronomy::CancellationError]
922
- # @api public
923
756
  def check_cancellation!(config, message = "invocation cancelled")
757
+ timeout_deadline = config[:phronomy_timeout_deadline]
758
+ raise Phronomy::TimeoutError, message if timeout_deadline&.expired?
759
+
924
760
  ct = config[:cancellation_token]
925
- raise Phronomy::CancellationError, message if ct&.cancelled?
761
+ return unless ct&.cancelled?
762
+
763
+ if ct.respond_to?(:remaining_monotonic_seconds) &&
764
+ ct.remaining_monotonic_seconds == 0.0
765
+ raise Phronomy::TimeoutError, message
766
+ end
767
+ raise Phronomy::CancellationError, message
926
768
  end
927
769
 
928
- # Builds the final Tool class to register with RubyLLM. Alias and Tool
929
- # result filters remain wrappers; authorization is handled only by
930
- # ToolInvocation before Tool#call begins.
931
- def prepare_tool_class(tool_class)
770
+ def prepare_tool_class(tool_class, invocation: nil)
932
771
  return tool_class unless tool_class.is_a?(Class)
933
772
 
934
773
  resolved = if (alias_name = self.class.tool_aliases[tool_class])
935
- parent_description = tool_class.description
936
774
  Class.new(tool_class) do
937
775
  tool_name alias_name
938
- description parent_description if parent_description
939
776
  end
940
777
  else
941
778
  tool_class