phronomy 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +5 -4
- data/lib/phronomy/agent/base.rb +109 -379
- data/lib/phronomy/agent/checkpoint.rb +12 -5
- data/lib/phronomy/agent/concerns/before_completion.rb +105 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +42 -0
- data/lib/phronomy/agent/concerns/retryable.rb +88 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +116 -0
- data/lib/phronomy/agent/react_agent.rb +37 -16
- data/lib/phronomy/agent/team_coordinator.rb +4 -4
- data/lib/phronomy/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 138e6b7d6b59f34f827e39a43b86c6f30ea0dd80e936d11e326febad4d3217b0
|
|
4
|
+
data.tar.gz: fada502e034850a3162a488cb02fc195364fc93e72398e858a79058c005c2ad3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 55526d56e69e328f9de38e75da98a9a1e0d206997f3463a18aa0481f18d978896f02567a0fefcb6ec4fe2a5f030d3829dde59c479305f6e3ce9d825b06222ce8
|
|
7
|
+
data.tar.gz: f58b275260866c5a7784c32c9846c9058cab815d6d294b92041dcf29525bbf76e1683c1151991c092eace65e5e55e41ad5390d6f6106f9306aa053bf42c5c0a8
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-05-20
|
|
11
|
+
|
|
12
|
+
### Breaking Changes
|
|
13
|
+
|
|
14
|
+
- **`Agent::Base#invoke` and `#stream` — `messages` and `thread_id` promoted to
|
|
15
|
+
top-level keyword arguments**:
|
|
16
|
+
Previously these values were passed inside the `config:` hash. They are now
|
|
17
|
+
explicit keyword arguments. The `config:` hash retains other runtime options
|
|
18
|
+
such as `:knowledge_sources`, `:user_id`, and `:session_id`.
|
|
19
|
+
|
|
20
|
+
**Before (v0.4.x)**:
|
|
21
|
+
```ruby
|
|
22
|
+
agent.invoke(input, config: { messages: prior_msgs, thread_id: "t1" })
|
|
23
|
+
agent.stream(input, config: { messages: prior_msgs, thread_id: "t1" }) { |e| ... }
|
|
24
|
+
```
|
|
25
|
+
**After (v0.5.0)**:
|
|
26
|
+
```ruby
|
|
27
|
+
agent.invoke(input, messages: prior_msgs, thread_id: "t1")
|
|
28
|
+
agent.stream(input, messages: prior_msgs, thread_id: "t1") { |e| ... }
|
|
29
|
+
```
|
|
30
|
+
Applications that only pass `:knowledge_sources`, `:user_id`, or `:session_id`
|
|
31
|
+
in `config:` require no changes.
|
|
32
|
+
|
|
33
|
+
- **`Agent::Checkpoint#initialize` — `original_input:` is now a required keyword
|
|
34
|
+
argument**: Applications that construct `Checkpoint` instances directly must
|
|
35
|
+
add `original_input: input`. Checkpoints produced by `#invoke` already include
|
|
36
|
+
this field automatically.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **`ReactAgent#step` — system instructions were never applied**: The first
|
|
41
|
+
iteration of the ReAct loop now calls `build_context` to assemble the system
|
|
42
|
+
prompt and history, matching the behaviour of `Agent::Base`. Subsequent
|
|
43
|
+
iterations re-apply instructions via `build_cached_system_text` before calling
|
|
44
|
+
`chat.complete`. Previously, all iterations silently omitted the system prompt.
|
|
45
|
+
|
|
46
|
+
- **`Agent::Base#resume` — system instructions were not re-applied after
|
|
47
|
+
suspension**: Resuming from a `Checkpoint` now calls `build_cached_system_text`
|
|
48
|
+
using the original input stored in the checkpoint, so the LLM receives the
|
|
49
|
+
correct system prompt when the conversation continues. Previously, the LLM was
|
|
50
|
+
called without any system instructions on resume.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
10
54
|
## [0.4.0] - 2026-05-19
|
|
11
55
|
|
|
12
56
|
### Removed
|
data/README.md
CHANGED
|
@@ -396,18 +396,19 @@ search_tool = Phronomy::Tool::McpTool.from_server(
|
|
|
396
396
|
|
|
397
397
|
### Conversation History — passing prior messages
|
|
398
398
|
|
|
399
|
-
Phronomy does not manage conversation history internally.
|
|
400
|
-
message array and passes it in via `
|
|
399
|
+
Phronomy does not manage conversation history internally. The application owns the
|
|
400
|
+
message array and passes it in via the `messages:` keyword argument:
|
|
401
401
|
|
|
402
402
|
```ruby
|
|
403
403
|
# First turn
|
|
404
|
-
result1 = MyAgent.new.invoke("Hello! I'm Alice.",
|
|
404
|
+
result1 = MyAgent.new.invoke("Hello! I'm Alice.", thread_id: "session-1")
|
|
405
405
|
prior_messages = result1[:messages] # Array<RubyLLM::Message>
|
|
406
406
|
|
|
407
407
|
# Second turn — pass prior messages so the agent has context
|
|
408
408
|
result2 = MyAgent.new.invoke(
|
|
409
409
|
"What is my name?",
|
|
410
|
-
|
|
410
|
+
messages: prior_messages,
|
|
411
|
+
thread_id: "session-1"
|
|
411
412
|
)
|
|
412
413
|
puts result2[:output] # => "Your name is Alice."
|
|
413
414
|
```
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -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.
|
|
@@ -165,35 +173,6 @@ module Phronomy
|
|
|
165
173
|
end
|
|
166
174
|
end
|
|
167
175
|
|
|
168
|
-
# Configures a retry policy that wraps the full #invoke call.
|
|
169
|
-
# GuardrailError is never retried regardless of this setting.
|
|
170
|
-
#
|
|
171
|
-
# @param times [Integer] maximum retry attempts (default: 0)
|
|
172
|
-
# @param wait [Symbol, Numeric] :exponential, :linear, or a fixed Float
|
|
173
|
-
# @param base [Float] base wait time in seconds (default: 1.0)
|
|
174
|
-
#
|
|
175
|
-
# @example
|
|
176
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
177
|
-
# retry_policy times: 2, wait: :exponential, base: 1.0
|
|
178
|
-
# end
|
|
179
|
-
def retry_policy(times: 0, wait: 0, base: 1.0)
|
|
180
|
-
@_retry_policy = {times: times, wait: wait, base: base}
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# Returns the configured retry policy, or nil when none is set.
|
|
184
|
-
# @return [Hash, nil]
|
|
185
|
-
attr_reader :_retry_policy
|
|
186
|
-
|
|
187
|
-
# Injectable sleep callable for testing (shared with Tool::Base pattern).
|
|
188
|
-
# @return [#call]
|
|
189
|
-
def _sleep_proc
|
|
190
|
-
@_sleep_proc || method(:sleep)
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
# Overrides the sleep callable used between retries.
|
|
194
|
-
# @param proc [#call]
|
|
195
|
-
attr_writer :_sleep_proc
|
|
196
|
-
|
|
197
176
|
# Registers one or more static knowledge sources on the agent class.
|
|
198
177
|
# Static sources are fetched once per agent instance and their content
|
|
199
178
|
# is cached in ContextVersionCache keyed by a fingerprint of the
|
|
@@ -352,37 +331,8 @@ module Phronomy
|
|
|
352
331
|
@context_overhead = val.to_i
|
|
353
332
|
end
|
|
354
333
|
end
|
|
355
|
-
|
|
356
|
-
# Sets or reads the class-level before_completion hook.
|
|
357
|
-
# The hook is called before every LLM request for instances of this class.
|
|
358
|
-
# Receives a {Phronomy::Agent::BeforeCompletionContext}; must return a Hash
|
|
359
|
-
# of params to merge into the LLM call, or nil to pass through unchanged.
|
|
360
|
-
#
|
|
361
|
-
# @param callable [#call, nil] lambda/proc to register, or nil to clear
|
|
362
|
-
# @return [#call, nil]
|
|
363
|
-
# @example
|
|
364
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
365
|
-
# before_completion ->(ctx) { { temperature: 0.2 } }
|
|
366
|
-
# end
|
|
367
|
-
def before_completion(callable = nil)
|
|
368
|
-
if callable.nil? && !block_given?
|
|
369
|
-
@before_completion
|
|
370
|
-
else
|
|
371
|
-
@before_completion = callable
|
|
372
|
-
end
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
# @return [#call, nil]
|
|
376
|
-
def _before_completion
|
|
377
|
-
@before_completion
|
|
378
|
-
end
|
|
379
334
|
end
|
|
380
335
|
|
|
381
|
-
# Instance-level before_completion hook. When set, takes precedence over
|
|
382
|
-
# the class-level hook for this specific agent instance only.
|
|
383
|
-
# @return [#call, nil]
|
|
384
|
-
attr_accessor :before_completion
|
|
385
|
-
|
|
386
336
|
# Registers an anonymous handoff tool class on this agent instance.
|
|
387
337
|
# Called by Runner during construction when routes are configured.
|
|
388
338
|
# @param tool_class [Class<Phronomy::Tool::Base>]
|
|
@@ -403,14 +353,18 @@ module Phronomy
|
|
|
403
353
|
# Applies the retry policy configured via {.retry_policy} when transient
|
|
404
354
|
# errors occur. {Phronomy::GuardrailError} is never retried.
|
|
405
355
|
#
|
|
406
|
-
# @param input
|
|
356
|
+
# @param input [String, Hash] the user message; a Hash may supply
|
|
407
357
|
# +:message+, +:query+, or +:user+ as the text key, plus any template
|
|
408
358
|
# variables consumed by the configured instructions template.
|
|
409
|
-
# @param
|
|
410
|
-
#
|
|
411
|
-
#
|
|
412
|
-
#
|
|
413
|
-
#
|
|
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
|
|
414
368
|
# @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
|
|
415
369
|
# or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
|
|
416
370
|
# messages: Array }+ when the invocation was suspended awaiting tool approval.
|
|
@@ -418,14 +372,17 @@ module Phronomy
|
|
|
418
372
|
# @example Normal invocation
|
|
419
373
|
# result = MyAgent.new.invoke("What is Ruby?")
|
|
420
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])
|
|
421
378
|
# @example Suspend / resume flow
|
|
422
379
|
# result = agent.invoke("Perform task X")
|
|
423
380
|
# if result[:suspended]
|
|
424
381
|
# result = agent.resume(result[:checkpoint], approved: true)
|
|
425
382
|
# end
|
|
426
383
|
# puts result[:output]
|
|
427
|
-
def invoke(input, config: {})
|
|
428
|
-
_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)
|
|
429
386
|
end
|
|
430
387
|
|
|
431
388
|
# Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
|
|
@@ -438,104 +395,21 @@ module Phronomy
|
|
|
438
395
|
# :done — final event carrying output, messages, and usage
|
|
439
396
|
# :error — if an unrecoverable error occurs
|
|
440
397
|
#
|
|
441
|
-
# @param input
|
|
442
|
-
# @param
|
|
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
|
|
443
402
|
# @yield [Phronomy::Agent::StreamEvent]
|
|
444
403
|
# @return [Hash] { output:, messages:, usage: } — same as #invoke
|
|
445
|
-
def stream(input, config: {}, &block)
|
|
446
|
-
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
|
|
447
406
|
|
|
448
|
-
_stream_impl(input, config: config, &block)
|
|
407
|
+
_stream_impl(input, messages: messages, thread_id: thread_id, config: config, &block)
|
|
449
408
|
rescue => e
|
|
450
409
|
block&.call(StreamEvent.new(type: :error, payload: {error: e}))
|
|
451
410
|
raise
|
|
452
411
|
end
|
|
453
412
|
|
|
454
|
-
# Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
|
|
455
|
-
#
|
|
456
|
-
# This method reconstructs the conversation state captured at suspension
|
|
457
|
-
# time, injects the tool result (executed or denied), and continues the
|
|
458
|
-
# LLM loop until it produces a final answer.
|
|
459
|
-
#
|
|
460
|
-
# @param checkpoint [Phronomy::Agent::Checkpoint] the checkpoint returned by
|
|
461
|
-
# the suspended #invoke call
|
|
462
|
-
# @param approved [Boolean] +true+ to execute the pending tool; +false+
|
|
463
|
-
# to inject a denial message and let the LLM handle it gracefully
|
|
464
|
-
# @param config [Hash] same runtime options as #invoke
|
|
465
|
-
# @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
|
|
466
|
-
# @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
|
|
467
|
-
def resume(checkpoint, approved:, config: {})
|
|
468
|
-
checkpoint.thread_id
|
|
469
|
-
|
|
470
|
-
# Build a fresh chat with all tools registered.
|
|
471
|
-
chat = build_chat
|
|
472
|
-
|
|
473
|
-
# Restore the full conversation (system + history + user + assistant).
|
|
474
|
-
checkpoint.messages.each { |msg| chat.messages << msg }
|
|
475
|
-
|
|
476
|
-
# Determine the tool result: execute it or inject a denial string.
|
|
477
|
-
tool_result =
|
|
478
|
-
if approved
|
|
479
|
-
tool_instance = chat.tools[checkpoint.pending_tool_name.to_sym]
|
|
480
|
-
tool_instance ? tool_instance.call(checkpoint.pending_tool_args) : "Tool not found."
|
|
481
|
-
else
|
|
482
|
-
"Tool execution denied."
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
# Inject the tool result so the LLM can continue.
|
|
486
|
-
chat.add_message(
|
|
487
|
-
role: :tool,
|
|
488
|
-
content: tool_result.to_s,
|
|
489
|
-
tool_call_id: checkpoint.pending_tool_call_id
|
|
490
|
-
)
|
|
491
|
-
|
|
492
|
-
# Continue the React loop.
|
|
493
|
-
response = chat.complete
|
|
494
|
-
|
|
495
|
-
output = response.content
|
|
496
|
-
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
497
|
-
|
|
498
|
-
run_output_guardrails!(output)
|
|
499
|
-
|
|
500
|
-
{output: output, suspended: false, messages: chat.messages, usage: usage}
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
# Registers a callback that is invoked before executing any tool that has
|
|
504
|
-
# +requires_approval true+ set. The block receives the tool name (String)
|
|
505
|
-
# and the arguments Hash, and must return a truthy value to allow execution.
|
|
506
|
-
# Returning a falsy value causes the tool to return a denial message instead
|
|
507
|
-
# of executing.
|
|
508
|
-
#
|
|
509
|
-
# When no handler is registered and a tool with +requires_approval+ is
|
|
510
|
-
# called, #invoke returns a suspended result hash containing a
|
|
511
|
-
# {Phronomy::Agent::Checkpoint}. Call #resume to continue execution after
|
|
512
|
-
# obtaining an approval decision from the user or an external system.
|
|
513
|
-
#
|
|
514
|
-
# @example Synchronous handler
|
|
515
|
-
# agent = MyAgent.new
|
|
516
|
-
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
517
|
-
# @return [self]
|
|
518
|
-
def on_approval_required(&block)
|
|
519
|
-
@approval_handler = block
|
|
520
|
-
self
|
|
521
|
-
end
|
|
522
|
-
|
|
523
|
-
# Attach a guardrail that validates input before every #invoke call.
|
|
524
|
-
# @param guardrail [Phronomy::Guardrail::InputGuardrail]
|
|
525
|
-
def add_input_guardrail(guardrail)
|
|
526
|
-
@input_guardrails ||= []
|
|
527
|
-
@input_guardrails << guardrail
|
|
528
|
-
self
|
|
529
|
-
end
|
|
530
|
-
|
|
531
|
-
# Attach a guardrail that validates output before it is returned.
|
|
532
|
-
# @param guardrail [Phronomy::Guardrail::OutputGuardrail]
|
|
533
|
-
def add_output_guardrail(guardrail)
|
|
534
|
-
@output_guardrails ||= []
|
|
535
|
-
@output_guardrails << guardrail
|
|
536
|
-
self
|
|
537
|
-
end
|
|
538
|
-
|
|
539
413
|
# Returns the {Context::ContextVersionCache} for the current thread.
|
|
540
414
|
# @api private
|
|
541
415
|
def context_version_cache
|
|
@@ -544,27 +418,8 @@ module Phronomy
|
|
|
544
418
|
|
|
545
419
|
private
|
|
546
420
|
|
|
547
|
-
# Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
|
|
548
|
-
def _invoke_impl(input, config: {})
|
|
549
|
-
policy = self.class._retry_policy
|
|
550
|
-
attempt = 0
|
|
551
|
-
begin
|
|
552
|
-
invoke_once(input, config: config)
|
|
553
|
-
rescue Phronomy::GuardrailError
|
|
554
|
-
raise
|
|
555
|
-
rescue
|
|
556
|
-
if policy && attempt < policy[:times]
|
|
557
|
-
wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
|
|
558
|
-
self.class._sleep_proc.call(wait) if wait > 0
|
|
559
|
-
attempt += 1
|
|
560
|
-
retry
|
|
561
|
-
end
|
|
562
|
-
raise
|
|
563
|
-
end
|
|
564
|
-
end
|
|
565
|
-
|
|
566
421
|
# Streaming implementation for #stream.
|
|
567
|
-
def _stream_impl(input, config: {}, &block)
|
|
422
|
+
def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
|
|
568
423
|
caller_meta = {}
|
|
569
424
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
570
425
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
@@ -572,54 +427,12 @@ module Phronomy
|
|
|
572
427
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
573
428
|
run_input_guardrails!(input)
|
|
574
429
|
|
|
575
|
-
thread_id = config[:thread_id]
|
|
576
|
-
|
|
577
430
|
chat = build_chat
|
|
578
431
|
user_message = extract_message(input)
|
|
579
|
-
budget = build_token_budget
|
|
580
|
-
|
|
581
|
-
# Assemble context via Assembler (same as invoke_once).
|
|
582
|
-
assembler = Context::Assembler.new(budget: budget)
|
|
583
|
-
system_msg = build_instructions(input)
|
|
584
|
-
assembler.add_instruction(system_msg) if system_msg
|
|
585
|
-
|
|
586
|
-
Array(config[:knowledge_sources]).each do |ks|
|
|
587
|
-
ks.fetch(query: user_message).each do |chunk|
|
|
588
|
-
assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
|
|
589
|
-
end
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
msgs = Array(config[:messages])
|
|
593
|
-
unless msgs.empty?
|
|
594
|
-
message_elements = build_message_elements(msgs)
|
|
595
|
-
|
|
596
|
-
# Run on_trim: app may call ctx.remove(seqs) to drop messages this turn.
|
|
597
|
-
if (trim_cb = self.class._on_trim_callback)
|
|
598
|
-
trim_ctx = Context::TrimContext.new(message_elements: message_elements, budget: budget)
|
|
599
|
-
trim_cb.call(trim_ctx)
|
|
600
|
-
message_elements = trim_ctx.message_elements
|
|
601
|
-
end
|
|
602
|
-
|
|
603
|
-
# Run on_compaction_trigger → on_compact pipeline before calling the LLM.
|
|
604
|
-
if (trigger_cb = self.class._on_compaction_trigger_callback)
|
|
605
|
-
trigger_ctx = Context::TriggerContext.new(message_elements: message_elements, budget: budget)
|
|
606
|
-
if trigger_cb.call(trigger_ctx)
|
|
607
|
-
if (compact_cb = self.class._on_compact_callback)
|
|
608
|
-
compact_ctx = Context::CompactionContext.new(
|
|
609
|
-
message_elements: message_elements,
|
|
610
|
-
budget: budget,
|
|
611
|
-
thread_id: thread_id
|
|
612
|
-
)
|
|
613
|
-
compact_cb.call(compact_ctx)
|
|
614
|
-
message_elements = build_message_elements(compact_ctx.result_messages)
|
|
615
|
-
end
|
|
616
|
-
end
|
|
617
|
-
end
|
|
618
|
-
|
|
619
|
-
assembler.add_messages(message_elements.map { |e| e[:message] })
|
|
620
|
-
end
|
|
621
432
|
|
|
622
|
-
context
|
|
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)
|
|
623
436
|
apply_instructions(chat, context[:system]) if context[:system]
|
|
624
437
|
context[:messages].each { |msg| chat.messages << msg }
|
|
625
438
|
|
|
@@ -655,9 +468,79 @@ module Phronomy
|
|
|
655
468
|
end
|
|
656
469
|
end
|
|
657
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
|
+
|
|
658
541
|
# Performs a single (non-retried) invocation. Extracted so that #invoke can
|
|
659
542
|
# wrap it in a retry loop without duplicating the LLM interaction logic.
|
|
660
|
-
def invoke_once(input, config: {})
|
|
543
|
+
def invoke_once(input, messages: [], thread_id: nil, config: {})
|
|
661
544
|
caller_meta = {}
|
|
662
545
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
663
546
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
@@ -666,62 +549,12 @@ module Phronomy
|
|
|
666
549
|
# Run input guardrails before touching the LLM.
|
|
667
550
|
run_input_guardrails!(input)
|
|
668
551
|
|
|
669
|
-
thread_id = config[:thread_id]
|
|
670
552
|
user_message = extract_message(input)
|
|
671
553
|
chat = build_chat
|
|
672
|
-
budget = build_token_budget
|
|
673
|
-
|
|
674
|
-
# Load conversation history from config[:messages] (app-managed).
|
|
675
|
-
raw_messages = Array(config[:messages])
|
|
676
|
-
|
|
677
|
-
# Assign synthetic 0-based seq numbers for use by trim/compaction callbacks.
|
|
678
|
-
message_elements = build_message_elements(raw_messages)
|
|
679
|
-
|
|
680
|
-
# Run on_trim: app may call ctx.remove(seqs) to drop messages this turn.
|
|
681
|
-
if (trim_cb = self.class._on_trim_callback)
|
|
682
|
-
trim_ctx = Context::TrimContext.new(message_elements: message_elements, budget: budget)
|
|
683
|
-
trim_cb.call(trim_ctx)
|
|
684
|
-
message_elements = trim_ctx.message_elements
|
|
685
|
-
end
|
|
686
|
-
|
|
687
|
-
# Run on_compaction_trigger → on_compact pipeline before calling the LLM.
|
|
688
|
-
if (trigger_cb = self.class._on_compaction_trigger_callback)
|
|
689
|
-
trigger_ctx = Context::TriggerContext.new(
|
|
690
|
-
message_elements: message_elements, budget: budget
|
|
691
|
-
)
|
|
692
|
-
if trigger_cb.call(trigger_ctx)
|
|
693
|
-
if (compact_cb = self.class._on_compact_callback)
|
|
694
|
-
compact_ctx = Context::CompactionContext.new(
|
|
695
|
-
message_elements: message_elements,
|
|
696
|
-
budget: budget,
|
|
697
|
-
thread_id: thread_id
|
|
698
|
-
)
|
|
699
|
-
compact_cb.call(compact_ctx)
|
|
700
|
-
message_elements = build_message_elements(compact_ctx.result_messages)
|
|
701
|
-
end
|
|
702
|
-
end
|
|
703
|
-
end
|
|
704
|
-
|
|
705
|
-
# Build the system prompt via the fingerprint-keyed ContextVersionCache.
|
|
706
|
-
# Static knowledge is fetched and concatenated once; the result is reused
|
|
707
|
-
# on subsequent calls as long as the fingerprint remains valid.
|
|
708
|
-
system_text = build_cached_system_text(input)
|
|
709
|
-
|
|
710
|
-
# Assemble context regions 1 (Instruction+Static Knowledge) + 3 (Dynamic Knowledge)
|
|
711
|
-
# + 4 (Conversation).
|
|
712
|
-
assembler = Context::Assembler.new(budget: budget)
|
|
713
|
-
assembler.add_instruction(system_text) if system_text
|
|
714
|
-
|
|
715
|
-
# Dynamic knowledge from config[:knowledge_sources] (backward compatible).
|
|
716
|
-
Array(config[:knowledge_sources]).each do |ks|
|
|
717
|
-
ks.fetch(query: user_message).each do |chunk|
|
|
718
|
-
assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
|
|
719
|
-
end
|
|
720
|
-
end
|
|
721
554
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
context =
|
|
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)
|
|
725
558
|
apply_instructions(chat, context[:system]) if context[:system]
|
|
726
559
|
context[:messages].each { |msg| chat.messages << msg }
|
|
727
560
|
|
|
@@ -737,6 +570,7 @@ module Phronomy
|
|
|
737
570
|
rescue SuspendSignal => signal
|
|
738
571
|
checkpoint = Checkpoint.new(
|
|
739
572
|
thread_id: thread_id,
|
|
573
|
+
original_input: input,
|
|
740
574
|
messages: chat.messages.dup,
|
|
741
575
|
pending_tool_name: signal.tool_name,
|
|
742
576
|
pending_tool_args: signal.args,
|
|
@@ -757,77 +591,6 @@ module Phronomy
|
|
|
757
591
|
end
|
|
758
592
|
end
|
|
759
593
|
|
|
760
|
-
# Computes the agent-level retry wait duration.
|
|
761
|
-
# @param strategy [Symbol, Numeric]
|
|
762
|
-
# @param base [Float]
|
|
763
|
-
# @param attempt [Integer]
|
|
764
|
-
# @return [Float]
|
|
765
|
-
def compute_agent_retry_wait(strategy, base, attempt)
|
|
766
|
-
case strategy
|
|
767
|
-
when :exponential
|
|
768
|
-
(2**attempt) * base
|
|
769
|
-
when :linear
|
|
770
|
-
(attempt + 1) * base
|
|
771
|
-
when Numeric
|
|
772
|
-
strategy.to_f
|
|
773
|
-
else
|
|
774
|
-
base.to_f
|
|
775
|
-
end
|
|
776
|
-
end
|
|
777
|
-
|
|
778
|
-
# Collects and runs all registered before_completion hooks in order
|
|
779
|
-
# (global → class → instance) and applies the merged params to the chat.
|
|
780
|
-
#
|
|
781
|
-
# @param chat [RubyLLM::Chat] the assembled chat object
|
|
782
|
-
# @param config [Hash] the invocation config hash
|
|
783
|
-
# @return [Hash] the merged params applied to the chat
|
|
784
|
-
def run_before_completion_hooks!(chat, config)
|
|
785
|
-
hooks = [
|
|
786
|
-
Phronomy.configuration.before_completion,
|
|
787
|
-
self.class._before_completion,
|
|
788
|
-
@before_completion
|
|
789
|
-
].compact
|
|
790
|
-
|
|
791
|
-
return {} if hooks.empty?
|
|
792
|
-
|
|
793
|
-
ctx = BeforeCompletionContext.new(
|
|
794
|
-
agent: self,
|
|
795
|
-
messages: chat.messages,
|
|
796
|
-
config: config,
|
|
797
|
-
params: {}
|
|
798
|
-
)
|
|
799
|
-
|
|
800
|
-
merged = {}
|
|
801
|
-
hooks.each do |hook|
|
|
802
|
-
result = hook.call(ctx)
|
|
803
|
-
merged.merge!(result) if result.is_a?(Hash)
|
|
804
|
-
end
|
|
805
|
-
|
|
806
|
-
apply_before_completion_params!(chat, merged)
|
|
807
|
-
merged
|
|
808
|
-
end
|
|
809
|
-
|
|
810
|
-
# Applies a merged param hash returned by before_completion hooks to
|
|
811
|
-
# the chat object using the appropriate RubyLLM::Chat API methods.
|
|
812
|
-
# When overriding the model, reuses the agent's configured provider and
|
|
813
|
-
# assume_exists setting so that local/namespaced models continue to work.
|
|
814
|
-
#
|
|
815
|
-
# @param chat [RubyLLM::Chat]
|
|
816
|
-
# @param params [Hash]
|
|
817
|
-
def apply_before_completion_params!(chat, params)
|
|
818
|
-
params.each do |key, value|
|
|
819
|
-
case key
|
|
820
|
-
when :model
|
|
821
|
-
prov = self.class.provider
|
|
822
|
-
chat.with_model(value, provider: prov, assume_exists: !prov.nil?)
|
|
823
|
-
when :temperature
|
|
824
|
-
chat.with_temperature(value)
|
|
825
|
-
else
|
|
826
|
-
chat.with_params(key => value)
|
|
827
|
-
end
|
|
828
|
-
end
|
|
829
|
-
end
|
|
830
|
-
|
|
831
594
|
# Builds a TokenBudget for this agent's model if possible.
|
|
832
595
|
# When context_window is set at the class level, that value is used directly
|
|
833
596
|
# (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
|
|
@@ -962,39 +725,6 @@ module Phronomy
|
|
|
962
725
|
end
|
|
963
726
|
end
|
|
964
727
|
|
|
965
|
-
def run_input_guardrails!(input)
|
|
966
|
-
(@input_guardrails || []).each { |g| g.run!(input) }
|
|
967
|
-
end
|
|
968
|
-
|
|
969
|
-
def run_output_guardrails!(output)
|
|
970
|
-
(@output_guardrails || []).each { |g| g.run!(output) }
|
|
971
|
-
end
|
|
972
|
-
|
|
973
|
-
# Registers an on_tool_call hook on the chat object that raises SuspendSignal
|
|
974
|
-
# when an approval-required tool is about to be executed and no synchronous
|
|
975
|
-
# on_approval_required handler has been registered.
|
|
976
|
-
#
|
|
977
|
-
# Does nothing when:
|
|
978
|
-
# - a synchronous handler is already registered (@approval_handler is set), or
|
|
979
|
-
# - none of the agent's tools have requires_approval set.
|
|
980
|
-
#
|
|
981
|
-
# @param chat [RubyLLM::Chat]
|
|
982
|
-
def _register_suspension_hook!(chat)
|
|
983
|
-
return if @approval_handler
|
|
984
|
-
return if self.class.tools.none? { |tc| tc.requires_approval }
|
|
985
|
-
|
|
986
|
-
chat.on_tool_call do |tool_call|
|
|
987
|
-
tool_instance = chat.tools[tool_call.name.to_sym]
|
|
988
|
-
if tool_instance&.requires_approval
|
|
989
|
-
raise SuspendSignal.new(
|
|
990
|
-
tool_name: tool_call.name,
|
|
991
|
-
args: tool_call.arguments,
|
|
992
|
-
tool_call_id: tool_call.id
|
|
993
|
-
)
|
|
994
|
-
end
|
|
995
|
-
end
|
|
996
|
-
end
|
|
997
|
-
|
|
998
728
|
# Builds the final tool class to register with the chat.
|
|
999
729
|
#
|
|
1000
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
|
|
40
|
-
# @param
|
|
41
|
-
# @param
|
|
42
|
-
# @param
|
|
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
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Concerns
|
|
6
|
+
# Adds before_completion hook support to an agent.
|
|
7
|
+
#
|
|
8
|
+
# Included in {Phronomy::Agent::Base}. Hooks are executed just before every
|
|
9
|
+
# LLM call (global → class → instance order) and may inject or override
|
|
10
|
+
# LLM parameters such as temperature or model.
|
|
11
|
+
module BeforeCompletion
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.extend(ClassMethods)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Class-level DSL methods mixed into the including agent class.
|
|
17
|
+
module ClassMethods
|
|
18
|
+
# Sets or reads the class-level before_completion hook.
|
|
19
|
+
# The hook is called before every LLM request for instances of this class.
|
|
20
|
+
# Receives a {Phronomy::Agent::BeforeCompletionContext}; must return a Hash
|
|
21
|
+
# of params to merge into the LLM call, or nil to pass through unchanged.
|
|
22
|
+
#
|
|
23
|
+
# @param callable [#call, nil] lambda/proc to register, or nil to clear
|
|
24
|
+
# @return [#call, nil]
|
|
25
|
+
# @example
|
|
26
|
+
# class MyAgent < Phronomy::Agent::Base
|
|
27
|
+
# before_completion ->(ctx) { { temperature: 0.2 } }
|
|
28
|
+
# end
|
|
29
|
+
def before_completion(callable = nil)
|
|
30
|
+
if callable.nil? && !block_given?
|
|
31
|
+
@before_completion
|
|
32
|
+
else
|
|
33
|
+
@before_completion = callable
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [#call, nil]
|
|
38
|
+
def _before_completion
|
|
39
|
+
@before_completion
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Instance-level before_completion hook. When set, takes precedence over
|
|
44
|
+
# the class-level hook for this specific agent instance only.
|
|
45
|
+
# @return [#call, nil]
|
|
46
|
+
attr_accessor :before_completion
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Collects and runs all registered before_completion hooks in order
|
|
51
|
+
# (global → class → instance) and applies the merged params to the chat.
|
|
52
|
+
#
|
|
53
|
+
# @param chat [RubyLLM::Chat] the assembled chat object
|
|
54
|
+
# @param config [Hash] the invocation config hash
|
|
55
|
+
# @return [Hash] the merged params applied to the chat
|
|
56
|
+
def run_before_completion_hooks!(chat, config)
|
|
57
|
+
hooks = [
|
|
58
|
+
Phronomy.configuration.before_completion,
|
|
59
|
+
self.class._before_completion,
|
|
60
|
+
@before_completion
|
|
61
|
+
].compact
|
|
62
|
+
|
|
63
|
+
return {} if hooks.empty?
|
|
64
|
+
|
|
65
|
+
ctx = BeforeCompletionContext.new(
|
|
66
|
+
agent: self,
|
|
67
|
+
messages: chat.messages,
|
|
68
|
+
config: config,
|
|
69
|
+
params: {}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
merged = {}
|
|
73
|
+
hooks.each do |hook|
|
|
74
|
+
result = hook.call(ctx)
|
|
75
|
+
merged.merge!(result) if result.is_a?(Hash)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
apply_before_completion_params!(chat, merged)
|
|
79
|
+
merged
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Applies a merged param hash returned by before_completion hooks to
|
|
83
|
+
# the chat object using the appropriate RubyLLM::Chat API methods.
|
|
84
|
+
# When overriding the model, reuses the agent's configured provider and
|
|
85
|
+
# assume_exists setting so that local/namespaced models continue to work.
|
|
86
|
+
#
|
|
87
|
+
# @param chat [RubyLLM::Chat]
|
|
88
|
+
# @param params [Hash]
|
|
89
|
+
def apply_before_completion_params!(chat, params)
|
|
90
|
+
params.each do |key, value|
|
|
91
|
+
case key
|
|
92
|
+
when :model
|
|
93
|
+
prov = self.class.provider
|
|
94
|
+
chat.with_model(value, provider: prov, assume_exists: !prov.nil?)
|
|
95
|
+
when :temperature
|
|
96
|
+
chat.with_temperature(value)
|
|
97
|
+
else
|
|
98
|
+
chat.with_params(key => value)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Concerns
|
|
6
|
+
# Adds input and output guardrail support to an agent.
|
|
7
|
+
#
|
|
8
|
+
# Included in {Phronomy::Agent::Base}. Guardrails are run on the raw
|
|
9
|
+
# input string before the LLM is called, and on the raw output string
|
|
10
|
+
# before the result is returned to the caller.
|
|
11
|
+
module Guardrailable
|
|
12
|
+
# Attach a guardrail that validates input before every #invoke call.
|
|
13
|
+
# @param guardrail [Phronomy::Guardrail::InputGuardrail]
|
|
14
|
+
# @return [self]
|
|
15
|
+
def add_input_guardrail(guardrail)
|
|
16
|
+
@input_guardrails ||= []
|
|
17
|
+
@input_guardrails << guardrail
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Attach a guardrail that validates output before it is returned.
|
|
22
|
+
# @param guardrail [Phronomy::Guardrail::OutputGuardrail]
|
|
23
|
+
# @return [self]
|
|
24
|
+
def add_output_guardrail(guardrail)
|
|
25
|
+
@output_guardrails ||= []
|
|
26
|
+
@output_guardrails << guardrail
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def run_input_guardrails!(input)
|
|
33
|
+
(@input_guardrails || []).each { |g| g.run!(input) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run_output_guardrails!(output)
|
|
37
|
+
(@output_guardrails || []).each { |g| g.run!(output) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Concerns
|
|
6
|
+
# Adds configurable retry behaviour to an agent.
|
|
7
|
+
#
|
|
8
|
+
# Included in {Phronomy::Agent::Base}. The retry loop wraps the full
|
|
9
|
+
# #invoke_once call; {Phronomy::GuardrailError} is never retried.
|
|
10
|
+
module Retryable
|
|
11
|
+
def self.included(base)
|
|
12
|
+
base.extend(ClassMethods)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Class-level DSL methods mixed into the including agent class.
|
|
16
|
+
module ClassMethods
|
|
17
|
+
# Configures a retry policy that wraps the full #invoke call.
|
|
18
|
+
# GuardrailError is never retried regardless of this setting.
|
|
19
|
+
#
|
|
20
|
+
# @param times [Integer] maximum retry attempts (default: 0)
|
|
21
|
+
# @param wait [Symbol, Numeric] :exponential, :linear, or a fixed Float
|
|
22
|
+
# @param base [Float] base wait time in seconds (default: 1.0)
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# class MyAgent < Phronomy::Agent::Base
|
|
26
|
+
# retry_policy times: 2, wait: :exponential, base: 1.0
|
|
27
|
+
# end
|
|
28
|
+
def retry_policy(times: 0, wait: 0, base: 1.0)
|
|
29
|
+
@_retry_policy = {times: times, wait: wait, base: base}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns the configured retry policy, or nil when none is set.
|
|
33
|
+
# @return [Hash, nil]
|
|
34
|
+
attr_reader :_retry_policy
|
|
35
|
+
|
|
36
|
+
# Injectable sleep callable for testing (shared with Tool::Base pattern).
|
|
37
|
+
# @return [#call]
|
|
38
|
+
def _sleep_proc
|
|
39
|
+
@_sleep_proc || method(:sleep)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Overrides the sleep callable used between retries.
|
|
43
|
+
# @param proc [#call]
|
|
44
|
+
attr_writer :_sleep_proc
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
|
|
50
|
+
def _invoke_impl(input, messages: [], thread_id: nil, config: {})
|
|
51
|
+
policy = self.class._retry_policy
|
|
52
|
+
attempt = 0
|
|
53
|
+
begin
|
|
54
|
+
invoke_once(input, messages: messages, thread_id: thread_id, config: config)
|
|
55
|
+
rescue Phronomy::GuardrailError
|
|
56
|
+
raise
|
|
57
|
+
rescue
|
|
58
|
+
if policy && attempt < policy[:times]
|
|
59
|
+
wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
|
|
60
|
+
self.class._sleep_proc.call(wait) if wait > 0
|
|
61
|
+
attempt += 1
|
|
62
|
+
retry
|
|
63
|
+
end
|
|
64
|
+
raise
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Computes the agent-level retry wait duration.
|
|
69
|
+
# @param strategy [Symbol, Numeric]
|
|
70
|
+
# @param base [Float]
|
|
71
|
+
# @param attempt [Integer]
|
|
72
|
+
# @return [Float]
|
|
73
|
+
def compute_agent_retry_wait(strategy, base, attempt)
|
|
74
|
+
case strategy
|
|
75
|
+
when :exponential
|
|
76
|
+
(2**attempt) * base
|
|
77
|
+
when :linear
|
|
78
|
+
(attempt + 1) * base
|
|
79
|
+
when Numeric
|
|
80
|
+
strategy.to_f
|
|
81
|
+
else
|
|
82
|
+
base.to_f
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Concerns
|
|
6
|
+
# Adds suspend/resume and tool-approval support to an agent.
|
|
7
|
+
#
|
|
8
|
+
# Included in {Phronomy::Agent::Base}. When a tool decorated with
|
|
9
|
+
# +requires_approval true+ is called and no synchronous approval handler
|
|
10
|
+
# has been registered, the invocation is suspended and a
|
|
11
|
+
# {Phronomy::Agent::Checkpoint} is returned so the caller can resume later.
|
|
12
|
+
module Suspendable
|
|
13
|
+
# Registers a callback that is invoked before executing any tool that has
|
|
14
|
+
# +requires_approval true+ set. The block receives the tool name (String)
|
|
15
|
+
# and the arguments Hash, and must return a truthy value to allow execution.
|
|
16
|
+
# Returning a falsy value causes the tool to return a denial message instead
|
|
17
|
+
# of executing.
|
|
18
|
+
#
|
|
19
|
+
# When no handler is registered and a tool with +requires_approval+ is
|
|
20
|
+
# called, #invoke returns a suspended result hash containing a
|
|
21
|
+
# {Phronomy::Agent::Checkpoint}. Call #resume to continue execution after
|
|
22
|
+
# obtaining an approval decision from the user or an external system.
|
|
23
|
+
#
|
|
24
|
+
# @example Synchronous handler
|
|
25
|
+
# agent = MyAgent.new
|
|
26
|
+
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
27
|
+
# @return [self]
|
|
28
|
+
def on_approval_required(&block)
|
|
29
|
+
@approval_handler = block
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
|
|
34
|
+
#
|
|
35
|
+
# This method reconstructs the conversation state captured at suspension
|
|
36
|
+
# time, injects the tool result (executed or denied), and continues the
|
|
37
|
+
# LLM loop until it produces a final answer.
|
|
38
|
+
#
|
|
39
|
+
# @param checkpoint [Phronomy::Agent::Checkpoint] the checkpoint returned by
|
|
40
|
+
# the suspended #invoke call
|
|
41
|
+
# @param approved [Boolean] +true+ to execute the pending tool; +false+
|
|
42
|
+
# to inject a denial message and let the LLM handle it gracefully
|
|
43
|
+
# @param config [Hash] same runtime options as #invoke
|
|
44
|
+
# @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
|
|
45
|
+
# @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
|
|
46
|
+
def resume(checkpoint, approved:, config: {})
|
|
47
|
+
# Build a fresh chat with all tools registered.
|
|
48
|
+
chat = build_chat
|
|
49
|
+
|
|
50
|
+
# Re-apply system instructions so the LLM has the same persona/context
|
|
51
|
+
# as the original invocation. build_cached_system_text is memoised, so
|
|
52
|
+
# a Proc- or PromptTemplate-based instructions block is re-evaluated
|
|
53
|
+
# against the original input rather than using a stale cached value.
|
|
54
|
+
system_text = build_cached_system_text(checkpoint.original_input)
|
|
55
|
+
apply_instructions(chat, system_text) if system_text
|
|
56
|
+
|
|
57
|
+
# Restore the full conversation (history + user + assistant with tool call).
|
|
58
|
+
checkpoint.messages.each { |msg| chat.messages << msg }
|
|
59
|
+
|
|
60
|
+
# Determine the tool result: execute it or inject a denial string.
|
|
61
|
+
tool_result =
|
|
62
|
+
if approved
|
|
63
|
+
tool_instance = chat.tools[checkpoint.pending_tool_name.to_sym]
|
|
64
|
+
tool_instance ? tool_instance.call(checkpoint.pending_tool_args) : "Tool not found."
|
|
65
|
+
else
|
|
66
|
+
"Tool execution denied."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Inject the tool result so the LLM can continue.
|
|
70
|
+
chat.add_message(
|
|
71
|
+
role: :tool,
|
|
72
|
+
content: tool_result.to_s,
|
|
73
|
+
tool_call_id: checkpoint.pending_tool_call_id
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Continue the React loop.
|
|
77
|
+
response = chat.complete
|
|
78
|
+
|
|
79
|
+
output = response.content
|
|
80
|
+
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
81
|
+
|
|
82
|
+
run_output_guardrails!(output)
|
|
83
|
+
|
|
84
|
+
{output: output, suspended: false, messages: chat.messages, usage: usage}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Registers an on_tool_call hook on the chat object that raises SuspendSignal
|
|
90
|
+
# when an approval-required tool is about to be executed and no synchronous
|
|
91
|
+
# on_approval_required handler has been registered.
|
|
92
|
+
#
|
|
93
|
+
# Does nothing when:
|
|
94
|
+
# - a synchronous handler is already registered (@approval_handler is set), or
|
|
95
|
+
# - none of the agent's tools have requires_approval set.
|
|
96
|
+
#
|
|
97
|
+
# @param chat [RubyLLM::Chat]
|
|
98
|
+
def _register_suspension_hook!(chat)
|
|
99
|
+
return if @approval_handler
|
|
100
|
+
return if self.class.tools.none? { |tc| tc.requires_approval }
|
|
101
|
+
|
|
102
|
+
chat.on_tool_call do |tool_call|
|
|
103
|
+
tool_instance = chat.tools[tool_call.name.to_sym]
|
|
104
|
+
if tool_instance&.requires_approval
|
|
105
|
+
raise SuspendSignal.new(
|
|
106
|
+
tool_name: tool_call.name,
|
|
107
|
+
args: tool_call.arguments,
|
|
108
|
+
tool_call_id: tool_call.id
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -9,7 +9,7 @@ module Phronomy
|
|
|
9
9
|
|
|
10
10
|
# Performs a single (non-retried) ReAct invocation.
|
|
11
11
|
# Overrides Base#invoke_once so that Base#invoke's retry loop is inherited.
|
|
12
|
-
def invoke_once(input, config: {})
|
|
12
|
+
def invoke_once(input, messages: [], thread_id: nil, config: {})
|
|
13
13
|
caller_meta = {}
|
|
14
14
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
15
15
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
@@ -18,17 +18,16 @@ module Phronomy
|
|
|
18
18
|
# Run input guardrails before any LLM interaction.
|
|
19
19
|
run_input_guardrails!(input)
|
|
20
20
|
|
|
21
|
-
config[:thread_id]
|
|
22
21
|
max_iter = self.class.max_iterations
|
|
23
22
|
|
|
24
23
|
# Seed with app-managed conversation history when provided.
|
|
25
|
-
messages = Array(
|
|
24
|
+
messages = Array(messages).dup
|
|
26
25
|
user_asked = false
|
|
27
26
|
total_usage = Phronomy::TokenUsage.zero
|
|
28
27
|
iterations_exhausted = true
|
|
29
28
|
|
|
30
29
|
max_iter.times do
|
|
31
|
-
response = step(messages, input, user_asked: user_asked, config: config)
|
|
30
|
+
response = step(messages, input, user_asked: user_asked, thread_id: thread_id, config: config)
|
|
32
31
|
user_asked = true
|
|
33
32
|
messages = response[:messages]
|
|
34
33
|
total_usage += response[:usage]
|
|
@@ -55,12 +54,14 @@ module Phronomy
|
|
|
55
54
|
# Streaming version of #invoke for the ReAct loop.
|
|
56
55
|
# Yields {Phronomy::Agent::StreamEvent} events while the LLM-tool loop runs.
|
|
57
56
|
#
|
|
58
|
-
# @param input
|
|
59
|
-
# @param
|
|
57
|
+
# @param input [String, Hash]
|
|
58
|
+
# @param messages [Array<RubyLLM::Message>] same as #invoke
|
|
59
|
+
# @param thread_id [String, nil] same as #invoke
|
|
60
|
+
# @param config [Hash]
|
|
60
61
|
# @yield [Phronomy::Agent::StreamEvent]
|
|
61
62
|
# @return [Hash] { output:, messages:, usage: }
|
|
62
|
-
def stream(input, config: {}, &block)
|
|
63
|
-
return invoke(input, config: config) unless block
|
|
63
|
+
def stream(input, messages: [], thread_id: nil, config: {}, &block)
|
|
64
|
+
return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block
|
|
64
65
|
|
|
65
66
|
caller_meta = {}
|
|
66
67
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
@@ -69,16 +70,15 @@ module Phronomy
|
|
|
69
70
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
70
71
|
run_input_guardrails!(input)
|
|
71
72
|
|
|
72
|
-
config[:thread_id]
|
|
73
73
|
max_iter = self.class.max_iterations
|
|
74
74
|
|
|
75
|
-
messages = Array(
|
|
75
|
+
messages = Array(messages).dup
|
|
76
76
|
user_asked = false
|
|
77
77
|
total_usage = Phronomy::TokenUsage.zero
|
|
78
78
|
iterations_exhausted = true
|
|
79
79
|
|
|
80
80
|
max_iter.times do
|
|
81
|
-
response = stream_step(messages, input, user_asked: user_asked, config: config, &block)
|
|
81
|
+
response = stream_step(messages, input, user_asked: user_asked, thread_id: thread_id, config: config, &block)
|
|
82
82
|
user_asked = true
|
|
83
83
|
messages = response[:messages]
|
|
84
84
|
total_usage += response[:usage]
|
|
@@ -104,11 +104,23 @@ module Phronomy
|
|
|
104
104
|
|
|
105
105
|
private
|
|
106
106
|
|
|
107
|
-
def step(messages, initial_input, user_asked: false, config: {})
|
|
107
|
+
def step(messages, initial_input, user_asked: false, thread_id: nil, config: {})
|
|
108
108
|
chat = build_chat
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
if user_asked
|
|
111
|
+
# Subsequent loop iteration — messages already contains the full conversation
|
|
112
|
+
# (including the user's original input from the first step); apply system
|
|
113
|
+
# instructions and replay the accumulated history, then let the LLM continue.
|
|
114
|
+
system_text = build_cached_system_text(initial_input)
|
|
115
|
+
apply_instructions(chat, system_text) if system_text
|
|
116
|
+
messages.each { |m| chat.add_message(m) }
|
|
117
|
+
else
|
|
118
|
+
# First iteration — assemble context (system + history) via build_context so
|
|
119
|
+
# that trimming, compaction, and knowledge sources are applied consistently.
|
|
120
|
+
context = build_context(initial_input, messages: messages, thread_id: thread_id, config: config)
|
|
121
|
+
apply_instructions(chat, context[:system]) if context[:system]
|
|
122
|
+
context[:messages].each { |m| chat.messages << m }
|
|
123
|
+
end
|
|
112
124
|
|
|
113
125
|
# Run before_completion hooks before each LLM call in the ReAct loop.
|
|
114
126
|
run_before_completion_hooks!(chat, config)
|
|
@@ -130,9 +142,18 @@ module Phronomy
|
|
|
130
142
|
|
|
131
143
|
# Streaming variant of #step. Yields :token / :tool_call / :tool_result events
|
|
132
144
|
# via the block while the LLM call is in progress.
|
|
133
|
-
def stream_step(messages, initial_input, user_asked: false, config: {}, &block)
|
|
145
|
+
def stream_step(messages, initial_input, user_asked: false, thread_id: nil, config: {}, &block)
|
|
134
146
|
chat = build_chat
|
|
135
|
-
|
|
147
|
+
|
|
148
|
+
if user_asked
|
|
149
|
+
system_text = build_cached_system_text(initial_input)
|
|
150
|
+
apply_instructions(chat, system_text) if system_text
|
|
151
|
+
messages.each { |m| chat.add_message(m) }
|
|
152
|
+
else
|
|
153
|
+
context = build_context(initial_input, messages: messages, thread_id: thread_id, config: config)
|
|
154
|
+
apply_instructions(chat, context[:system]) if context[:system]
|
|
155
|
+
context[:messages].each { |m| chat.messages << m }
|
|
156
|
+
end
|
|
136
157
|
|
|
137
158
|
current_tool_call = nil
|
|
138
159
|
chat.on_tool_call do |tc|
|
|
@@ -16,8 +16,8 @@ module Phronomy
|
|
|
16
16
|
# - +finalize+ — signals that all tasks have been enqueued
|
|
17
17
|
#
|
|
18
18
|
# Worker persistence is implemented by passing each worker's accumulated
|
|
19
|
-
# +messages+ array back
|
|
20
|
-
# call, so the LLM retains context across multiple task assignments.
|
|
19
|
+
# +messages+ array back as a top-level +messages:+ argument on every subsequent
|
|
20
|
+
# +invoke+ call, so the LLM retains context across multiple task assignments.
|
|
21
21
|
#
|
|
22
22
|
# @example Basic usage
|
|
23
23
|
# class MigrationTeam < Phronomy::Agent::TeamCoordinator
|
|
@@ -38,7 +38,7 @@ module Phronomy
|
|
|
38
38
|
class TeamCoordinator
|
|
39
39
|
# Holds per-worker context between task invocations.
|
|
40
40
|
# Worker persistence is implemented by carrying +messages+ forward on each
|
|
41
|
-
# successive +agent#invoke+ call
|
|
41
|
+
# successive +agent#invoke+ call as the top-level +messages:+ argument..
|
|
42
42
|
WorkerState = Struct.new(
|
|
43
43
|
:index, # Integer — 0-based worker index
|
|
44
44
|
:agent, # Agent::Base instance
|
|
@@ -201,7 +201,7 @@ module Phronomy
|
|
|
201
201
|
worker = scheduler ? scheduler.call(available) : default_scheduler(available)
|
|
202
202
|
|
|
203
203
|
begin
|
|
204
|
-
result = worker.agent.invoke(task[:description],
|
|
204
|
+
result = worker.agent.invoke(task[:description], messages: worker.messages)
|
|
205
205
|
worker.messages = result[:messages]
|
|
206
206
|
worker.status = :available
|
|
207
207
|
entry = {task: task, result: result[:output], worker: worker.index, error: nil}
|
data/lib/phronomy/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phronomy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Raizo T.C.S
|
|
@@ -73,6 +73,10 @@ files:
|
|
|
73
73
|
- lib/phronomy/agent/base.rb
|
|
74
74
|
- lib/phronomy/agent/before_completion_context.rb
|
|
75
75
|
- lib/phronomy/agent/checkpoint.rb
|
|
76
|
+
- lib/phronomy/agent/concerns/before_completion.rb
|
|
77
|
+
- lib/phronomy/agent/concerns/guardrailable.rb
|
|
78
|
+
- lib/phronomy/agent/concerns/retryable.rb
|
|
79
|
+
- lib/phronomy/agent/concerns/suspendable.rb
|
|
76
80
|
- lib/phronomy/agent/handoff.rb
|
|
77
81
|
- lib/phronomy/agent/orchestrator.rb
|
|
78
82
|
- lib/phronomy/agent/react_agent.rb
|