phronomy 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +54 -0
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +124 -92
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +1 -1
  10. data/benchmark/bench_tool_schema.rb +2 -35
  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 +2 -2
  14. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  15. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  16. data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
  17. data/lib/phronomy/agent/agent_root.rb +1 -2
  18. data/lib/phronomy/agent/base.rb +135 -314
  19. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  20. data/lib/phronomy/agent/context_assembler.rb +65 -29
  21. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  22. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  23. data/lib/phronomy/agent/execution_coordinator.rb +0 -1
  24. data/lib/phronomy/agent/journal_projection.rb +28 -2
  25. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  26. data/lib/phronomy/agent/shared_state.rb +46 -138
  27. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  28. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  29. data/lib/phronomy/agent.rb +6 -10
  30. data/lib/phronomy/configuration.rb +15 -158
  31. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  32. data/lib/phronomy/engine/runtime.rb +15 -230
  33. data/lib/phronomy/engine/task_group.rb +30 -102
  34. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  35. data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
  36. data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
  37. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  38. data/lib/phronomy/version.rb +1 -1
  39. data/lib/phronomy.rb +3 -120
  40. data/scripts/api_snapshot.rb +1 -12
  41. metadata +3 -9
  42. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  43. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  44. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  45. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  46. data/lib/phronomy/knowledge_source.rb +0 -12
  47. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  48. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
@@ -10,7 +10,7 @@ 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
@@ -27,7 +27,7 @@ module Phronomy
27
27
  # agent_definition id: "research-agent", version: 1
28
28
  # model "gpt-4o"
29
29
  # instructions "You are a research assistant."
30
- # tools WebSearchTool, CalculatorTool
30
+ # tools(WebSearchTool => nil, CalculatorTool => nil)
31
31
  # max_iterations 15
32
32
  # end
33
33
  class Base
@@ -86,40 +86,34 @@ module Phronomy
86
86
 
87
87
  # Registers tool classes for this agent.
88
88
  #
89
- # Accepts either a splat of classes (backward-compatible) or a Hash mapping
90
- # each class to an explicit alias name (String) or nil (use tool's own name).
91
- # The alias form is useful when two tools share the same auto-generated name
92
- # (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.
93
92
  #
94
- # @example Splat form (no alias)
95
- # tools WeatherTool, TimeTool
96
- #
97
- # @example Hash form (with optional per-tool alias)
93
+ # @example
98
94
  # tools(
99
95
  # Weather::SearchTool => "weather_search",
100
96
  # Places::SearchTool => "places_search",
101
97
  # CurrentTimeTool => nil
102
98
  # )
103
99
  # @api public
104
- def tools(*args)
105
- if args.empty?
106
- if instance_variable_defined?(:@tools)
107
- return @tools
108
- end
100
+ def tools(definitions = nil)
101
+ if definitions.nil?
102
+ return @tools if instance_variable_defined?(:@tools)
109
103
  return superclass.respond_to?(:tools) ? superclass.tools : []
110
104
  end
111
105
 
112
- if args.length == 1 && args.first.is_a?(Hash)
113
- hash = args.first
114
- @tools = hash.keys
115
- @tool_aliases = hash.transform_values { |v| v&.to_s }.reject { |_, v| v.nil? }
116
- else
117
- @tools = args
118
- @tool_aliases = {}
106
+ unless definitions.is_a?(Hash)
107
+ raise ArgumentError,
108
+ "tools expects a Hash of ToolClass => alias_or_nil"
119
109
  end
110
+
111
+ @tools = definitions.keys
112
+ @tool_aliases = definitions.transform_values { |value| value&.to_s }
113
+ .reject { |_, value| value.nil? }
120
114
  end
121
115
 
122
- # Returns the alias map registered via the hash form of .tools.
116
+ # Returns the alias map registered via .tools.
123
117
  # Merges parent class aliases so subclasses inherit their parent's mappings.
124
118
  # Subclass-specific aliases take precedence over parent aliases.
125
119
  # @return [Hash{Class => String}]
@@ -190,73 +184,9 @@ module Phronomy
190
184
  end
191
185
  end
192
186
 
193
- # Registers one or more static knowledge sources on the agent class.
194
- # Static source content is fetched and memoized at the **class** level
195
- # the first time +invoke+ is called. The cache persists for the lifetime
196
- # of the process; call {.static_knowledge_refresh!} to force a reload.
197
- #
198
- # @param sources [Array<Phronomy::Agent::Context::Knowledge::Base>]
199
- # @example
200
- # class PolicyAgent < Phronomy::Agent::Base
201
- # static_knowledge Phronomy::Agent::Context::Knowledge::StaticKnowledge.new(POLICY_TEXT)
202
- # end
203
- # @api public
204
- def static_knowledge(*sources)
205
- @static_knowledge_sources = sources.flatten
206
- # Invalidate the cached chunks so the new sources are fetched on
207
- # the next call to static_knowledge_chunks.
208
- @static_knowledge_chunks = nil
209
- end
210
-
211
- # Returns the registered static knowledge sources.
212
- # @return [Array<Phronomy::Agent::Context::Knowledge::Base>]
213
- # @api public
214
- def static_knowledge_sources
215
- @static_knowledge_sources || []
216
- end
217
-
218
- # Returns the fetched content from all static knowledge sources.
219
- # Results are cached at the class level so that each source is fetched
220
- # only once regardless of how many times the agent is invoked.
221
- # @return [Array<Hash>]
222
- # @api public
223
- def static_knowledge_chunks
224
- @static_knowledge_chunks ||= static_knowledge_sources.flat_map { |ks|
225
- ks.fetch(query: nil)
226
- }
227
- end
228
-
229
- # Clears the class-level knowledge cache so that the next +invoke+ call
230
- # re-fetches content from all registered static knowledge sources.
231
- #
232
- # Call this method when the underlying knowledge source has been updated
233
- # at runtime (e.g. a file was rewritten, a DB record changed) and you
234
- # want the agent to pick up the new content without restarting the
235
- # process.
236
- #
237
- # @return [nil]
238
- # @example Refresh after updating a knowledge file
239
- # MyAgent.static_knowledge_refresh!
240
- # @api public
241
- def static_knowledge_refresh!
242
- @static_knowledge_chunks = nil
243
- end
244
-
245
187
  # When enabled, attaches Anthropic prompt-cache markers to the system
246
188
  # message so that the fixed instructions are served from cache on
247
189
  # subsequent turns, reducing input-token costs.
248
- #
249
- # Only has an effect when the agent also declares `provider :anthropic`.
250
- # The cache_control field is provider-specific (the format differs
251
- # between Anthropic direct, Bedrock, etc.), so the agent must explicitly
252
- # declare its provider via the DSL rather than having it inferred from
253
- # the model name.
254
- #
255
- # @example
256
- # class MyAgent < Phronomy::Agent::Base
257
- # provider :anthropic
258
- # cache_instructions true
259
- # end
260
190
  # @api public
261
191
  def cache_instructions(enabled = nil)
262
192
  if enabled.nil?
@@ -268,11 +198,6 @@ module Phronomy
268
198
 
269
199
  # Tokens to reserve for the model's output.
270
200
  # When nil, the model's max_output_tokens from the registry is used.
271
- #
272
- # @example
273
- # class MyAgent < Phronomy::Agent::Base
274
- # max_output_tokens 4096
275
- # end
276
201
  # @api public
277
202
  def max_output_tokens(val = nil)
278
203
  if val.nil?
@@ -283,14 +208,6 @@ module Phronomy
283
208
  end
284
209
 
285
210
  # Overrides the context window size used for token budget calculations.
286
- # When set, this value takes precedence over the RubyLLM model registry,
287
- # which is useful for locally-hosted models (e.g. LM Studio) where the
288
- # actually-loaded context length may differ from the catalogue value.
289
- #
290
- # @example
291
- # class MyAgent < Phronomy::Agent::Base
292
- # context_window 4096
293
- # end
294
211
  # @api public
295
212
  def context_window(val = nil)
296
213
  if val.nil?
@@ -300,23 +217,6 @@ module Phronomy
300
217
  end
301
218
  end
302
219
 
303
- # Tokens reserved in the legacy build_context path only.
304
- # Manifest-first assembly ignores this value because
305
- # ContextAssembler estimates actual mandatory content for each LLM Call.
306
- #
307
- # @example
308
- # class MyAgent < Phronomy::Agent::Base
309
- # context_overhead 500
310
- # end
311
- # @api public
312
- def context_overhead(val = nil)
313
- if val.nil?
314
- @context_overhead || 0
315
- else
316
- @context_overhead = val.to_i
317
- end
318
- end
319
-
320
220
  # Defines or reads the stable Agent definition identity.
321
221
  # Subclass with no explicit declaration inherits the parent's definition.
322
222
  def agent_definition(id: nil, version: nil)
@@ -326,7 +226,6 @@ module Phronomy
326
226
  end
327
227
  return @agent_definition if @agent_definition
328
228
 
329
- # Walk ancestors to support anonymous runtime subclasses and abstract bases.
330
229
  klass = superclass
331
230
  while klass.respond_to?(:agent_definition, true) &&
332
231
  klass < Phronomy::Agent::Base
@@ -339,8 +238,14 @@ module Phronomy
339
238
  "#{name || self} must declare agent_definition id: ..., version: ..."
340
239
  end
341
240
 
342
- def create(agent_id: SecureRandom.uuid, context: nil, persistence: nil, metadata: {})
343
- new(agent_id: agent_id, context: context, persistence: persistence, metadata: metadata)
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
+ )
344
249
  end
345
250
 
346
251
  def load(agent_id, persistence:)
@@ -373,6 +278,7 @@ module Phronomy
373
278
  def initialize(
374
279
  agent_id: SecureRandom.uuid,
375
280
  context: nil,
281
+ knowledge: [],
376
282
  persistence: nil,
377
283
  metadata: {},
378
284
  load_existing: false
@@ -391,7 +297,7 @@ module Phronomy
391
297
  end
392
298
  loaded
393
299
  else
394
- create_agent_root!(context: context, metadata: metadata)
300
+ create_agent_root!(context: context, knowledge: knowledge, metadata: metadata)
395
301
  end
396
302
  end
397
303
 
@@ -417,23 +323,56 @@ module Phronomy
417
323
  end
418
324
  end
419
325
 
420
- def clear_memory!
421
- mutate_context!(:memory_cleared) do |root|
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|
422
330
  root.with(
423
331
  agent_revision: root.agent_revision + 1,
424
- context_revision: root.context_revision + 1,
425
- memory_generation: root.memory_generation + 1
332
+ context_revision: root.context_revision + 1
426
333
  )
427
334
  end
428
335
  end
429
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
+
430
370
  def reset_context!
431
371
  mutate_context!(:context_reset) do |root|
432
372
  root.with(
433
373
  agent_revision: root.agent_revision + 1,
434
374
  context_revision: root.context_revision + 1,
435
- transcript_generation: root.transcript_generation + 1,
436
- memory_generation: root.memory_generation + 1
375
+ transcript_generation: root.transcript_generation + 1
437
376
  )
438
377
  end
439
378
  end
@@ -465,7 +404,7 @@ module Phronomy
465
404
 
466
405
  private
467
406
 
468
- def create_agent_root!(context:, metadata:)
407
+ def create_agent_root!(context:, knowledge:, metadata:)
469
408
  definition = self.class.agent_definition
470
409
  root = Agent::AgentRoot.create(
471
410
  agent_id: agent_id,
@@ -475,32 +414,13 @@ module Phronomy
475
414
  )
476
415
  persistence.transaction do |tx|
477
416
  tx.agents.create(root)
478
- if context
479
- imported = context.respond_to?(:records) ? context :
480
- Agent::ContextImporter.import_messages(context)
481
- records = imported.records.map do |record|
482
- content_ref = case record.content_format
483
- when :text then tx.contents.put_text(record.content)
484
- when :json then tx.contents.put_json(record.content)
485
- else
486
- raise ArgumentError,
487
- "unsupported imported content format: #{record.content_format.inspect}"
488
- end
489
- Agent::JournalRecord.new(
490
- agent_id: agent_id,
491
- kind: record.kind,
492
- channel: record.channel,
493
- role: record.role,
494
- content_ref: content_ref,
495
- context_generation: root.transcript_generation,
496
- context_candidate: true,
497
- metadata: record.metadata
498
- )
499
- end
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?
500
420
  appended = tx.journals.append(agent_id, expected_position: 0, records: records)
501
421
  root = root.with(
502
422
  agent_revision: 1,
503
- context_revision: records.any? ? 1 : 0,
423
+ context_revision: 1,
504
424
  journal_position: appended.length
505
425
  )
506
426
  tx.agents.save(agent_id, expected_revision: 0, root: root)
@@ -509,6 +429,56 @@ module Phronomy
509
429
  root
510
430
  end
511
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
+
512
482
  def mutate_context!(kind, context_affecting: true)
513
483
  next_root = nil
514
484
  persistence.transaction do |tx|
@@ -541,36 +511,18 @@ module Phronomy
541
511
  (proposed.context_revision == current.context_revision) ? current.context_revision + 1 : proposed.context_revision
542
512
  end
543
513
 
544
- def ensure_no_active_execution!
545
- return if persistence.executions.list_active(agent_id).empty?
546
- raise Phronomy::AgentBusyError, "agent has an active or suspended execution: #{agent_id}"
547
- end
548
-
549
514
  public
550
515
 
551
- # Registers an anonymous handoff tool class on this agent instance.
552
- # Called by Runner during construction when routes are configured.
553
- # @param tool_class [Class<Phronomy::Agent::Context::Capability::Base>]
554
- # @return [self]
555
- # @api private
556
516
  def _add_handoff_tool(tool_class)
557
517
  @_handoff_tools ||= []
558
518
  @_handoff_tools << tool_class
559
519
  self
560
520
  end
561
521
 
562
- # Returns handoff tool classes registered on this instance by Runner.
563
- # @return [Array<Class>]
564
- # @api private
565
522
  def _handoff_tools
566
523
  @_handoff_tools || []
567
524
  end
568
525
 
569
- # Registers the final Agent/Application authorization policy.
570
- # The block runs on the Runtime authorization pool and must return
571
- # :allow, :require_approval, or :reject.
572
- # @return [self]
573
- # @api public
574
526
  def tool_approval_policy(&block)
575
527
  raise ArgumentError, "tool_approval_policy requires a block" unless block
576
528
 
@@ -578,9 +530,6 @@ module Phronomy
578
530
  self
579
531
  end
580
532
 
581
- # Registers a non-blocking Application notification listener.
582
- # @return [self]
583
- # @api public
584
533
  def on_tool_approval_required(&block)
585
534
  raise ArgumentError, "on_tool_approval_required requires a block" unless block
586
535
 
@@ -590,19 +539,15 @@ module Phronomy
590
539
 
591
540
  private
592
541
 
593
- # Merges an {InvocationContext} into the +thread_id+ / +config+ pair.
594
- # Returns +[effective_thread_id, effective_config]+.
595
- #
596
- # Precedence rules (existing explicit values always win):
597
- # - +thread_id+ argument > +ic.thread_id+
598
- # - +config[:cancellation_token]+ > +ic.cancellation_token+ > token derived from +ic.deadline+
599
- # - +ic+ is stored in +config[:invocation_context]+ (overwriting any previous value)
600
542
  def _apply_invocation_context(thread_id, config, ic)
601
543
  effective_thread_id = thread_id || ic.thread_id
602
544
  effective_config = config.merge(invocation_context: ic)
603
545
  if effective_config[:cancellation_token].nil?
604
546
  if (tok = ic.effective_timeout_token)
605
- 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
+ )
606
551
  end
607
552
  end
608
553
  [effective_thread_id, effective_config]
@@ -629,28 +574,6 @@ module Phronomy
629
574
  end
630
575
  end
631
576
 
632
- # Registers a per-instance knowledge source. Knowledge chunks from all
633
- # registered sources are included in every LLM call via the Context Policy.
634
- #
635
- # @param source [#fetch] any object responding to +fetch(query:)+
636
- # @return [void]
637
- # @api public
638
- def add_knowledge_source(source)
639
- @instance_knowledge_sources ||= []
640
- @instance_knowledge_sources << source
641
- end
642
- protected :add_knowledge_source
643
-
644
- # Returns knowledge chunks fetched from all instance-level knowledge sources.
645
- #
646
- # @return [Array<Hash>]
647
- # @api private
648
- def instance_knowledge_chunks
649
- return [] unless @instance_knowledge_sources
650
- @instance_knowledge_sources.flat_map { |ks| ks.fetch(query: nil) }
651
- end
652
- protected :instance_knowledge_chunks
653
-
654
577
  def _complete_result_task(task, result)
655
578
  task.backend.unblock(result, nil)
656
579
  task.transition!(:completed, value: result)
@@ -667,20 +590,6 @@ module Phronomy
667
590
  translated
668
591
  end
669
592
 
670
- def _build_stream_terminal_event(result)
671
- if result[:suspended]
672
- StreamEvent.new(
673
- type: :approval_required,
674
- payload: {request: result[:approval_request]}
675
- )
676
- else
677
- StreamEvent.new(type: :done, payload: result)
678
- end
679
- end
680
-
681
- # Returns the Application exception instead of allowing it to escape the
682
- # shared EventLoop. A nil return means delivery succeeded or no listener
683
- # was registered.
684
593
  def _deliver_stream_event(listener, event)
685
594
  return unless listener
686
595
 
@@ -773,22 +682,7 @@ module Phronomy
773
682
  meta
774
683
  end
775
684
 
776
- def _apply_context_to_chat(chat, context)
777
- model_config = context[:model_config] || {}
778
- if context[:system]
779
- apply_instructions(
780
- chat,
781
- context[:system],
782
- cache: model_config["cache_instructions"],
783
- provider: model_config["provider"]
784
- )
785
- end
786
- (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
787
- context[:messages].each { |msg| chat.messages << msg }
788
- end
789
-
790
- def _replace_chat_messages(chat, projection)
791
- chat.messages.clear
685
+ def _apply_runtime_projection_to_chat(chat, projection, invocation: nil)
792
686
  if projection.system
793
687
  apply_instructions(
794
688
  chat,
@@ -797,63 +691,13 @@ module Phronomy
797
691
  provider: projection.model_config["provider"]
798
692
  )
799
693
  end
694
+ projection.tool_classes.each do |tool_class|
695
+ chat.with_tool(prepare_tool_class(tool_class, invocation: invocation))
696
+ end
800
697
  projection.messages.each { |message| chat.messages << message }
801
698
  chat
802
699
  end
803
700
 
804
- # Builds a TokenBudget for this agent's model if possible.
805
- # When context_window is set at the class level, that value is used directly
806
- # (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
807
- # the loaded context length differs from the catalogue value.
808
- # Returns nil when the model is not registered in RubyLLM (e.g. local/unknown models).
809
- def build_token_budget
810
- model_name = self.class.model
811
- return nil unless model_name
812
-
813
- if (cw = self.class.context_window)
814
- Phronomy::LlmContextWindow::TokenBudget.new(
815
- context_window: cw,
816
- max_output_tokens: self.class.max_output_tokens || 0,
817
- overhead: self.class.context_overhead
818
- )
819
- else
820
- ruby_llm_model = RubyLLM.models.find(model_name)
821
- return nil unless ruby_llm_model
822
-
823
- registry_context = ruby_llm_model.context_window.to_i
824
- registry_max_output = ruby_llm_model.max_output_tokens.to_i
825
-
826
- # Priority: agent explicit → framework default → registry (if < context_window)
827
- output_reserve =
828
- self.class.max_output_tokens ||
829
- Phronomy.configuration.default_output_reserve ||
830
- ((registry_max_output < registry_context) ? registry_max_output : nil)
831
-
832
- if output_reserve.nil?
833
- raise Phronomy::InvalidContextBudgetConfigurationError,
834
- "Cannot determine output token reserve for model '#{model_name}'. " \
835
- "Set max_output_tokens on the agent or Phronomy.configure { |c| c.default_output_reserve = N }."
836
- end
837
-
838
- Phronomy::LlmContextWindow::TokenBudget.new(
839
- context_window: registry_context,
840
- max_output_tokens: output_reserve,
841
- overhead: self.class.context_overhead
842
- )
843
- end
844
- rescue Phronomy::LlmContextWindow::UnknownModelError, RubyLLM::ModelNotFoundError
845
- nil
846
- end
847
-
848
- # Returns the chat class to instantiate for this invocation.
849
- # When {Phronomy.configuration.parallel_tool_execution} is true,
850
- # returns {ParallelToolChat} so that concurrent tool dispatch is enabled.
851
- # Falls back to +nil+ otherwise, signalling {#build_chat} to use the
852
- # standard +RubyLLM.chat+ factory.
853
- def build_chat_class
854
- Phronomy.configuration.parallel_tool_execution ? Phronomy::MultiAgent::ParallelToolChat : nil
855
- end
856
-
857
701
  def build_chat(model_config: nil)
858
702
  config = model_config || {
859
703
  "model" => self.class.model,
@@ -892,10 +736,6 @@ module Phronomy
892
736
  end
893
737
  end
894
738
 
895
- # Applies system instructions to a chat object.
896
- # When cache_instructions is enabled and the provider is Anthropic,
897
- # attaches a cache_control marker so that the fixed system prompt is
898
- # eligible for prompt caching.
899
739
  def apply_instructions(chat, text, cache: false, provider: nil)
900
740
  if cache && provider.to_s == "anthropic"
901
741
  content = RubyLLM::Providers::Anthropic::Content.new(text, cache: true)
@@ -905,14 +745,6 @@ module Phronomy
905
745
  end
906
746
  end
907
747
 
908
- # Returns true when this agent explicitly declares `provider :anthropic`.
909
- # Provider is intentionally checked via the DSL value rather than inferred
910
- # from the model name, because cache_control format is API-endpoint-specific
911
- # (Anthropic direct vs. Bedrock vs. OpenRouter all differ).
912
- def anthropic_provider?
913
- self.class.provider == :anthropic
914
- end
915
-
916
748
  def extract_message(input)
917
749
  case input
918
750
  when String then input
@@ -921,37 +753,26 @@ module Phronomy
921
753
  end
922
754
  end
923
755
 
924
- # Raises CancellationError if the cancellation_token in config is cancelled.
925
- # No-op when config has no cancellation_token or the token is not cancelled.
926
- #
927
- # @param config [Hash] the invocation config hash
928
- # @param message [String] the message for the CancellationError
929
- # @raise [Phronomy::CancellationError]
930
- # @api public
931
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
+
932
760
  ct = config[:cancellation_token]
933
761
  return unless ct&.cancelled?
934
762
 
935
- # Deadline expiry is a timeout; explicit cancel! is a cancellation.
936
- if (ct.respond_to?(:deadline) && ct.deadline && Time.now >= ct.deadline) ||
937
- (ct.respond_to?(:remaining_monotonic_seconds) &&
938
- ct.remaining_monotonic_seconds == 0.0)
763
+ if ct.respond_to?(:remaining_monotonic_seconds) &&
764
+ ct.remaining_monotonic_seconds == 0.0
939
765
  raise Phronomy::TimeoutError, message
940
766
  end
941
767
  raise Phronomy::CancellationError, message
942
768
  end
943
769
 
944
- # Builds the final Tool class to register with RubyLLM. Alias and Tool
945
- # result filters remain wrappers; authorization is handled only by
946
- # ToolInvocation before Tool#call begins.
947
- def prepare_tool_class(tool_class)
770
+ def prepare_tool_class(tool_class, invocation: nil)
948
771
  return tool_class unless tool_class.is_a?(Class)
949
772
 
950
773
  resolved = if (alias_name = self.class.tool_aliases[tool_class])
951
- parent_description = tool_class.description
952
774
  Class.new(tool_class) do
953
775
  tool_name alias_name
954
- description parent_description if parent_description
955
776
  end
956
777
  else
957
778
  tool_class