phronomy 0.3.0 → 0.5.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.
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require_relative "concerns/retryable"
5
+ require_relative "concerns/guardrailable"
6
+ require_relative "concerns/before_completion"
7
+ require_relative "concerns/suspendable"
4
8
 
5
9
  module Phronomy
6
10
  module Agent
@@ -27,6 +31,10 @@ module Phronomy
27
31
  # end
28
32
  class Base
29
33
  include Phronomy::Runnable
34
+ include Concerns::Retryable
35
+ include Concerns::Guardrailable
36
+ include Concerns::BeforeCompletion
37
+ include Concerns::Suspendable
30
38
 
31
39
  class << self
32
40
  # Sets or reads the LLM model identifier for this agent.
@@ -66,7 +74,8 @@ module Phronomy
66
74
  if text || block_given?
67
75
  @instructions = text || block
68
76
  else
69
- @instructions
77
+ return @instructions if instance_variable_defined?(:@instructions)
78
+ superclass.respond_to?(:instructions) ? superclass.instructions : nil
70
79
  end
71
80
  end
72
81
 
@@ -88,7 +97,10 @@ module Phronomy
88
97
  # )
89
98
  def tools(*args)
90
99
  if args.empty?
91
- return @tools || []
100
+ if instance_variable_defined?(:@tools)
101
+ return @tools
102
+ end
103
+ return superclass.respond_to?(:tools) ? superclass.tools : []
92
104
  end
93
105
 
94
106
  if args.length == 1 && args.first.is_a?(Hash)
@@ -122,7 +134,8 @@ module Phronomy
122
134
  if name
123
135
  @provider = name
124
136
  else
125
- @provider
137
+ return @provider if instance_variable_defined?(:@provider)
138
+ superclass.respond_to?(:provider) ? superclass.provider : nil
126
139
  end
127
140
  end
128
141
 
@@ -160,35 +173,6 @@ module Phronomy
160
173
  end
161
174
  end
162
175
 
163
- # Configures a retry policy that wraps the full #invoke call.
164
- # GuardrailError is never retried regardless of this setting.
165
- #
166
- # @param times [Integer] maximum retry attempts (default: 0)
167
- # @param wait [Symbol, Numeric] :exponential, :linear, or a fixed Float
168
- # @param base [Float] base wait time in seconds (default: 1.0)
169
- #
170
- # @example
171
- # class MyAgent < Phronomy::Agent::Base
172
- # retry_policy times: 2, wait: :exponential, base: 1.0
173
- # end
174
- def retry_policy(times: 0, wait: 0, base: 1.0)
175
- @_retry_policy = {times: times, wait: wait, base: base}
176
- end
177
-
178
- # Returns the configured retry policy, or nil when none is set.
179
- # @return [Hash, nil]
180
- attr_reader :_retry_policy
181
-
182
- # Injectable sleep callable for testing (shared with Tool::Base pattern).
183
- # @return [#call]
184
- def _sleep_proc
185
- @_sleep_proc || method(:sleep)
186
- end
187
-
188
- # Overrides the sleep callable used between retries.
189
- # @param proc [#call]
190
- attr_writer :_sleep_proc
191
-
192
176
  # Registers one or more static knowledge sources on the agent class.
193
177
  # Static sources are fetched once per agent instance and their content
194
178
  # is cached in ContextVersionCache keyed by a fingerprint of the
@@ -347,37 +331,8 @@ module Phronomy
347
331
  @context_overhead = val.to_i
348
332
  end
349
333
  end
350
-
351
- # Sets or reads the class-level before_completion hook.
352
- # The hook is called before every LLM request for instances of this class.
353
- # Receives a {Phronomy::Agent::BeforeCompletionContext}; must return a Hash
354
- # of params to merge into the LLM call, or nil to pass through unchanged.
355
- #
356
- # @param callable [#call, nil] lambda/proc to register, or nil to clear
357
- # @return [#call, nil]
358
- # @example
359
- # class MyAgent < Phronomy::Agent::Base
360
- # before_completion ->(ctx) { { temperature: 0.2 } }
361
- # end
362
- def before_completion(callable = nil)
363
- if callable.nil? && !block_given?
364
- @before_completion
365
- else
366
- @before_completion = callable
367
- end
368
- end
369
-
370
- # @return [#call, nil]
371
- def _before_completion
372
- @before_completion
373
- end
374
334
  end
375
335
 
376
- # Instance-level before_completion hook. When set, takes precedence over
377
- # the class-level hook for this specific agent instance only.
378
- # @return [#call, nil]
379
- attr_accessor :before_completion
380
-
381
336
  # Registers an anonymous handoff tool class on this agent instance.
382
337
  # Called by Runner during construction when routes are configured.
383
338
  # @param tool_class [Class<Phronomy::Tool::Base>]
@@ -398,14 +353,18 @@ module Phronomy
398
353
  # Applies the retry policy configured via {.retry_policy} when transient
399
354
  # errors occur. {Phronomy::GuardrailError} is never retried.
400
355
  #
401
- # @param input [String, Hash] the user message; a Hash may supply
356
+ # @param input [String, Hash] the user message; a Hash may supply
402
357
  # +:message+, +:query+, or +:user+ as the text key, plus any template
403
358
  # variables consumed by the configured instructions template.
404
- # @param config [Hash] runtime options:
405
- # +:messages+ (Array<RubyLLM::Message>) — conversation history from a previous invocation
406
- # +:thread_id+ (+String+) — conversation thread identifier
407
- # +:user_id+ (+String+, optional) — caller identity forwarded to the tracer
408
- # +:session_id+ (+String+, optional) — session identity forwarded to the tracer
359
+ # @param messages [Array<RubyLLM::Message>] conversation history from a
360
+ # previous invocation. The application owns and persists this array;
361
+ # pass it on every turn to maintain multi-turn context.
362
+ # @param thread_id [String, nil] conversation thread identifier, forwarded
363
+ # to the compaction context when on_compact is configured.
364
+ # @param config [Hash] additional runtime options:
365
+ # +:knowledge_sources+ (Array) — dynamic knowledge sources for this turn
366
+ # +:user_id+ (+String+, optional) — caller identity forwarded to the tracer
367
+ # +:session_id+ (+String+, optional) — session identity forwarded to the tracer
409
368
  # @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
410
369
  # or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
411
370
  # messages: Array }+ when the invocation was suspended awaiting tool approval.
@@ -413,14 +372,17 @@ module Phronomy
413
372
  # @example Normal invocation
414
373
  # result = MyAgent.new.invoke("What is Ruby?")
415
374
  # puts result[:output]
375
+ # @example Multi-turn conversation
376
+ # result1 = agent.invoke("Hi, I'm Alice.")
377
+ # result2 = agent.invoke("What's my name?", messages: result1[:messages])
416
378
  # @example Suspend / resume flow
417
379
  # result = agent.invoke("Perform task X")
418
380
  # if result[:suspended]
419
381
  # result = agent.resume(result[:checkpoint], approved: true)
420
382
  # end
421
383
  # puts result[:output]
422
- def invoke(input, config: {})
423
- _invoke_impl(input, config: config)
384
+ def invoke(input, messages: [], thread_id: nil, config: {})
385
+ _invoke_impl(input, messages: messages, thread_id: thread_id, config: config)
424
386
  end
425
387
 
426
388
  # Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
@@ -433,104 +395,21 @@ module Phronomy
433
395
  # :done — final event carrying output, messages, and usage
434
396
  # :error — if an unrecoverable error occurs
435
397
  #
436
- # @param input [String, Hash] same as #invoke
437
- # @param config [Hash] same as #invoke
398
+ # @param input [String, Hash] same as #invoke
399
+ # @param messages [Array<RubyLLM::Message>] same as #invoke
400
+ # @param thread_id [String, nil] same as #invoke
401
+ # @param config [Hash] same as #invoke
438
402
  # @yield [Phronomy::Agent::StreamEvent]
439
403
  # @return [Hash] { output:, messages:, usage: } — same as #invoke
440
- def stream(input, config: {}, &block)
441
- return invoke(input, config: config) unless block
404
+ def stream(input, messages: [], thread_id: nil, config: {}, &block)
405
+ return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block
442
406
 
443
- _stream_impl(input, config: config, &block)
407
+ _stream_impl(input, messages: messages, thread_id: thread_id, config: config, &block)
444
408
  rescue => e
445
409
  block&.call(StreamEvent.new(type: :error, payload: {error: e}))
446
410
  raise
447
411
  end
448
412
 
449
- # Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
450
- #
451
- # This method reconstructs the conversation state captured at suspension
452
- # time, injects the tool result (executed or denied), and continues the
453
- # LLM loop until it produces a final answer.
454
- #
455
- # @param checkpoint [Phronomy::Agent::Checkpoint] the checkpoint returned by
456
- # the suspended #invoke call
457
- # @param approved [Boolean] +true+ to execute the pending tool; +false+
458
- # to inject a denial message and let the LLM handle it gracefully
459
- # @param config [Hash] same runtime options as #invoke
460
- # @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
461
- # @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
462
- def resume(checkpoint, approved:, config: {})
463
- checkpoint.thread_id
464
-
465
- # Build a fresh chat with all tools registered.
466
- chat = build_chat
467
-
468
- # Restore the full conversation (system + history + user + assistant).
469
- checkpoint.messages.each { |msg| chat.messages << msg }
470
-
471
- # Determine the tool result: execute it or inject a denial string.
472
- tool_result =
473
- if approved
474
- tool_instance = chat.tools[checkpoint.pending_tool_name.to_sym]
475
- tool_instance ? tool_instance.call(checkpoint.pending_tool_args) : "Tool not found."
476
- else
477
- "Tool execution denied."
478
- end
479
-
480
- # Inject the tool result so the LLM can continue.
481
- chat.add_message(
482
- role: :tool,
483
- content: tool_result.to_s,
484
- tool_call_id: checkpoint.pending_tool_call_id
485
- )
486
-
487
- # Continue the React loop.
488
- response = chat.complete
489
-
490
- output = response.content
491
- usage = Phronomy::TokenUsage.from_tokens(response.tokens)
492
-
493
- run_output_guardrails!(output)
494
-
495
- {output: output, suspended: false, messages: chat.messages, usage: usage}
496
- end
497
-
498
- # Registers a callback that is invoked before executing any tool that has
499
- # +requires_approval true+ set. The block receives the tool name (String)
500
- # and the arguments Hash, and must return a truthy value to allow execution.
501
- # Returning a falsy value causes the tool to return a denial message instead
502
- # of executing.
503
- #
504
- # When no handler is registered and a tool with +requires_approval+ is
505
- # called, #invoke returns a suspended result hash containing a
506
- # {Phronomy::Agent::Checkpoint}. Call #resume to continue execution after
507
- # obtaining an approval decision from the user or an external system.
508
- #
509
- # @example Synchronous handler
510
- # agent = MyAgent.new
511
- # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
512
- # @return [self]
513
- def on_approval_required(&block)
514
- @approval_handler = block
515
- self
516
- end
517
-
518
- # Attach a guardrail that validates input before every #invoke call.
519
- # @param guardrail [Phronomy::Guardrail::InputGuardrail]
520
- def add_input_guardrail(guardrail)
521
- @input_guardrails ||= []
522
- @input_guardrails << guardrail
523
- self
524
- end
525
-
526
- # Attach a guardrail that validates output before it is returned.
527
- # @param guardrail [Phronomy::Guardrail::OutputGuardrail]
528
- def add_output_guardrail(guardrail)
529
- @output_guardrails ||= []
530
- @output_guardrails << guardrail
531
- self
532
- end
533
-
534
413
  # Returns the {Context::ContextVersionCache} for the current thread.
535
414
  # @api private
536
415
  def context_version_cache
@@ -539,27 +418,8 @@ module Phronomy
539
418
 
540
419
  private
541
420
 
542
- # Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
543
- def _invoke_impl(input, config: {})
544
- policy = self.class._retry_policy
545
- attempt = 0
546
- begin
547
- invoke_once(input, config: config)
548
- rescue Phronomy::GuardrailError
549
- raise
550
- rescue
551
- if policy && attempt < policy[:times]
552
- wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
553
- self.class._sleep_proc.call(wait) if wait > 0
554
- attempt += 1
555
- retry
556
- end
557
- raise
558
- end
559
- end
560
-
561
421
  # Streaming implementation for #stream.
562
- def _stream_impl(input, config: {}, &block)
422
+ def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
563
423
  caller_meta = {}
564
424
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
565
425
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
@@ -567,54 +427,12 @@ module Phronomy
567
427
  trace("agent.invoke", input: input, **caller_meta) do |_span|
568
428
  run_input_guardrails!(input)
569
429
 
570
- thread_id = config[:thread_id]
571
-
572
430
  chat = build_chat
573
431
  user_message = extract_message(input)
574
- budget = build_token_budget
575
-
576
- # Assemble context via Assembler (same as invoke_once).
577
- assembler = Context::Assembler.new(budget: budget)
578
- system_msg = build_instructions(input)
579
- assembler.add_instruction(system_msg) if system_msg
580
432
 
581
- Array(config[:knowledge_sources]).each do |ks|
582
- ks.fetch(query: user_message).each do |chunk|
583
- assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
584
- end
585
- end
586
-
587
- msgs = Array(config[:messages])
588
- unless msgs.empty?
589
- message_elements = build_message_elements(msgs)
590
-
591
- # Run on_trim: app may call ctx.remove(seqs) to drop messages this turn.
592
- if (trim_cb = self.class._on_trim_callback)
593
- trim_ctx = Context::TrimContext.new(message_elements: message_elements, budget: budget)
594
- trim_cb.call(trim_ctx)
595
- message_elements = trim_ctx.message_elements
596
- end
597
-
598
- # Run on_compaction_trigger → on_compact pipeline before calling the LLM.
599
- if (trigger_cb = self.class._on_compaction_trigger_callback)
600
- trigger_ctx = Context::TriggerContext.new(message_elements: message_elements, budget: budget)
601
- if trigger_cb.call(trigger_ctx)
602
- if (compact_cb = self.class._on_compact_callback)
603
- compact_ctx = Context::CompactionContext.new(
604
- message_elements: message_elements,
605
- budget: budget,
606
- thread_id: thread_id
607
- )
608
- compact_cb.call(compact_ctx)
609
- message_elements = build_message_elements(compact_ctx.result_messages)
610
- end
611
- end
612
- end
613
-
614
- assembler.add_messages(message_elements.map { |e| e[:message] })
615
- end
616
-
617
- context = assembler.build
433
+ # Assemble context (system prompt + history). Override #build_context to
434
+ # inject custom context editing logic at the Agent subclass level.
435
+ context = build_context(input, messages: messages, thread_id: thread_id, config: config)
618
436
  apply_instructions(chat, context[:system]) if context[:system]
619
437
  context[:messages].each { |msg| chat.messages << msg }
620
438
 
@@ -650,9 +468,79 @@ module Phronomy
650
468
  end
651
469
  end
652
470
 
471
+ # Assembles the LLM context (system prompt + conversation messages)
472
+ # for a single invocation. Subclasses may override this method to
473
+ # inject custom context editing logic without having to override
474
+ # the full #invoke_once pipeline.
475
+ #
476
+ # @param input [String, Hash] the user's input for this turn
477
+ # @param messages [Array<RubyLLM::Message>] raw conversation history
478
+ # @param thread_id [String, nil] conversation thread identifier
479
+ # @param config [Hash] the invocation config (see #invoke)
480
+ # @return [Hash] { system: String|nil, messages: Array }
481
+ def build_context(input, messages: [], thread_id: nil, config: {})
482
+ history = prepare_history(messages: messages, thread_id: thread_id, config: config)
483
+ budget = build_token_budget
484
+ system_text = build_cached_system_text(input)
485
+ user_message = extract_message(input)
486
+
487
+ assembler = Context::Assembler.new(budget: budget)
488
+ assembler.add_instruction(system_text) if system_text
489
+
490
+ Array(config[:knowledge_sources]).each do |ks|
491
+ ks.fetch(query: user_message).each do |chunk|
492
+ assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
493
+ end
494
+ end
495
+
496
+ assembler.add_messages(history)
497
+ assembler.build
498
+ end
499
+ protected :build_context
500
+
501
+ # Runs the on_trim / on_compaction_trigger / on_compact pipeline on the
502
+ # supplied message array and returns the final Array of message objects
503
+ # ready to pass to the Assembler.
504
+ #
505
+ # Override this method in a subclass to customize how conversation
506
+ # history is filtered or compressed before context assembly.
507
+ #
508
+ # @param messages [Array<RubyLLM::Message>] raw conversation history
509
+ # @param thread_id [String, nil] conversation thread identifier
510
+ # @param config [Hash] additional invocation options
511
+ # @return [Array] filtered and/or compacted message objects
512
+ def prepare_history(messages: [], thread_id: nil, config: {})
513
+ budget = build_token_budget
514
+ elements = build_message_elements(Array(messages))
515
+
516
+ if (trim_cb = self.class._on_trim_callback)
517
+ trim_ctx = Context::TrimContext.new(message_elements: elements, budget: budget)
518
+ trim_cb.call(trim_ctx)
519
+ elements = trim_ctx.message_elements
520
+ end
521
+
522
+ if (trigger_cb = self.class._on_compaction_trigger_callback)
523
+ trigger_ctx = Context::TriggerContext.new(message_elements: elements, budget: budget)
524
+ if trigger_cb.call(trigger_ctx)
525
+ if (compact_cb = self.class._on_compact_callback)
526
+ compact_ctx = Context::CompactionContext.new(
527
+ message_elements: elements,
528
+ budget: budget,
529
+ thread_id: thread_id
530
+ )
531
+ compact_cb.call(compact_ctx)
532
+ elements = build_message_elements(compact_ctx.result_messages)
533
+ end
534
+ end
535
+ end
536
+
537
+ elements.map { |e| e[:message] }
538
+ end
539
+ protected :prepare_history
540
+
653
541
  # Performs a single (non-retried) invocation. Extracted so that #invoke can
654
542
  # wrap it in a retry loop without duplicating the LLM interaction logic.
655
- def invoke_once(input, config: {})
543
+ def invoke_once(input, messages: [], thread_id: nil, config: {})
656
544
  caller_meta = {}
657
545
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
658
546
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
@@ -661,62 +549,12 @@ module Phronomy
661
549
  # Run input guardrails before touching the LLM.
662
550
  run_input_guardrails!(input)
663
551
 
664
- thread_id = config[:thread_id]
665
552
  user_message = extract_message(input)
666
553
  chat = build_chat
667
- budget = build_token_budget
668
-
669
- # Load conversation history from config[:messages] (app-managed).
670
- raw_messages = Array(config[:messages])
671
-
672
- # Assign synthetic 0-based seq numbers for use by trim/compaction callbacks.
673
- message_elements = build_message_elements(raw_messages)
674
-
675
- # Run on_trim: app may call ctx.remove(seqs) to drop messages this turn.
676
- if (trim_cb = self.class._on_trim_callback)
677
- trim_ctx = Context::TrimContext.new(message_elements: message_elements, budget: budget)
678
- trim_cb.call(trim_ctx)
679
- message_elements = trim_ctx.message_elements
680
- end
681
-
682
- # Run on_compaction_trigger → on_compact pipeline before calling the LLM.
683
- if (trigger_cb = self.class._on_compaction_trigger_callback)
684
- trigger_ctx = Context::TriggerContext.new(
685
- message_elements: message_elements, budget: budget
686
- )
687
- if trigger_cb.call(trigger_ctx)
688
- if (compact_cb = self.class._on_compact_callback)
689
- compact_ctx = Context::CompactionContext.new(
690
- message_elements: message_elements,
691
- budget: budget,
692
- thread_id: thread_id
693
- )
694
- compact_cb.call(compact_ctx)
695
- message_elements = build_message_elements(compact_ctx.result_messages)
696
- end
697
- end
698
- end
699
-
700
- # Build the system prompt via the fingerprint-keyed ContextVersionCache.
701
- # Static knowledge is fetched and concatenated once; the result is reused
702
- # on subsequent calls as long as the fingerprint remains valid.
703
- system_text = build_cached_system_text(input)
704
-
705
- # Assemble context regions 1 (Instruction+Static Knowledge) + 3 (Dynamic Knowledge)
706
- # + 4 (Conversation).
707
- assembler = Context::Assembler.new(budget: budget)
708
- assembler.add_instruction(system_text) if system_text
709
-
710
- # Dynamic knowledge from config[:knowledge_sources] (backward compatible).
711
- Array(config[:knowledge_sources]).each do |ks|
712
- ks.fetch(query: user_message).each do |chunk|
713
- assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
714
- end
715
- end
716
554
 
717
- assembler.add_messages(message_elements.map { |e| e[:message] })
718
-
719
- context = assembler.build
555
+ # Assemble context (system prompt + history). Override #build_context to
556
+ # inject custom context editing logic at the Agent subclass level.
557
+ context = build_context(input, messages: messages, thread_id: thread_id, config: config)
720
558
  apply_instructions(chat, context[:system]) if context[:system]
721
559
  context[:messages].each { |msg| chat.messages << msg }
722
560
 
@@ -732,6 +570,7 @@ module Phronomy
732
570
  rescue SuspendSignal => signal
733
571
  checkpoint = Checkpoint.new(
734
572
  thread_id: thread_id,
573
+ original_input: input,
735
574
  messages: chat.messages.dup,
736
575
  pending_tool_name: signal.tool_name,
737
576
  pending_tool_args: signal.args,
@@ -752,77 +591,6 @@ module Phronomy
752
591
  end
753
592
  end
754
593
 
755
- # Computes the agent-level retry wait duration.
756
- # @param strategy [Symbol, Numeric]
757
- # @param base [Float]
758
- # @param attempt [Integer]
759
- # @return [Float]
760
- def compute_agent_retry_wait(strategy, base, attempt)
761
- case strategy
762
- when :exponential
763
- (2**attempt) * base
764
- when :linear
765
- (attempt + 1) * base
766
- when Numeric
767
- strategy.to_f
768
- else
769
- base.to_f
770
- end
771
- end
772
-
773
- # Collects and runs all registered before_completion hooks in order
774
- # (global → class → instance) and applies the merged params to the chat.
775
- #
776
- # @param chat [RubyLLM::Chat] the assembled chat object
777
- # @param config [Hash] the invocation config hash
778
- # @return [Hash] the merged params applied to the chat
779
- def run_before_completion_hooks!(chat, config)
780
- hooks = [
781
- Phronomy.configuration.before_completion,
782
- self.class._before_completion,
783
- @before_completion
784
- ].compact
785
-
786
- return {} if hooks.empty?
787
-
788
- ctx = BeforeCompletionContext.new(
789
- agent: self,
790
- messages: chat.messages,
791
- config: config,
792
- params: {}
793
- )
794
-
795
- merged = {}
796
- hooks.each do |hook|
797
- result = hook.call(ctx)
798
- merged.merge!(result) if result.is_a?(Hash)
799
- end
800
-
801
- apply_before_completion_params!(chat, merged)
802
- merged
803
- end
804
-
805
- # Applies a merged param hash returned by before_completion hooks to
806
- # the chat object using the appropriate RubyLLM::Chat API methods.
807
- # When overriding the model, reuses the agent's configured provider and
808
- # assume_exists setting so that local/namespaced models continue to work.
809
- #
810
- # @param chat [RubyLLM::Chat]
811
- # @param params [Hash]
812
- def apply_before_completion_params!(chat, params)
813
- params.each do |key, value|
814
- case key
815
- when :model
816
- prov = self.class.provider
817
- chat.with_model(value, provider: prov, assume_exists: !prov.nil?)
818
- when :temperature
819
- chat.with_temperature(value)
820
- else
821
- chat.with_params(key => value)
822
- end
823
- end
824
- end
825
-
826
594
  # Builds a TokenBudget for this agent's model if possible.
827
595
  # When context_window is set at the class level, that value is used directly
828
596
  # (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
@@ -957,39 +725,6 @@ module Phronomy
957
725
  end
958
726
  end
959
727
 
960
- def run_input_guardrails!(input)
961
- (@input_guardrails || []).each { |g| g.run!(input) }
962
- end
963
-
964
- def run_output_guardrails!(output)
965
- (@output_guardrails || []).each { |g| g.run!(output) }
966
- end
967
-
968
- # Registers an on_tool_call hook on the chat object that raises SuspendSignal
969
- # when an approval-required tool is about to be executed and no synchronous
970
- # on_approval_required handler has been registered.
971
- #
972
- # Does nothing when:
973
- # - a synchronous handler is already registered (@approval_handler is set), or
974
- # - none of the agent's tools have requires_approval set.
975
- #
976
- # @param chat [RubyLLM::Chat]
977
- def _register_suspension_hook!(chat)
978
- return if @approval_handler
979
- return if self.class.tools.none? { |tc| tc.requires_approval }
980
-
981
- chat.on_tool_call do |tool_call|
982
- tool_instance = chat.tools[tool_call.name.to_sym]
983
- if tool_instance&.requires_approval
984
- raise SuspendSignal.new(
985
- tool_name: tool_call.name,
986
- args: tool_call.arguments,
987
- tool_call_id: tool_call.id
988
- )
989
- end
990
- end
991
- end
992
-
993
728
  # Builds the final tool class to register with the chat.
994
729
  #
995
730
  # Two transformations are applied in order:
@@ -22,6 +22,11 @@ module Phronomy
22
22
  # @return [String, nil] the thread_id from the invocation config
23
23
  attr_reader :thread_id
24
24
 
25
+ # @return [String, Hash] the original input passed to #invoke; stored so
26
+ # that #resume can re-apply dynamic system instructions (e.g. Proc or
27
+ # PromptTemplate-based instructions that depend on the input value).
28
+ attr_reader :original_input
29
+
25
30
  # @return [Array<RubyLLM::Message>] conversation messages up to and including
26
31
  # the assistant message that requested the pending tool call
27
32
  attr_reader :messages
@@ -36,13 +41,15 @@ module Phronomy
36
41
  # inject the tool result message on resume)
37
42
  attr_reader :pending_tool_call_id
38
43
 
39
- # @param thread_id [String, nil]
40
- # @param messages [Array<RubyLLM::Message>]
41
- # @param pending_tool_name [String]
42
- # @param pending_tool_args [Hash]
44
+ # @param thread_id [String, nil]
45
+ # @param original_input [String, Hash] the input passed to the original #invoke call
46
+ # @param messages [Array<RubyLLM::Message>]
47
+ # @param pending_tool_name [String]
48
+ # @param pending_tool_args [Hash]
43
49
  # @param pending_tool_call_id [String]
44
- def initialize(thread_id:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:)
50
+ def initialize(thread_id:, original_input:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:)
45
51
  @thread_id = thread_id
52
+ @original_input = original_input
46
53
  @messages = messages.dup.freeze
47
54
  @pending_tool_name = pending_tool_name
48
55
  @pending_tool_args = pending_tool_args