phronomy 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -18
  3. data/README.md +300 -75
  4. data/benchmark/bench_agent_invoke.rb +3 -0
  5. data/benchmark/bench_regression.rb +2 -18
  6. data/benchmark/bench_tool_schema.rb +1 -0
  7. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  8. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  9. data/lib/phronomy/agent/activation_registry.rb +28 -0
  10. data/lib/phronomy/agent/agent_execution.rb +97 -0
  11. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  12. data/lib/phronomy/agent/agent_invocation.rb +42 -10
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
  14. data/lib/phronomy/agent/agent_root.rb +67 -0
  15. data/lib/phronomy/agent/async_event_api.rb +55 -393
  16. data/lib/phronomy/agent/base.rb +301 -641
  17. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  18. data/lib/phronomy/agent/context_assembler.rb +321 -0
  19. data/lib/phronomy/agent/context_candidate.rb +47 -0
  20. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  21. data/lib/phronomy/agent/context_importer.rb +217 -0
  22. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  23. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  24. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  25. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
  26. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  27. data/lib/phronomy/agent/context_plan.rb +25 -0
  28. data/lib/phronomy/agent/context_plan_validator.rb +167 -0
  29. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  30. data/lib/phronomy/agent/context_policy.rb +15 -0
  31. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  32. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  33. data/lib/phronomy/agent/context_request.rb +35 -0
  34. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  35. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  36. data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
  37. data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
  38. data/lib/phronomy/agent/immutable.rb +31 -0
  39. data/lib/phronomy/agent/journal_projection.rb +34 -0
  40. data/lib/phronomy/agent/journal_record.rb +67 -0
  41. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  42. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  43. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  44. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  45. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  46. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  47. data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
  48. data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
  49. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  50. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  51. data/lib/phronomy/agent.rb +14 -16
  52. data/lib/phronomy/agent_busy_error.rb +5 -0
  53. data/lib/phronomy/canonical_json.rb +136 -0
  54. data/lib/phronomy/configuration.rb +9 -4
  55. data/lib/phronomy/content_store/base.rb +51 -0
  56. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  57. data/lib/phronomy/engine/event_loop.rb +3 -0
  58. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  59. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  60. data/lib/phronomy/llm_context_window/assembler.rb +8 -8
  61. data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
  62. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
  64. data/lib/phronomy/persistence/in_memory.rb +247 -0
  65. data/lib/phronomy/persistence.rb +39 -0
  66. data/lib/phronomy/tools/agent.rb +14 -36
  67. data/lib/phronomy/version.rb +1 -1
  68. data/lib/phronomy.rb +11 -0
  69. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  70. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  71. data/scripts/migrate_spec_agent_definition.rb +108 -0
  72. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  73. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  74. metadata +54 -47
  75. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  76. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  77. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
@@ -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,173 +588,6 @@ module Phronomy
386
588
  self
387
589
  end
388
590
 
389
- # Invokes the agent with the given input and returns a result Hash.
390
- # Provider errors are translated after the configured LLM adapter returns
391
- # its final result. Phronomy does not replay the Agent invocation.
392
- #
393
- # @param input [String, Hash] the user message; a Hash may supply
394
- # +:message+, +:query+, or +:user+ as the text key, plus any template
395
- # variables consumed by the configured instructions template.
396
- # @param messages [Array<RubyLLM::Message>] conversation history from a
397
- # previous invocation. The application owns and persists this array;
398
- # pass it on every turn to maintain multi-turn context.
399
- # @param thread_id [String, nil] conversation thread identifier, forwarded
400
- # to the compaction context when on_compact is configured.
401
- # @param config [Hash] additional runtime options:
402
- # +:user_id+ (+String+, optional) — caller identity forwarded to the tracer
403
- # +:session_id+ (+String+, optional) — session identity forwarded to the tracer
404
- # @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
405
- # object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
406
- # derived from it (existing +config:+ keys take precedence as backward-compat
407
- # aliases). The object is also stored in +config[:invocation_context]+ so that
408
- # +task_id+ / +parent_task_id+ appear in trace spans automatically.
409
- # @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
410
- # or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
411
- # messages: Array }+ when the invocation was suspended awaiting tool approval.
412
- # @raise [Phronomy::FilterBlockError] when an input or output filter rejects the value
413
- # @example Normal invocation
414
- # result = MyAgent.new.invoke("What is Ruby?")
415
- # puts result[:output]
416
- # @example Multi-turn conversation
417
- # result1 = agent.invoke("Hi, I'm Alice.")
418
- # result2 = agent.invoke("What's my name?", messages: result1[:messages])
419
- # @example Suspend / resume flow
420
- # result = agent.invoke("Perform task X")
421
- # if result[:suspended]
422
- # result = agent.resume(result[:checkpoint], approved: true)
423
- # end
424
- # puts result[:output]
425
- # @example With InvocationContext (deadline-based timeout)
426
- # ctx = Phronomy::InvocationContext.new(
427
- # thread_id: "conv-123",
428
- # deadline: Phronomy::Concurrency::Deadline.in(30),
429
- # task_id: SecureRandom.uuid
430
- # )
431
- # result = MyAgent.new.invoke("Hello", invocation_context: ctx)
432
- # @api public
433
- def invoke(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
434
- if invocation_context
435
- thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
436
- end
437
- _check_scheduler_reentrancy(:invoke, :invoke_async)
438
-
439
- trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
440
- result = invoke_async(
441
- input,
442
- messages: messages,
443
- thread_id: thread_id,
444
- config: config
445
- ).wait_result
446
- [result, result[:usage]]
447
- end
448
- end
449
-
450
- # Invokes this agent asynchronously and returns a {Phronomy::Task}.
451
- #
452
- # This is the primary async entry point. {#invoke} is a synchronous wrapper
453
- # that calls this method and blocks the caller until the task completes.
454
- # Calling {#invoke} from inside an active scheduler task raises
455
- # {Phronomy::SchedulerReentrancyError}; use +invoke_async+ directly in that
456
- # context.
457
- #
458
- # The task is registered with the Runtime task registry so {Runtime#shutdown}
459
- # drains in-flight invocations before process exit.
460
- #
461
- # @example
462
- # task = agent.invoke_async("Hello!")
463
- # result = task.wait_result # => { output: "...", messages: [...], usage: ... }
464
- #
465
- # @param input [String, Hash]
466
- # @param messages [Array]
467
- # @param thread_id [String, nil]
468
- # @param config [Hash]
469
- # @param invocation_context [Phronomy::InvocationContext, nil]
470
- # @return [Phronomy::Task]
471
- # @api public
472
- def invoke_async(input, messages: [], thread_id: nil, config: {},
473
- invocation_context: nil, on_tool_approval_required: nil)
474
- if invocation_context
475
- thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
476
- end
477
- result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
478
- approval_snapshot = _approval_configuration_snapshot(on_tool_approval_required)
479
- _start_invocation(
480
- result_task, input,
481
- messages: messages, thread_id: thread_id, config: config,
482
- approval_snapshot: approval_snapshot
483
- )
484
- result_task
485
- end
486
-
487
- # Invokes this agent asynchronously and delivers stream events from the
488
- # Runtime-owned EventLoop thread.
489
- #
490
- # The callback must return quickly. Blocking I/O, synchronous Agent calls,
491
- # sleep, and heavy CPU work must be delegated by the Application.
492
- #
493
- # @return [Phronomy::Task] final invocation result
494
- # @api public
495
- def stream_async(input, messages: [], thread_id: nil, config: {},
496
- invocation_context: nil, on_tool_approval_required: nil, &block)
497
- raise ArgumentError, "stream_async requires a block" unless block
498
-
499
- if invocation_context
500
- thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
501
- end
502
-
503
- result_task = Phronomy::Task.deferred(
504
- name: "agent-#{(self.class.name || "anonymous").downcase}-stream-async"
505
- )
506
- approval_snapshot = _approval_configuration_snapshot(on_tool_approval_required)
507
- _start_invocation(
508
- result_task,
509
- input,
510
- messages: messages,
511
- thread_id: thread_id,
512
- config: config,
513
- approval_snapshot: approval_snapshot,
514
- mode: :stream,
515
- on_event: block
516
- )
517
- result_task
518
- end
519
-
520
- # Synchronous wrapper around {#stream_async}.
521
- #
522
- # Stream callbacks execute on the EventLoop thread, not on the thread that
523
- # calls this method. This method only blocks while waiting for the final Task.
524
- # @yield [Phronomy::Agent::StreamEvent]
525
- # @return [Hash] same result shape as #invoke
526
- # @api public
527
- def stream(input, messages: [], thread_id: nil, config: {},
528
- invocation_context: nil, on_tool_approval_required: nil, &block)
529
- raise ArgumentError, "stream requires a block" unless block
530
-
531
- if invocation_context
532
- thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
533
- end
534
- _check_scheduler_reentrancy(:stream, :stream_async)
535
-
536
- trace("agent.stream", input: input, **_build_caller_meta(config)) do |_span|
537
- result = stream_async(
538
- input,
539
- messages: messages,
540
- thread_id: thread_id,
541
- config: config,
542
- on_tool_approval_required: on_tool_approval_required,
543
- &block
544
- ).wait_result
545
- [result, result[:usage]]
546
- end
547
- end
548
-
549
- # @deprecated The context version cache has been removed. Returns nil.
550
- # Retained for backward compatibility with callers using safe navigation (+&.reset+).
551
- # @api private
552
- def context_version_cache
553
- nil
554
- end
555
-
556
591
  private
557
592
 
558
593
  # Merges an {InvocationContext} into the +thread_id+ / +config+ pair.
@@ -594,153 +629,8 @@ module Phronomy
594
629
  end
595
630
  end
596
631
 
597
- # Assembles the LLM context (system prompt + conversation messages)
598
- # for a single invocation. Subclasses may override this method to
599
- # inject custom context editing logic without having to override
600
- # the full #invoke_once pipeline.
601
- #
602
- # The keyword arguments +budget+, +instruction+, +tools+, and +knowledge+
603
- # carry pre-computed values. Override them in a subclass call to +super+
604
- # to inject custom context without recomputing the defaults.
605
- #
606
- # @param input [String, Hash] the user's input for this turn
607
- # @param messages [Array<RubyLLM::Message>] raw conversation history
608
- # @param thread_id [String, nil] conversation thread identifier
609
- # @param config [Hash] the invocation config (see #invoke)
610
- # @param budget [LlmContextWindow::TokenBudget, nil] pre-computed token budget
611
- # @param instruction [String, nil] pre-computed system instruction
612
- # @param tools [Array<Class>] tool classes to expose
613
- # @param knowledge [Array<Hash>] knowledge chunks ({ content:, type:, source: })
614
- # @return [Hash] { system: String|nil, messages: Array, tool_classes: Array }
615
- # @api public
616
- def build_context(
617
- input,
618
- messages: [],
619
- thread_id: nil,
620
- config: {},
621
- budget: build_token_budget,
622
- instruction: build_instructions(input),
623
- tools: self.class.tools + _handoff_tools,
624
- knowledge: self.class.static_knowledge_chunks + instance_knowledge_chunks
625
- )
626
- assembler = LlmContextWindow::Assembler.new(budget: budget)
627
- assembler.add_instruction(instruction) if instruction
628
- assembler.add_capability(tools)
629
- knowledge.each { |chunk| assembler.add_knowledge(chunk[:content], type: chunk[:type] || :static, trusted: true, source: chunk[:source]) }
630
-
631
- msgs = Array(messages)
632
-
633
- if budget && budget_exceeded?(msgs)
634
- # Default strategy when the token budget is tight:
635
- # 1. Compact: keep the most recent half of the messages verbatim and
636
- # replace the older half with a brief omission marker.
637
- # 2. Trim: if the compacted history still exceeds the budget, call
638
- # trim_to_budget with the :safe strategy, which discards the oldest
639
- # message one at a time until the history fits.
640
- # Subclasses can override build_context to apply a different strategy
641
- # (e.g. LLM-based summarisation) before calling super.
642
- keep = [msgs.size / 2, 2].max
643
- msgs = compact_messages(msgs, keep_tail: keep) do |dropped|
644
- "[#{dropped.size} earlier messages omitted]"
645
- end
646
- remaining = assembler.available_for_messages
647
- msgs = trim_to_budget(msgs, remaining: remaining, strategy: :safe)
648
- end
649
-
650
- assembler.add_messages(msgs)
651
- @last_context = assembler.build
652
- end
653
- protected :build_context
654
-
655
- # Keeps the last +keep+ messages from +messages+, discarding older ones.
656
- # Use this inside a +build_context+ override to trim conversation history.
657
- #
658
- # @param messages [Array<RubyLLM::Message>] conversation history
659
- # @param keep [Integer] number of messages to retain (from the tail)
660
- # @return [Array<RubyLLM::Message>]
661
- # @api public
662
- def trim_messages(messages, keep:)
663
- Array(messages).last(keep)
664
- end
665
- protected :trim_messages
666
-
667
- # Removes the oldest messages one at a time until the count is within +limit+.
668
- #
669
- # @param messages [Array<RubyLLM::Message>] conversation history
670
- # @param limit [Integer] maximum number of messages to retain
671
- # @return [Array<RubyLLM::Message>]
672
- # @api public
673
- def drop_messages_over(messages, limit:)
674
- msgs = Array(messages).dup
675
- msgs.shift while msgs.size > limit
676
- msgs
677
- end
678
- protected :drop_messages_over
679
-
680
- # Replaces all but the last +keep_tail+ messages with a single system summary.
681
- # The block receives the dropped messages and must return a summary String.
682
- #
683
- # @param messages [Array<RubyLLM::Message>] conversation history
684
- # @param keep_tail [Integer] number of recent messages to preserve verbatim
685
- # @yield [Array<RubyLLM::Message>] the messages being summarised
686
- # @yieldreturn [String] summary text
687
- # @return [Array<RubyLLM::Message>]
688
- # @api public
689
- def compact_messages(messages, keep_tail:, &summariser)
690
- msgs = Array(messages)
691
- return msgs if msgs.size <= keep_tail
692
- tail = msgs.last(keep_tail)
693
- dropped = msgs.first(msgs.size - keep_tail)
694
- summary_text = summariser.call(dropped)
695
- [RubyLLM::Message.new(role: :system, content: summary_text)] + tail
696
- end
697
- protected :compact_messages
698
-
699
- # Trims +messages+ to fit within +remaining+ tokens using the given
700
- # +strategy+. Returns the trimmed message array without touching the
701
- # assembler. The caller is responsible for passing the result to
702
- # +assembler.add_messages+ and calling +assembler.build+.
703
- #
704
- # Supported strategies:
705
- # +:safe+ — discard the oldest message one at a time (default)
706
- #
707
- # @param messages [Array<RubyLLM::Message>] conversation history
708
- # @param remaining [Integer, nil] token allowance for messages; when +nil+
709
- # the messages are returned unchanged
710
- # @param strategy [Symbol] trim strategy (default +:safe+)
711
- # @return [Array<RubyLLM::Message>]
712
- # @api public
713
- def trim_to_budget(messages, remaining:, strategy: :safe)
714
- return Array(messages) unless remaining
715
- msgs = Array(messages)
716
- loop do
717
- used = msgs.sum { |m| LlmContextWindow::TokenEstimator.estimate(m.content.to_s) }
718
- return msgs if used <= remaining
719
- break if msgs.empty?
720
- msgs = trim_messages(msgs, keep: msgs.size - 1)
721
- end
722
- msgs
723
- end
724
- protected :trim_to_budget
725
-
726
- # Returns +true+ when the estimated token usage of +messages+ exceeds
727
- # +threshold+ times the available context budget.
728
- # Always returns +false+ when no token budget is available.
729
- #
730
- # @param messages [Array<RubyLLM::Message>] conversation history
731
- # @param threshold [Float] fraction of the available budget (default 0.8)
732
- # @return [Boolean]
733
- # @api public
734
- def budget_exceeded?(messages, threshold: 0.8)
735
- return false unless (b = build_token_budget)
736
- total = Array(messages).sum { |m| LlmContextWindow::TokenEstimator.estimate(m.content.to_s) }
737
- limit = b.available(used: 0)
738
- total > limit * threshold
739
- end
740
- protected :budget_exceeded?
741
-
742
632
  # Registers a per-instance knowledge source. Knowledge chunks from all
743
- # registered sources are included in every LLM call via +build_context+.
633
+ # registered sources are included in every LLM call via the Context Policy.
744
634
  #
745
635
  # @param source [#fetch] any object responding to +fetch(query:)+
746
636
  # @return [void]
@@ -761,50 +651,6 @@ module Phronomy
761
651
  end
762
652
  protected :instance_knowledge_chunks
763
653
 
764
- # Starts one AgentInvocation and resolves +result_task+ from that session.
765
- # Phronomy translates the adapter's final error but never starts another
766
- # AgentInvocation automatically.
767
- # @api private
768
- def _start_invocation(result_task, input, messages:, thread_id:, config:,
769
- approval_snapshot:, mode: :invoke, on_event: nil)
770
- effective_config = thread_id ? config.merge(thread_id: thread_id) : config
771
- check_cancellation!(effective_config, "invocation cancelled")
772
- runtime = Phronomy::Runtime.instance
773
- event_loop = runtime.event_loop
774
- session = Agent::AgentInvocationSessionBuilder.build(
775
- agent: self,
776
- input: input,
777
- messages: messages,
778
- config: effective_config,
779
- approval_policy: approval_snapshot[:policy],
780
- approval_listener: approval_snapshot[:listener],
781
- mode: mode,
782
- on_event: on_event,
783
- runtime: runtime
784
- )
785
- callback_error_policy =
786
- Phronomy.configuration.stream_callback_error_policy
787
- source_task = Phronomy::Task.deferred(name: "#{result_task.name}-source")
788
- source_task.on_complete do |invocation, error|
789
- _handle_agent_completion(
790
- result_task: result_task,
791
- invocation: invocation,
792
- error: error,
793
- mode: mode,
794
- listener: on_event,
795
- event_loop: event_loop,
796
- callback_error_policy: callback_error_policy
797
- )
798
- end
799
-
800
- # Register completion handling before EventLoop admission. Otherwise an
801
- # immediately finishing session can complete source_task before the
802
- # callback is installed, causing Task#on_complete to run on this thread.
803
- event_loop.register(session, completion: source_task)
804
- rescue => e
805
- _fail_result_task(result_task, e)
806
- end
807
-
808
654
  def _complete_result_task(task, result)
809
655
  task.backend.unblock(result, nil)
810
656
  task.transition!(:completed, value: result)
@@ -821,83 +667,6 @@ module Phronomy
821
667
  translated
822
668
  end
823
669
 
824
- # Completes one Agent execution interval. Execution failures and
825
- # Application callback failures are deliberately handled in separate
826
- # exception domains.
827
- def _handle_agent_completion(result_task:, invocation:, error:, mode:, listener:,
828
- event_loop:, callback_error_policy:)
829
- if mode == :stream && !event_loop.current?
830
- completion_error = error || Phronomy::Error.new(
831
- "Stream completion occurred outside the EventLoop"
832
- )
833
- _fail_result_task(result_task, _translated_error(completion_error))
834
- return
835
- end
836
-
837
- result = nil
838
- execution_error = nil
839
- begin
840
- raise error if error
841
-
842
- result = _extract_invoke_result(invocation)
843
- rescue => e
844
- execution_error = _translated_error(e)
845
- end
846
-
847
- if execution_error
848
- if mode == :stream
849
- event = StreamEvent.new(
850
- type: :error,
851
- payload: {error: execution_error}
852
- )
853
- callback_error = _deliver_stream_event(listener, event)
854
- if callback_error
855
- _report_stream_callback_error(
856
- callback_error,
857
- event: event,
858
- invocation_id: invocation&.id,
859
- callback_error_policy: callback_error_policy
860
- )
861
- end
862
- end
863
-
864
- # An Application failure while consuming :error never replaces the
865
- # original Agent/LLM/Tool/Runtime failure.
866
- _fail_result_task(result_task, execution_error)
867
- return
868
- end
869
-
870
- unless mode == :stream
871
- _complete_result_task(result_task, result)
872
- return
873
- end
874
-
875
- event = _build_stream_terminal_event(result)
876
- callback_error = _deliver_stream_event(listener, event)
877
- unless callback_error
878
- _complete_result_task(result_task, result)
879
- return
880
- end
881
-
882
- _report_stream_callback_error(
883
- callback_error,
884
- event: event,
885
- invocation_id: invocation&.id,
886
- callback_error_policy: callback_error_policy
887
- )
888
-
889
- if callback_error_policy == :fail_task
890
- wrapped = _build_stream_callback_error(
891
- event_type: event.type,
892
- callback_error: callback_error,
893
- result: result
894
- )
895
- _fail_result_task(result_task, wrapped)
896
- else
897
- _complete_result_task(result_task, result)
898
- end
899
- end
900
-
901
670
  def _build_stream_terminal_event(result)
902
671
  if result[:suspended]
903
672
  StreamEvent.new(
@@ -976,169 +745,6 @@ module Phronomy
976
745
  nil
977
746
  end
978
747
 
979
- # Continues a suspended AgentInvocation. The parent session is registered
980
- # asynchronously; this method is only the synchronous wrapper.
981
- # @return [Hash]
982
- # @api public
983
- def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
984
- _check_scheduler_reentrancy(:approve, :approve_async)
985
- approve_async(
986
- agent_invocation_id,
987
- approval_request_id: approval_request_id,
988
- approved: approved,
989
- config: config
990
- ).wait_result
991
- end
992
- public :approve
993
-
994
- # Continues a suspended AgentInvocation without blocking the caller.
995
- #
996
- # This method is safe to call from an EventLoop stream callback. The
997
- # returned Task completes when the resumed AgentInvocation finishes,
998
- # suspends again, or fails.
999
- # @return [Phronomy::Task]
1000
- # @api public
1001
- def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
1002
- result_task = Phronomy::Task.deferred(
1003
- name: "agent-approval-resume:#{agent_invocation_id}"
1004
- )
1005
-
1006
- begin
1007
- entry = Agent::AgentInvocationRegistry.consume_approval(
1008
- agent_invocation_id, approval_request_id
1009
- )
1010
- unless entry
1011
- raise ArgumentError,
1012
- "No pending approval found for AgentInvocation #{agent_invocation_id}"
1013
- end
1014
-
1015
- _start_approval_resume(
1016
- result_task,
1017
- entry.invocation,
1018
- approved: approved,
1019
- config: config
1020
- )
1021
- rescue => e
1022
- _fail_result_task(result_task, e)
1023
- end
1024
-
1025
- result_task
1026
- end
1027
- public :approve_async
1028
-
1029
- # Parent completion handling is installed before EventLoop registration,
1030
- # and the parent session is registered before child sessions so immediate
1031
- # child events cannot be lost.
1032
- # @api private
1033
- def _start_approval_resume(result_task, invocation, approved:, config:)
1034
- invocation.merge_config!(config)
1035
- invocation.begin_approval_resume!(approved: approved)
1036
- runtime = Phronomy::Runtime.instance
1037
- event_loop = runtime.event_loop
1038
- source_task = Phronomy::Task.deferred(
1039
- name: "#{result_task.name}-source"
1040
- )
1041
- parent_session = Agent::AgentInvocationSessionBuilder.build_for_resume(
1042
- agent_invocation: invocation,
1043
- resume_event: :resume,
1044
- resume_phase: :suspended,
1045
- runtime: runtime
1046
- )
1047
- stream_listener = invocation.stream_listener
1048
- mode = stream_listener ? :stream : :invoke
1049
- callback_error_policy =
1050
- Phronomy.configuration.stream_callback_error_policy
1051
-
1052
- source_task.on_complete do |completed_invocation, error|
1053
- _handle_agent_completion(
1054
- result_task: result_task,
1055
- invocation: completed_invocation,
1056
- error: error,
1057
- mode: mode,
1058
- listener: stream_listener,
1059
- event_loop: event_loop,
1060
- callback_error_policy: callback_error_policy
1061
- )
1062
- end
1063
-
1064
- # The parent must exist before any child can post an immediate result.
1065
- event_loop.register(parent_session, completion: source_task)
1066
-
1067
- invocation.tool_invocations.each do |child|
1068
- child_session = if child.awaiting_approval?
1069
- Agent::ToolInvocationSessionBuilder.build_for_resume(
1070
- tool_invocation: child,
1071
- resume_event: approved ? :approve : :reject,
1072
- resume_phase: :awaiting_approval,
1073
- runtime: runtime
1074
- )
1075
- elsif !approved && child.authorized?
1076
- Agent::ToolInvocationSessionBuilder.build_for_resume(
1077
- tool_invocation: child,
1078
- resume_event: :cancel,
1079
- resume_phase: :authorized,
1080
- runtime: runtime
1081
- )
1082
- end
1083
- _register_tool_invocation_session(event_loop, runtime, child, child_session) if child_session
1084
- end
1085
- end
1086
-
1087
- def _extract_invoke_result(invocation)
1088
- if invocation.phase == :suspended
1089
- request = invocation.approval_request
1090
- Agent::AgentInvocationRegistry.store_suspended(invocation, request)
1091
- _dispatch_tool_approval_notification(invocation, request)
1092
- {
1093
- suspended: true,
1094
- agent_invocation_id: invocation.id,
1095
- approval_request: request,
1096
- messages: invocation.messages
1097
- }
1098
- elsif invocation.input_blocked? || invocation.output_blocked?
1099
- raise invocation.block_error
1100
- elsif invocation.error
1101
- raise invocation.error
1102
- elsif invocation.rejected
1103
- {rejected: true, messages: invocation.messages}
1104
- else
1105
- {output: invocation.output, messages: invocation.messages, usage: invocation.usage}
1106
- end
1107
- end
1108
-
1109
- def _register_tool_invocation_session(event_loop, runtime, child, session)
1110
- completion = Phronomy::Task.deferred(name: "tool-session:#{child.id}")
1111
- completion.on_complete do |_result, error|
1112
- next unless error
1113
-
1114
- child.mark_framework_failed!(error)
1115
- runtime.event_loop.post(
1116
- Phronomy::Event.new(
1117
- type: :tool_failed,
1118
- target_id: child.parent_agent_invocation_id,
1119
- payload: {tool_invocation_id: child.id}
1120
- )
1121
- )
1122
- end
1123
- event_loop.register(session, completion: completion)
1124
- end
1125
-
1126
- def _dispatch_tool_approval_notification(invocation, request)
1127
- listener = invocation.approval_listener
1128
- return unless listener
1129
-
1130
- Phronomy::Runtime.instance.blocking_io.submit(on_full: :raise) do
1131
- listener.call(request)
1132
- end
1133
- rescue => e
1134
- message = "[Phronomy] Tool approval notification failed: #{e.class}: #{e.message}"
1135
- if Phronomy.configuration.logger
1136
- Phronomy.configuration.logger.warn(message)
1137
- else
1138
- Kernel.warn(message)
1139
- end
1140
- end
1141
-
1142
748
  def _approval_configuration_mutex
1143
749
  return @approval_configuration_mutex if @approval_configuration_mutex
1144
750
 
@@ -1168,11 +774,33 @@ module Phronomy
1168
774
  end
1169
775
 
1170
776
  def _apply_context_to_chat(chat, context)
1171
- 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
1172
786
  (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
1173
787
  context[:messages].each { |msg| chat.messages << msg }
1174
788
  end
1175
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
+
1176
804
  # Builds a TokenBudget for this agent's model if possible.
1177
805
  # When context_window is set at the class level, that value is used directly
1178
806
  # (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
@@ -1189,9 +817,27 @@ module Phronomy
1189
817
  overhead: self.class.context_overhead
1190
818
  )
1191
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
+
1192
838
  Phronomy::LlmContextWindow::TokenBudget.new(
1193
- model: model_name,
1194
- max_output_tokens: self.class.max_output_tokens,
839
+ context_window: registry_context,
840
+ max_output_tokens: output_reserve,
1195
841
  overhead: self.class.context_overhead
1196
842
  )
1197
843
  end
@@ -1208,23 +854,29 @@ module Phronomy
1208
854
  Phronomy.configuration.parallel_tool_execution ? Phronomy::MultiAgent::ParallelToolChat : nil
1209
855
  end
1210
856
 
1211
- 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
+ }
1212
865
  opts = {}
1213
- m = self.class.model
1214
- opts[:model] = m if m
1215
- p = self.class.provider
1216
- if p
1217
- 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
1218
871
  opts[:assume_model_exists] = true
1219
872
  end
1220
- t = self.class.temperature
1221
- parallel_class = build_chat_class
1222
- chat = if parallel_class
1223
- parallel_class.new(**opts)
1224
- else
1225
- 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"])
1226
879
  end
1227
- chat.with_temperature(t) if t
1228
880
  chat
1229
881
  end
1230
882
 
@@ -1244,8 +896,8 @@ module Phronomy
1244
896
  # When cache_instructions is enabled and the provider is Anthropic,
1245
897
  # attaches a cache_control marker so that the fixed system prompt is
1246
898
  # eligible for prompt caching.
1247
- def apply_instructions(chat, text)
1248
- if self.class.cache_instructions && anthropic_provider?
899
+ def apply_instructions(chat, text, cache: false, provider: nil)
900
+ if cache && provider.to_s == "anthropic"
1249
901
  content = RubyLLM::Providers::Anthropic::Content.new(text, cache: true)
1250
902
  chat.with_instructions(content)
1251
903
  else
@@ -1278,7 +930,15 @@ module Phronomy
1278
930
  # @api public
1279
931
  def check_cancellation!(config, message = "invocation cancelled")
1280
932
  ct = config[:cancellation_token]
1281
- 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
1282
942
  end
1283
943
 
1284
944
  # Builds the final Tool class to register with RubyLLM. Alias and Tool