phronomy 0.15.1 → 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 +105 -28
  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 -475
  16. data/lib/phronomy/agent/base.rb +301 -285
  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 -7
  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
@@ -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
@@ -15,6 +15,7 @@ module Phronomy
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,6 +24,7 @@ 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
30
  # tools WebSearchTool, CalculatorTool
@@ -31,7 +33,7 @@ module Phronomy
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
@@ -298,8 +300,9 @@ module Phronomy
298
300
  end
299
301
  end
300
302
 
301
- # Tokens reserved for the system prompt + tool definitions overhead.
302
- # Subtract this from the context window before computing the memory budget.
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.
303
306
  #
304
307
  # @example
305
308
  # class MyAgent < Phronomy::Agent::Base
@@ -314,31 +317,50 @@ module Phronomy
314
317
  end
315
318
  end
316
319
 
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,
320
+ # Defines or reads the stable Agent definition identity.
321
+ # Subclass with no explicit declaration inherits the parent's definition.
322
+ def agent_definition(id: nil, version: nil)
323
+ if id || version
324
+ raise ArgumentError, "agent_definition requires id: and version:" unless id && version
325
+ @agent_definition = {id: id.to_s.freeze, version: Integer(version)}.freeze
326
+ end
327
+ return @agent_definition if @agent_definition
328
+
329
+ # Walk ancestors to support anonymous runtime subclasses and abstract bases.
330
+ klass = superclass
331
+ while klass.respond_to?(:agent_definition, true) &&
332
+ klass < Phronomy::Agent::Base
333
+ defn = klass.instance_variable_get(:@agent_definition)
334
+ return defn if defn
335
+ klass = klass.superclass
336
+ end
337
+
338
+ raise Phronomy::ConfigurationError,
339
+ "#{name || self} must declare agent_definition id: ..., version: ..."
340
+ end
341
+
342
+ def create(agent_id: SecureRandom.uuid, context: nil, persistence: nil, metadata: {})
343
+ new(agent_id: agent_id, context: context, persistence: persistence, metadata: metadata)
344
+ end
345
+
346
+ def load(agent_id, persistence:)
347
+ new(agent_id: agent_id, persistence: persistence, load_existing: true)
348
+ end
349
+
350
+ def approve(execution_id, approval_request_id:, persistence:, approved: true, config: {})
351
+ approve_async(
352
+ execution_id,
326
353
  approval_request_id: approval_request_id,
327
354
  approved: approved,
328
- config: config
329
- )
355
+ config: config,
356
+ persistence: persistence
357
+ ).wait_result
330
358
  end
331
359
 
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,
360
+ def approve_async(execution_id, approval_request_id:, persistence:, approved: true, config: {})
361
+ execution = persistence.executions.load(execution_id)
362
+ load(execution.agent_id, persistence: persistence).approve_async(
363
+ execution_id,
342
364
  approval_request_id: approval_request_id,
343
365
  approved: approved,
344
366
  config: config
@@ -346,6 +368,186 @@ module Phronomy
346
368
  end
347
369
  end
348
370
 
371
+ attr_reader :agent_id, :persistence
372
+
373
+ def initialize(
374
+ agent_id: SecureRandom.uuid,
375
+ context: nil,
376
+ persistence: nil,
377
+ metadata: {},
378
+ load_existing: false
379
+ )
380
+ @persistence = persistence || Phronomy::Persistence::InMemory.new
381
+ @agent_id = agent_id.to_s.freeze
382
+ @root = if load_existing
383
+ loaded = @persistence.agents.load(@agent_id)
384
+ definition = self.class.agent_definition
385
+ unless loaded.agent_definition_id == definition.fetch(:id) &&
386
+ loaded.definition_version == definition.fetch(:version)
387
+ raise Phronomy::ConfigurationError,
388
+ "Agent definition mismatch for #{@agent_id}: stored " \
389
+ "#{loaded.agent_definition_id}@#{loaded.definition_version}, runtime " \
390
+ "#{definition.fetch(:id)}@#{definition.fetch(:version)}"
391
+ end
392
+ loaded
393
+ else
394
+ create_agent_root!(context: context, metadata: metadata)
395
+ end
396
+ end
397
+
398
+ def agent_root
399
+ @root
400
+ end
401
+
402
+ def journal_projection
403
+ Agent::JournalProjection.new(persistence: persistence, agent_root: @root)
404
+ end
405
+
406
+ def transcript
407
+ journal_projection.transcript_records
408
+ end
409
+
410
+ def clear_transcript!
411
+ mutate_context!(:transcript_cleared) do |root|
412
+ root.with(
413
+ agent_revision: root.agent_revision + 1,
414
+ context_revision: root.context_revision + 1,
415
+ transcript_generation: root.transcript_generation + 1
416
+ )
417
+ end
418
+ end
419
+
420
+ def clear_memory!
421
+ mutate_context!(:memory_cleared) do |root|
422
+ root.with(
423
+ agent_revision: root.agent_revision + 1,
424
+ context_revision: root.context_revision + 1,
425
+ memory_generation: root.memory_generation + 1
426
+ )
427
+ end
428
+ end
429
+
430
+ def reset_context!
431
+ mutate_context!(:context_reset) do |root|
432
+ root.with(
433
+ agent_revision: root.agent_revision + 1,
434
+ context_revision: root.context_revision + 1,
435
+ transcript_generation: root.transcript_generation + 1,
436
+ memory_generation: root.memory_generation + 1
437
+ )
438
+ end
439
+ end
440
+
441
+ def close!
442
+ mutate_context!(:agent_closed, context_affecting: false) do |root|
443
+ root.with(
444
+ agent_revision: root.agent_revision + 1,
445
+ lifecycle_status: :closed
446
+ )
447
+ end
448
+ end
449
+
450
+ def purge!
451
+ persistence.transaction do |tx|
452
+ tx.executions.assert_idle!(agent_id)
453
+ tx.journals.delete(agent_id)
454
+ tx.executions.delete_for_agent(agent_id)
455
+ tx.agents.delete(agent_id)
456
+ end
457
+ @root = nil
458
+ true
459
+ end
460
+
461
+ # Internal hook used after a successful Persistence transaction.
462
+ def __replace_root(root)
463
+ @root = root
464
+ end
465
+
466
+ private
467
+
468
+ def create_agent_root!(context:, metadata:)
469
+ definition = self.class.agent_definition
470
+ root = Agent::AgentRoot.create(
471
+ agent_id: agent_id,
472
+ agent_definition_id: definition.fetch(:id),
473
+ definition_version: definition.fetch(:version),
474
+ metadata: metadata
475
+ )
476
+ persistence.transaction do |tx|
477
+ 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
500
+ appended = tx.journals.append(agent_id, expected_position: 0, records: records)
501
+ root = root.with(
502
+ agent_revision: 1,
503
+ context_revision: records.any? ? 1 : 0,
504
+ journal_position: appended.length
505
+ )
506
+ tx.agents.save(agent_id, expected_revision: 0, root: root)
507
+ end
508
+ end
509
+ root
510
+ end
511
+
512
+ def mutate_context!(kind, context_affecting: true)
513
+ next_root = nil
514
+ persistence.transaction do |tx|
515
+ tx.executions.assert_idle!(agent_id)
516
+ current = tx.agents.load(agent_id)
517
+ record = Agent::JournalRecord.new(
518
+ agent_id: agent_id,
519
+ kind: kind,
520
+ channel: :state,
521
+ context_generation: current.transcript_generation,
522
+ context_candidate: false
523
+ )
524
+ appended = tx.journals.append(
525
+ agent_id,
526
+ expected_position: current.journal_position,
527
+ records: [record]
528
+ )
529
+ proposed = yield(current)
530
+ next_root = proposed.with(
531
+ journal_position: current.journal_position + appended.length,
532
+ context_revision: context_affecting ?
533
+ yield_context_revision(current, proposed) : current.context_revision
534
+ )
535
+ tx.agents.save(agent_id, expected_revision: current.agent_revision, root: next_root)
536
+ end
537
+ @root = next_root
538
+ end
539
+
540
+ def yield_context_revision(current, proposed)
541
+ (proposed.context_revision == current.context_revision) ? current.context_revision + 1 : proposed.context_revision
542
+ end
543
+
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
+ public
550
+
349
551
  # Registers an anonymous handoff tool class on this agent instance.
350
552
  # Called by Runner during construction when routes are configured.
351
553
  # @param tool_class [Class<Phronomy::Agent::Context::Capability::Base>]
@@ -386,13 +588,6 @@ module Phronomy
386
588
  self
387
589
  end
388
590
 
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
591
  private
397
592
 
398
593
  # Merges an {InvocationContext} into the +thread_id+ / +config+ pair.
@@ -434,153 +629,8 @@ module Phronomy
434
629
  end
435
630
  end
436
631
 
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
632
  # Registers a per-instance knowledge source. Knowledge chunks from all
583
- # registered sources are included in every LLM call via +build_context+.
633
+ # registered sources are included in every LLM call via the Context Policy.
584
634
  #
585
635
  # @param source [#fetch] any object responding to +fetch(query:)+
586
636
  # @return [void]
@@ -695,94 +745,6 @@ module Phronomy
695
745
  nil
696
746
  end
697
747
 
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
748
  def _approval_configuration_mutex
787
749
  return @approval_configuration_mutex if @approval_configuration_mutex
788
750
 
@@ -812,11 +774,33 @@ module Phronomy
812
774
  end
813
775
 
814
776
  def _apply_context_to_chat(chat, context)
815
- apply_instructions(chat, context[:system]) if context[:system]
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
816
786
  (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
817
787
  context[:messages].each { |msg| chat.messages << msg }
818
788
  end
819
789
 
790
+ def _replace_chat_messages(chat, projection)
791
+ chat.messages.clear
792
+ if projection.system
793
+ apply_instructions(
794
+ chat,
795
+ projection.system,
796
+ cache: projection.model_config["cache_instructions"],
797
+ provider: projection.model_config["provider"]
798
+ )
799
+ end
800
+ projection.messages.each { |message| chat.messages << message }
801
+ chat
802
+ end
803
+
820
804
  # Builds a TokenBudget for this agent's model if possible.
821
805
  # When context_window is set at the class level, that value is used directly
822
806
  # (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
@@ -833,9 +817,27 @@ module Phronomy
833
817
  overhead: self.class.context_overhead
834
818
  )
835
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
+
836
838
  Phronomy::LlmContextWindow::TokenBudget.new(
837
- model: model_name,
838
- max_output_tokens: self.class.max_output_tokens,
839
+ context_window: registry_context,
840
+ max_output_tokens: output_reserve,
839
841
  overhead: self.class.context_overhead
840
842
  )
841
843
  end
@@ -852,23 +854,29 @@ module Phronomy
852
854
  Phronomy.configuration.parallel_tool_execution ? Phronomy::MultiAgent::ParallelToolChat : nil
853
855
  end
854
856
 
855
- def build_chat
857
+ def build_chat(model_config: nil)
858
+ config = model_config || {
859
+ "model" => self.class.model,
860
+ "provider" => self.class.provider,
861
+ "temperature" => self.class.temperature,
862
+ "max_output_tokens" => self.class.max_output_tokens,
863
+ "parallel_tool_execution" => Phronomy.configuration.parallel_tool_execution
864
+ }
856
865
  opts = {}
857
- m = self.class.model
858
- opts[:model] = m if m
859
- p = self.class.provider
860
- if p
861
- opts[:provider] = p
866
+ model = config["model"]
867
+ opts[:model] = model if model
868
+ provider = config["provider"]
869
+ if provider
870
+ opts[:provider] = provider.to_sym
862
871
  opts[:assume_model_exists] = true
863
872
  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)
873
+ parallel_class = config["parallel_tool_execution"] ?
874
+ Phronomy::MultiAgent::ParallelToolChat : nil
875
+ chat = parallel_class ? parallel_class.new(**opts) : RubyLLM.chat(**opts)
876
+ chat.with_temperature(config["temperature"]) if config["temperature"]
877
+ if config["max_output_tokens"] && chat.respond_to?(:with_max_output_tokens)
878
+ chat.with_max_output_tokens(config["max_output_tokens"])
870
879
  end
871
- chat.with_temperature(t) if t
872
880
  chat
873
881
  end
874
882
 
@@ -888,8 +896,8 @@ module Phronomy
888
896
  # When cache_instructions is enabled and the provider is Anthropic,
889
897
  # attaches a cache_control marker so that the fixed system prompt is
890
898
  # eligible for prompt caching.
891
- def apply_instructions(chat, text)
892
- if self.class.cache_instructions && anthropic_provider?
899
+ def apply_instructions(chat, text, cache: false, provider: nil)
900
+ if cache && provider.to_s == "anthropic"
893
901
  content = RubyLLM::Providers::Anthropic::Content.new(text, cache: true)
894
902
  chat.with_instructions(content)
895
903
  else
@@ -922,7 +930,15 @@ module Phronomy
922
930
  # @api public
923
931
  def check_cancellation!(config, message = "invocation cancelled")
924
932
  ct = config[:cancellation_token]
925
- raise Phronomy::CancellationError, message if ct&.cancelled?
933
+ return unless ct&.cancelled?
934
+
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)
939
+ raise Phronomy::TimeoutError, message
940
+ end
941
+ raise Phronomy::CancellationError, message
926
942
  end
927
943
 
928
944
  # Builds the final Tool class to register with RubyLLM. Alias and Tool