phronomy 0.10.0 → 0.11.1
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 +41 -1
- data/README.md +35 -24
- data/benchmark/bench_agent_invoke.rb +1 -0
- data/lib/phronomy/agent/base.rb +223 -95
- data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
- data/lib/phronomy/agent/concerns/filterable.rb +184 -0
- data/lib/phronomy/agent/concerns/retryable.rb +9 -5
- data/lib/phronomy/agent/context/capability/base.rb +2 -2
- data/lib/phronomy/agent/invocation_context.rb +171 -0
- data/lib/phronomy/agent/invocation_session.rb +337 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
- data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
- data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
- data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
- data/lib/phronomy/engine/fsm_session.rb +248 -0
- data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
- data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
- data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
- data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
- data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
- data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
- data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
- data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
- data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
- data/lib/phronomy/filter/base.rb +56 -0
- data/lib/phronomy/{guardrail/prompt_injection_guardrail.rb → filter/prompt_injection_filter.rb} +20 -13
- data/lib/phronomy/filter.rb +5 -0
- data/lib/phronomy/generator_verifier.rb +21 -13
- data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
- data/lib/phronomy/multi_agent/team_coordinator.rb +1 -2
- data/lib/phronomy/tools/mcp.rb +1 -1
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
- data/lib/phronomy/workflow.rb +6 -3
- data/lib/phronomy/workflow_context.rb +7 -5
- data/lib/phronomy/workflow_runner.rb +77 -27
- data/lib/phronomy.rb +24 -13
- data/scripts/api_snapshot.rb +2 -2
- metadata +42 -44
- data/lib/phronomy/agent/checkpoint.rb +0 -208
- data/lib/phronomy/agent/checkpoint_store.rb +0 -97
- data/lib/phronomy/agent/concerns/guardrailable.rb +0 -45
- data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
- data/lib/phronomy/agent/suspend_signal.rb +0 -37
- data/lib/phronomy/context.rb +0 -12
- data/lib/phronomy/guardrail/base.rb +0 -45
- data/lib/phronomy/guardrail/input_guardrail.rb +0 -19
- data/lib/phronomy/guardrail/output_guardrail.rb +0 -19
- data/lib/phronomy/guardrail.rb +0 -7
- data/lib/phronomy/tool.rb +0 -9
- data/lib/phronomy/workflow/fsm_session.rb +0 -249
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
- /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2a6262c12bc063819c0e1656699f47666b06b182747d6ce85843d2a74988c358
|
|
4
|
+
data.tar.gz: 6529858071913e61a6a840fe03e36620322bfddd50e415b054314124f9b7fcd1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b1441aab8f7197c23bd84fea750388280ee00036e729f47de13b0113249261f72fb10f01b5e9a78fb2464fcb0ebac5fd07d91eef7d4b5e83fb973b463c0630e
|
|
7
|
+
data.tar.gz: d9c0401177c9d59900543c7ff5bbae698401fa0dc5ca7e33272458f41eddff2fa61caa501a5735b273f59b44de96e1fc70b0c5a648bb159066f7facd18b5aa82
|
data/CHANGELOG.md
CHANGED
|
@@ -9,7 +9,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`Phronomy::Filter::Base` — unified value filter interface** (#389):
|
|
15
|
+
A single abstract base class `Filter::Base` with one method `call(value, **context)`
|
|
16
|
+
covers all three agent boundaries — user input, LLM output, and tool return values.
|
|
17
|
+
Subclasses return the (possibly transformed) value to continue, or call `block!` /
|
|
18
|
+
`raise Phronomy::FilterBlockError` to reject. The same filter instance can be
|
|
19
|
+
registered at multiple sites. Guardrails registered via `add_input_guardrail` /
|
|
20
|
+
`add_output_guardrail` are automatically included at the front of the filter chain.
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
class PiiMaskFilter < Phronomy::Filter::Base
|
|
24
|
+
def call(value, **_context)
|
|
25
|
+
value.to_s.gsub(/\b\d{2,4}-\d{2,4}-\d{4}\b/, "[PHONE]")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
f = PiiMaskFilter.new
|
|
30
|
+
agent.add_input_filter(f)
|
|
31
|
+
agent.add_output_filter(f)
|
|
32
|
+
agent.add_tool_result_filter(CustomerDataTool, f)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Class-level DSL counterparts: `input_filter`, `output_filter`, `tool_result_filter`.
|
|
36
|
+
The `tools(Hash)` DSL also accepts a `:result_filter` key for per-tool scoping.
|
|
37
|
+
|
|
38
|
+
- **`Guardrail::Base#call` — Filter::Base-compatible interface**:
|
|
39
|
+
`Guardrail::Base` now implements `call(value, **_context)`, which calls `check(value)`
|
|
40
|
+
and returns the value unchanged (or raises `GuardrailError`). Existing guardrail
|
|
41
|
+
subclasses require no changes.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **Guardrail execution unified into the filter chain**:
|
|
46
|
+
Guardrails registered via `add_input_guardrail` / `add_output_guardrail` now run
|
|
47
|
+
as the first entries in `run_input_filters!` / `run_output_filters!`. The separate
|
|
48
|
+
`run_input_guardrails!` / `run_output_guardrails!` call sites in `invoke_once`,
|
|
49
|
+
`_stream_impl`, and `Suspendable#resume` have been removed. Behaviour is unchanged
|
|
50
|
+
— guardrails still run before any filters and `GuardrailError` still propagates.
|
|
51
|
+
|
|
52
|
+
|
|
13
53
|
|
|
14
54
|
## [0.10.0] - 2026-06-08
|
|
15
55
|
|
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
> We apologise for the instability this may cause.
|
|
8
8
|
|
|
9
9
|
**Phronomy** is a Ruby AI agent framework inspired by open-source AI agent frameworks.
|
|
10
|
-
It provides composable building blocks — Workflows, Agents, Tools,
|
|
10
|
+
It provides composable building blocks — Workflows, Agents, Tools, Filters, and Tracing — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
|
|
11
11
|
|
|
12
12
|
## Features
|
|
13
13
|
|
|
@@ -31,8 +31,8 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
|
|
|
31
31
|
| **Agent** — ReAct-style tool-calling agents with guardrails and conversation history | Stable |
|
|
32
32
|
| **Before-Completion Hook** — Three-tier LLM parameter injection | Stable |
|
|
33
33
|
| **Context Management** — Token budget calculation, estimation, and pruning; `Agent::Base` protected hooks: `build_context` (overridable), `trim_messages`, `trim_to_budget`, `compact_messages`, `budget_exceeded?`, `drop_messages_over` | Stable |
|
|
34
|
-
| **
|
|
35
|
-
| **`
|
|
34
|
+
| **Filters** — Input/output transformation and blocking via `Filter::Base`; call `block!(reason)` to reject and raise `FilterBlockError` | Beta |
|
|
35
|
+
| **`PromptInjectionFilter`** — Built-in `Filter::Base` subclass that detects prompt-injection patterns; usable standalone or as part of a filter chain | Beta |
|
|
36
36
|
| **`Agent::Context::Capability::Base.redact_params` / `.max_result_size`** — Class-level DSL: `redact_params` masks parameter values in log/trace output; `max_result_size` truncates oversized tool results before they reach the LLM | Beta |
|
|
37
37
|
| **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
|
|
38
38
|
| **Eval Framework** — Dataset-driven evaluation with multiple scorer types | Beta |
|
|
@@ -78,6 +78,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
|
|
|
78
78
|
| **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
|
|
79
79
|
| **`ScopePolicy`** — Configurable policy callable that maps (tool, scope, agent) to `:allow`/`:approve`/`:reject`; default policy auto-routes high-risk scopes through the approval gate | Experimental |
|
|
80
80
|
| **HITL Checkpoint/Resume** — `Agent::Base#invoke` returns `{ suspended: true, checkpoint: Checkpoint }` when an approval-required tool is encountered without a synchronous handler; `Agent::Base#resume(checkpoint, approved:)` resumes execution; `Agent::Base.resume(checkpoint, approved:)` (class-level) resolves the agent class automatically; `Checkpoint#to_h` / `Checkpoint.from_h` for serialization; `Agent::Base#checkpoint_store=` for custom idempotency backends; `CheckpointAlreadyResumedError` raised on duplicate resume | Experimental |
|
|
81
|
+
| **`Filter::Base` — unified value filter interface** — `Phronomy::Filter::Base` with a single abstract method `call(value, **context)`; apply to user input (`add_input_filter` / `input_filter` DSL), final LLM output (`add_output_filter` / `output_filter` DSL), or individual tool return values (`add_tool_result_filter(tool_class?, filter)` / `tool_result_filter` DSL); filters transform values and return the result, or raise `Phronomy::FilterBlockError` to reject; filter chains are composable; the same filter instance can be reused across all three sites | Beta |
|
|
81
82
|
|
|
82
83
|
> **Public API boundary**: The tables above are the complete list of classes, modules, and features
|
|
83
84
|
> intended for gem consumers. Every entry has an associated stability label.
|
|
@@ -252,30 +253,31 @@ result = OrchestratorAgent.new.invoke("Write a blog post about Ruby 3.4 features
|
|
|
252
253
|
puts result[:output]
|
|
253
254
|
```
|
|
254
255
|
|
|
255
|
-
###
|
|
256
|
+
### Filters — Input/output transformation and blocking
|
|
256
257
|
|
|
257
|
-
|
|
258
|
-
|
|
258
|
+
Filters sit between user input and the LLM (input filters) or between the LLM response and the caller (output filters).
|
|
259
|
+
A filter may **transform** the value (return the modified value) or **block** it (call `block!(reason)`, which raises `Phronomy::FilterBlockError`).
|
|
259
260
|
|
|
260
261
|
```ruby
|
|
261
|
-
class
|
|
262
|
-
def
|
|
263
|
-
|
|
262
|
+
class NoCreditCardFilter < Phronomy::Filter::Base
|
|
263
|
+
def call(value, **_context)
|
|
264
|
+
block!("Credit card numbers are not allowed") if value.match?(/\d{4}-\d{4}-\d{4}-\d{4}/)
|
|
265
|
+
value
|
|
264
266
|
end
|
|
265
267
|
end
|
|
266
268
|
|
|
267
269
|
agent = ResearchAgent.new
|
|
268
|
-
agent.
|
|
270
|
+
agent.add_input_filter(NoCreditCardFilter.new)
|
|
269
271
|
|
|
270
272
|
begin
|
|
271
273
|
agent.invoke("Charge 4111-1111-1111-1111")
|
|
272
|
-
rescue Phronomy::
|
|
274
|
+
rescue Phronomy::FilterBlockError => e
|
|
273
275
|
puts e.message # => "Credit card numbers are not allowed"
|
|
274
276
|
end
|
|
275
277
|
```
|
|
276
278
|
|
|
277
|
-
> **Note:** Phronomy includes `
|
|
278
|
-
> input
|
|
279
|
+
> **Note:** Phronomy includes `PromptInjectionFilter`, a built-in pattern-based
|
|
280
|
+
> input filter that detects common injection patterns (see the feature table above).
|
|
279
281
|
> PII scanning and content classification are **not** provided by the framework;
|
|
280
282
|
> that logic must be implemented by the application. Reference implementations for
|
|
281
283
|
> common patterns are available in `phronomy-examples` (example 06).
|
|
@@ -601,14 +603,16 @@ Understanding when to use each prevents scheduler stalls and hidden deadlocks.
|
|
|
601
603
|
| Context | Recommended API |
|
|
602
604
|
|---------|----------------|
|
|
603
605
|
| Top-level application code, Rails controller, background job | `agent.invoke(input)` — blocks the calling thread until done |
|
|
604
|
-
|
|
|
606
|
+
| Workflow action / EventLoop callback | `agent.invoke_async(input).map { |r| ctx.merge(output: r[:output]) }` — returns a Task and resumes by state transition |
|
|
607
|
+
| Top-level code that wants explicit async | `agent.invoke_async(input).wait_result` — blocks the calling thread until the Task completes |
|
|
605
608
|
|
|
606
609
|
### Why this matters
|
|
607
610
|
|
|
608
611
|
`invoke` is a synchronous wrapper that calls `invoke_async` and then _blocks_ the calling
|
|
609
|
-
thread until the task completes.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
+
thread until the task completes. It is intended for top-level application threads such as
|
|
613
|
+
Rails controller actions, CLI scripts, or background jobs. Inside EventLoop-driven
|
|
614
|
+
workflow actions, return a Task and let `Task#map` / `Task#on_complete` drive the next
|
|
615
|
+
state transition instead of waiting inside the EventLoop thread.
|
|
612
616
|
|
|
613
617
|
### Runtime guard
|
|
614
618
|
|
|
@@ -628,14 +632,21 @@ You can also query the current context directly:
|
|
|
628
632
|
Phronomy::Runtime.in_scheduler_context? # => true if called from inside a task
|
|
629
633
|
```
|
|
630
634
|
|
|
631
|
-
### Migration:
|
|
635
|
+
### Migration: blocking wait → Task mapping
|
|
632
636
|
|
|
633
637
|
```ruby
|
|
634
|
-
#
|
|
638
|
+
# Top-level synchronous use
|
|
635
639
|
result = my_agent.invoke("Hello")
|
|
636
640
|
|
|
637
|
-
#
|
|
638
|
-
result = my_agent.invoke_async("Hello").
|
|
641
|
+
# Explicit async from top-level code
|
|
642
|
+
result = my_agent.invoke_async("Hello").wait_result
|
|
643
|
+
|
|
644
|
+
# Workflow action / EventLoop-safe use
|
|
645
|
+
NODE = ->(ctx) {
|
|
646
|
+
my_agent.invoke_async("Hello").map { |result|
|
|
647
|
+
ctx.merge(answer: result[:output])
|
|
648
|
+
}
|
|
649
|
+
}
|
|
639
650
|
```
|
|
640
651
|
|
|
641
652
|
### :immediate backend (synchronous / test mode)
|
|
@@ -851,11 +862,11 @@ span attributes by default (`trace_pii: false`). To include full content in trac
|
|
|
851
862
|
Phronomy configuration. Evaluate whether your tracing backend (OTLP collector, Jaeger,
|
|
852
863
|
Honeycomb, etc.) meets your data-retention and privacy requirements.
|
|
853
864
|
|
|
854
|
-
**Prompt injection** — Phronomy provides `
|
|
855
|
-
pattern-based input
|
|
865
|
+
**Prompt injection** — Phronomy provides `PromptInjectionFilter`, a built-in
|
|
866
|
+
pattern-based input filter that detects common injection patterns (ignore/override
|
|
856
867
|
instructions, role-switching phrases, etc.). It is a useful starting point, not a
|
|
857
868
|
comprehensive defence; applications processing untrusted input should layer additional
|
|
858
|
-
custom
|
|
869
|
+
custom filters as needed (see the Filters section above).
|
|
859
870
|
|
|
860
871
|
**Tool and MCP security** — Tools can perform real-world side effects (database
|
|
861
872
|
writes, API calls, file deletion). Treat tool execution as a privileged operation:
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
-
require_relative "checkpoint_store"
|
|
5
4
|
require_relative "concerns/retryable"
|
|
6
|
-
require_relative "concerns/
|
|
5
|
+
require_relative "concerns/filterable"
|
|
7
6
|
require_relative "concerns/before_completion"
|
|
8
|
-
require_relative "concerns/suspendable"
|
|
9
7
|
require_relative "concerns/error_translation"
|
|
10
8
|
|
|
11
9
|
module Phronomy
|
|
@@ -34,9 +32,8 @@ module Phronomy
|
|
|
34
32
|
class Base
|
|
35
33
|
include Phronomy::Runnable
|
|
36
34
|
include Concerns::Retryable
|
|
37
|
-
include Concerns::
|
|
35
|
+
include Concerns::Filterable
|
|
38
36
|
include Concerns::BeforeCompletion
|
|
39
|
-
include Concerns::Suspendable
|
|
40
37
|
include Concerns::ErrorTranslation
|
|
41
38
|
|
|
42
39
|
class << self
|
|
@@ -376,25 +373,21 @@ module Phronomy
|
|
|
376
373
|
end
|
|
377
374
|
end
|
|
378
375
|
|
|
379
|
-
#
|
|
380
|
-
# requiring the original agent instance to be kept in memory.
|
|
376
|
+
# Continues a suspended invocation identified by +session_id+.
|
|
381
377
|
#
|
|
382
|
-
#
|
|
383
|
-
#
|
|
378
|
+
# Instantiates a fresh agent and delegates to the instance-level #approve.
|
|
379
|
+
# When +approved: false+, the agent rejects the pending tool call and ends
|
|
380
|
+
# the invocation.
|
|
384
381
|
#
|
|
385
|
-
# @param
|
|
382
|
+
# @param session_id [String] the session_id from the suspended result hash
|
|
386
383
|
# @param approved [Boolean] +true+ to execute the pending tool; +false+ to deny
|
|
387
384
|
# @param config [Hash] same runtime options as {#invoke}
|
|
388
385
|
# @return [Hash] same shape as {#invoke} — may contain +suspended: true+ if
|
|
389
386
|
# another approval-required tool is encountered during continuation
|
|
390
|
-
# @raise [ArgumentError] when
|
|
387
|
+
# @raise [ArgumentError] when no suspended session matches +session_id+
|
|
391
388
|
# @api public
|
|
392
|
-
def
|
|
393
|
-
|
|
394
|
-
raise ArgumentError,
|
|
395
|
-
"checkpoint belongs to #{checkpoint.agent_class}, cannot resume with #{name}"
|
|
396
|
-
end
|
|
397
|
-
new.resume(checkpoint, approved: approved, config: config)
|
|
389
|
+
def approve(session_id, approved: true, config: {})
|
|
390
|
+
new.approve(session_id, approved: approved, config: config)
|
|
398
391
|
end
|
|
399
392
|
end
|
|
400
393
|
|
|
@@ -416,9 +409,38 @@ module Phronomy
|
|
|
416
409
|
@_handoff_tools || []
|
|
417
410
|
end
|
|
418
411
|
|
|
412
|
+
# Registers a synchronous approval callback that is invoked before
|
|
413
|
+
# executing any tool that has +requires_approval true+ set.
|
|
414
|
+
# The block receives the tool name (String) and the arguments Hash, and
|
|
415
|
+
# must return a truthy value to allow execution.
|
|
416
|
+
# Returning a falsy value causes the tool to return a denial message.
|
|
417
|
+
#
|
|
418
|
+
# When no handler is registered and a tool with +requires_approval+ is
|
|
419
|
+
# called, #invoke returns a suspended result hash containing a
|
|
420
|
+
# +session_id+. Call #approve to continue execution.
|
|
421
|
+
#
|
|
422
|
+
# @example
|
|
423
|
+
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
424
|
+
# @return [self]
|
|
425
|
+
# @api public
|
|
426
|
+
def on_approval_required(&block)
|
|
427
|
+
@approval_handler = block
|
|
428
|
+
self
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Registers a scope policy callable for this agent instance.
|
|
432
|
+
#
|
|
433
|
+
# The callable receives +(tool_class, scope, agent)+ and must return
|
|
434
|
+
# +:allow+, +:reject+, or +:approve+.
|
|
435
|
+
#
|
|
436
|
+
# @param policy [#call]
|
|
437
|
+
# @return [void]
|
|
438
|
+
# @api public
|
|
439
|
+
attr_writer :scope_policy
|
|
440
|
+
|
|
419
441
|
# Invokes the agent with the given input and returns a result Hash.
|
|
420
442
|
# Applies the retry policy configured via {.retry_policy} when transient
|
|
421
|
-
# errors occur. {Phronomy::
|
|
443
|
+
# errors occur. {Phronomy::FilterBlockError} is never retried.
|
|
422
444
|
#
|
|
423
445
|
# @param input [String, Hash] the user message; a Hash may supply
|
|
424
446
|
# +:message+, +:query+, or +:user+ as the text key, plus any template
|
|
@@ -439,7 +461,7 @@ module Phronomy
|
|
|
439
461
|
# @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
|
|
440
462
|
# or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
|
|
441
463
|
# messages: Array }+ when the invocation was suspended awaiting tool approval.
|
|
442
|
-
# @raise [Phronomy::
|
|
464
|
+
# @raise [Phronomy::FilterBlockError] when an input or output filter rejects the value
|
|
443
465
|
# @example Normal invocation
|
|
444
466
|
# result = MyAgent.new.invoke("What is Ruby?")
|
|
445
467
|
# puts result[:output]
|
|
@@ -468,7 +490,10 @@ module Phronomy
|
|
|
468
490
|
|
|
469
491
|
timeout_sec = self.class.invoke_timeout
|
|
470
492
|
unless timeout_sec
|
|
471
|
-
return
|
|
493
|
+
return trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
|
|
494
|
+
result = invoke_async(input, messages: messages, thread_id: thread_id, config: config).wait_result
|
|
495
|
+
[result, result[:usage]]
|
|
496
|
+
end
|
|
472
497
|
end
|
|
473
498
|
|
|
474
499
|
# invoke_timeout: create a CancellationScope with deadline, pass its token
|
|
@@ -482,7 +507,7 @@ module Phronomy
|
|
|
482
507
|
# Bridge the task result to an AsyncQueue so scope.pop_queue can observe the deadline.
|
|
483
508
|
completion_queue = Phronomy::Concurrency::AsyncQueue.new
|
|
484
509
|
Phronomy::Runtime.instance.spawn(name: "invoke-timeout-bridge:#{(self.class.name || "agent").downcase}") do
|
|
485
|
-
completion_queue.push(task.
|
|
510
|
+
completion_queue.push(task.wait_result)
|
|
486
511
|
rescue => e
|
|
487
512
|
completion_queue.push(e)
|
|
488
513
|
end
|
|
@@ -508,7 +533,7 @@ module Phronomy
|
|
|
508
533
|
#
|
|
509
534
|
# @example
|
|
510
535
|
# task = agent.invoke_async("Hello!")
|
|
511
|
-
# result = task.
|
|
536
|
+
# result = task.wait_result # => { output: "...", messages: [...], usage: ... }
|
|
512
537
|
#
|
|
513
538
|
# @param input [String, Hash]
|
|
514
539
|
# @param messages [Array]
|
|
@@ -524,11 +549,11 @@ module Phronomy
|
|
|
524
549
|
bp = Phronomy.configuration.backpressure
|
|
525
550
|
on_full = (bp == :raise) ? :reject : (bp || :wait)
|
|
526
551
|
bp_timeout = Phronomy.configuration.backpressure_timeout
|
|
552
|
+
result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
|
|
527
553
|
gate = Phronomy::Runtime.instance.gate(:agent)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
end
|
|
554
|
+
gate.acquire(on_full: on_full, timeout: bp_timeout) do
|
|
555
|
+
_start_invoke_attempt(result_task, input, messages: messages, thread_id: thread_id, config: config, attempt: 0)
|
|
556
|
+
return result_task
|
|
532
557
|
end
|
|
533
558
|
end
|
|
534
559
|
|
|
@@ -603,7 +628,7 @@ module Phronomy
|
|
|
603
628
|
# Streaming implementation for #stream.
|
|
604
629
|
def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
|
|
605
630
|
trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
|
|
606
|
-
|
|
631
|
+
input = run_input_filters!(input)
|
|
607
632
|
|
|
608
633
|
chat = build_chat
|
|
609
634
|
user_message = extract_message(input)
|
|
@@ -634,7 +659,7 @@ module Phronomy
|
|
|
634
659
|
run_before_completion_hooks!(chat, config)
|
|
635
660
|
|
|
636
661
|
output, usage = _drain_stream(chat, user_message, config, &block)
|
|
637
|
-
|
|
662
|
+
output = run_output_filters!(output)
|
|
638
663
|
|
|
639
664
|
result = {output: output, messages: chat.messages, usage: usage}
|
|
640
665
|
block.call(StreamEvent.new(type: :done, payload: result))
|
|
@@ -809,34 +834,154 @@ module Phronomy
|
|
|
809
834
|
end
|
|
810
835
|
protected :instance_knowledge_chunks
|
|
811
836
|
|
|
812
|
-
#
|
|
813
|
-
#
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
837
|
+
# Runs the agent invocation through the FSM-based execution engine.
|
|
838
|
+
# Called by Retryable#_invoke_impl (which wraps it in a retry loop).
|
|
839
|
+
# Returns the result hash: { output:, messages:, usage: } on success,
|
|
840
|
+
# or { suspended: true, session_id:, messages: } when awaiting approval.
|
|
841
|
+
# @api private
|
|
842
|
+
def _invoke_via_fsm(input, messages: [], thread_id: nil, config: {})
|
|
843
|
+
effective_config = thread_id ? config.merge(thread_id: thread_id) : config
|
|
844
|
+
# Fail fast when the token is already cancelled before any LLM call.
|
|
845
|
+
check_cancellation!(effective_config, "invocation cancelled")
|
|
846
|
+
# Ensure EventLoop is running. start is idempotent when already alive.
|
|
847
|
+
Phronomy::EventLoop.instance.start
|
|
848
|
+
trace("agent.invoke", input: input, **_build_caller_meta(effective_config)) do |_span|
|
|
849
|
+
session = Agent::InvocationSession.build(
|
|
850
|
+
agent: self,
|
|
851
|
+
input: input,
|
|
852
|
+
messages: messages,
|
|
853
|
+
config: effective_config
|
|
825
854
|
)
|
|
826
|
-
|
|
855
|
+
completion_queue = Phronomy::EventLoop.instance.register(session)
|
|
856
|
+
ctx = completion_queue.pop
|
|
857
|
+
raise ctx if ctx.is_a?(Exception)
|
|
858
|
+
result = _extract_invoke_result(ctx, session.id)
|
|
859
|
+
[result, result[:usage]]
|
|
860
|
+
end
|
|
861
|
+
end
|
|
827
862
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
863
|
+
# Starts a single invocation attempt and wires retry/translation onto result_task.
|
|
864
|
+
# Non-blocking: registers with EventLoop and returns immediately.
|
|
865
|
+
# On error, retries via timer_queue when policy allows; otherwise translates
|
|
866
|
+
# and resolves result_task as failed.
|
|
867
|
+
# @api private
|
|
868
|
+
def _start_invoke_attempt(result_task, input, messages:, thread_id:, config:, attempt:)
|
|
869
|
+
effective_config = thread_id ? config.merge(thread_id: thread_id) : config
|
|
870
|
+
check_cancellation!(effective_config, "invocation cancelled")
|
|
871
|
+
Phronomy::EventLoop.instance.start
|
|
872
|
+
session = Agent::InvocationSession.build(
|
|
873
|
+
agent: self,
|
|
874
|
+
input: input,
|
|
875
|
+
messages: messages,
|
|
876
|
+
config: effective_config
|
|
877
|
+
)
|
|
878
|
+
source_task = Phronomy::Task.deferred(name: "#{result_task.name}-attempt-#{attempt}")
|
|
879
|
+
Phronomy::EventLoop.instance.register(session, completion: source_task)
|
|
880
|
+
session_id = session.id
|
|
881
|
+
policy = self.class._retry_policy
|
|
882
|
+
|
|
883
|
+
source_task.on_complete do |ctx, error|
|
|
884
|
+
retriable = error &&
|
|
885
|
+
!error.is_a?(Phronomy::FilterBlockError) &&
|
|
886
|
+
!error.is_a?(Phronomy::CancellationError) &&
|
|
887
|
+
policy && attempt < policy[:times]
|
|
888
|
+
|
|
889
|
+
if retriable
|
|
890
|
+
wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
|
|
891
|
+
# Call _sleep_proc for instrumentation (test spy records the duration;
|
|
892
|
+
# in production this is a no-op since timer_queue handles the actual delay).
|
|
893
|
+
self.class._sleep_proc.call(wait) if wait > 0
|
|
894
|
+
do_retry = -> {
|
|
895
|
+
_start_invoke_attempt(
|
|
896
|
+
result_task, input,
|
|
897
|
+
messages: messages, thread_id: thread_id, config: config,
|
|
898
|
+
attempt: attempt + 1
|
|
899
|
+
)
|
|
900
|
+
}
|
|
901
|
+
if wait > 0
|
|
902
|
+
Phronomy::Runtime.instance.timer_queue.schedule(seconds: wait, &do_retry)
|
|
903
|
+
else
|
|
904
|
+
do_retry.call
|
|
905
|
+
end
|
|
906
|
+
elsif error
|
|
907
|
+
begin
|
|
908
|
+
translate_and_reraise!(error)
|
|
909
|
+
rescue => translated
|
|
910
|
+
result_task.backend.unblock(nil, translated)
|
|
911
|
+
result_task.transition!(:failed, error: translated)
|
|
912
|
+
end
|
|
913
|
+
else
|
|
914
|
+
begin
|
|
915
|
+
result = _extract_invoke_result(ctx, session_id)
|
|
916
|
+
result_task.backend.unblock(result, nil)
|
|
917
|
+
result_task.transition!(:completed, value: result)
|
|
918
|
+
rescue => e
|
|
919
|
+
result_task.backend.unblock(nil, e)
|
|
920
|
+
result_task.transition!(:failed, error: e)
|
|
921
|
+
end
|
|
922
|
+
end
|
|
923
|
+
end
|
|
924
|
+
rescue => e
|
|
925
|
+
result_task.backend.unblock(nil, e)
|
|
926
|
+
result_task.transition!(:failed, error: e)
|
|
927
|
+
end
|
|
831
928
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
929
|
+
# Continues a suspended invocation identified by +session_id+.
|
|
930
|
+
# When +approved: true+, executes the pending tool and continues.
|
|
931
|
+
# When +approved: false+, rejects the tool call and ends the invocation.
|
|
932
|
+
#
|
|
933
|
+
# @param session_id [String]
|
|
934
|
+
# @param approved [Boolean]
|
|
935
|
+
# @param config [Hash]
|
|
936
|
+
# @return [Hash]
|
|
937
|
+
# @api public
|
|
938
|
+
def approve(session_id, approved: true, config: {})
|
|
939
|
+
ctx = Agent::SuspendedSessionRegistry.fetch(session_id)
|
|
940
|
+
raise ArgumentError, "No suspended session found: #{session_id}" unless ctx
|
|
941
|
+
|
|
942
|
+
# Reset approval_required so executing_tool_action proceeds instead of
|
|
943
|
+
# re-suspending when called after the :approve FSM transition.
|
|
944
|
+
ctx.approval_required = false
|
|
945
|
+
ctx.approved = true if approved # signals executing_tool to run the tool
|
|
946
|
+
ctx.rejected = !approved # signals _extract_invoke_result for rejection
|
|
947
|
+
|
|
948
|
+
if approved
|
|
949
|
+
_resume_fsm(ctx, :approve)
|
|
950
|
+
else
|
|
951
|
+
_resume_fsm(ctx, :reject)
|
|
952
|
+
end
|
|
953
|
+
end
|
|
954
|
+
public :approve
|
|
837
955
|
|
|
838
|
-
|
|
839
|
-
|
|
956
|
+
# Builds and runs a resume FSMSession for the given context and event.
|
|
957
|
+
# @api private
|
|
958
|
+
def _resume_fsm(ctx, event)
|
|
959
|
+
session = Agent::InvocationSession.build_for_resume(
|
|
960
|
+
agent: self,
|
|
961
|
+
context: ctx,
|
|
962
|
+
resume_event: event,
|
|
963
|
+
resume_phase: :awaiting_approval
|
|
964
|
+
)
|
|
965
|
+
completion_queue = Phronomy::EventLoop.instance.register(session)
|
|
966
|
+
resumed_ctx = completion_queue.pop
|
|
967
|
+
raise resumed_ctx if resumed_ctx.is_a?(Exception)
|
|
968
|
+
_extract_invoke_result(resumed_ctx, session.id)
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
# Interprets the InvocationContext after FSM completion/halt and returns
|
|
972
|
+
# the appropriate result hash or raises the block error.
|
|
973
|
+
# @api private
|
|
974
|
+
def _extract_invoke_result(ctx, session_id)
|
|
975
|
+
if ctx.phase == :awaiting_approval
|
|
976
|
+
Agent::SuspendedSessionRegistry.store(session_id, ctx)
|
|
977
|
+
{suspended: true, session_id: session_id, messages: ctx.messages}
|
|
978
|
+
elsif ctx.input_blocked? || ctx.output_blocked?
|
|
979
|
+
raise ctx.block_error
|
|
980
|
+
elsif ctx.rejected
|
|
981
|
+
# Rejected path: :reject event → :blocked terminal
|
|
982
|
+
{rejected: true, messages: ctx.messages}
|
|
983
|
+
else
|
|
984
|
+
{output: ctx.output, messages: ctx.messages, usage: ctx.usage}
|
|
840
985
|
end
|
|
841
986
|
end
|
|
842
987
|
|
|
@@ -857,36 +1002,6 @@ module Phronomy
|
|
|
857
1002
|
context[:messages].each { |msg| chat.messages << msg }
|
|
858
1003
|
end
|
|
859
1004
|
|
|
860
|
-
# Submits the LLM call via LLMAdapter and handles SuspendSignal.
|
|
861
|
-
# Sets/clears the chat cancellation token around the call so that
|
|
862
|
-
# ParallelToolChat can observe cancellation without Thread.current.
|
|
863
|
-
# Returns [result_hash, usage_or_nil].
|
|
864
|
-
def _complete_with_suspension_guard(chat, user_message, config, thread_id:, original_input:)
|
|
865
|
-
chat.cancellation_token = config[:cancellation_token] if chat.respond_to?(:cancellation_token=)
|
|
866
|
-
begin
|
|
867
|
-
adapter = Phronomy.configuration.llm_adapter
|
|
868
|
-
response = adapter.complete_async(chat, user_message, config: config).await
|
|
869
|
-
rescue SuspendSignal => signal
|
|
870
|
-
checkpoint = Checkpoint.new(
|
|
871
|
-
checkpoint_id: SecureRandom.uuid,
|
|
872
|
-
agent_class: self.class.name,
|
|
873
|
-
requested_at: Time.now.utc,
|
|
874
|
-
thread_id: thread_id,
|
|
875
|
-
original_input: original_input,
|
|
876
|
-
messages: chat.messages.dup,
|
|
877
|
-
pending_tool_name: signal.tool_name,
|
|
878
|
-
pending_tool_args: signal.args,
|
|
879
|
-
pending_tool_call_id: signal.tool_call_id
|
|
880
|
-
)
|
|
881
|
-
return [{output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}, nil]
|
|
882
|
-
ensure
|
|
883
|
-
chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
|
|
884
|
-
end
|
|
885
|
-
output = response.content
|
|
886
|
-
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
887
|
-
[{output: output, messages: chat.messages, usage: usage}, usage]
|
|
888
|
-
end
|
|
889
|
-
|
|
890
1005
|
def _drain_stream(chat, user_message, config, &block)
|
|
891
1006
|
adapter = Phronomy.configuration.llm_adapter
|
|
892
1007
|
chunk_queue = Phronomy::Concurrency::AsyncQueue.new(max_size: Phronomy.configuration.stream_queue_max_size)
|
|
@@ -899,7 +1014,7 @@ module Phronomy
|
|
|
899
1014
|
check_cancellation!(config, "invocation cancelled during streaming")
|
|
900
1015
|
end
|
|
901
1016
|
|
|
902
|
-
response = pending.
|
|
1017
|
+
response = pending.blocking_wait
|
|
903
1018
|
[response.content, Phronomy::TokenUsage.from_tokens(response.tokens)]
|
|
904
1019
|
end
|
|
905
1020
|
|
|
@@ -1076,21 +1191,34 @@ module Phronomy
|
|
|
1076
1191
|
end
|
|
1077
1192
|
|
|
1078
1193
|
# Step 3: wrap with approval gate when handler is registered.
|
|
1079
|
-
|
|
1194
|
+
if resolved.requires_approval && @approval_handler
|
|
1195
|
+
handler = @approval_handler
|
|
1196
|
+
# Capture the effective tool name before building the anonymous subclass.
|
|
1197
|
+
# Class-level instance variables (@tool_name) are not inherited through
|
|
1198
|
+
# subclassing, so the wrapper must set it explicitly.
|
|
1199
|
+
effective_name = resolved.new.name
|
|
1200
|
+
resolved = Class.new(resolved) do
|
|
1201
|
+
tool_name effective_name
|
|
1202
|
+
define_method(:call) do |args|
|
|
1203
|
+
if handler.call(name, args)
|
|
1204
|
+
super(args)
|
|
1205
|
+
else
|
|
1206
|
+
"Tool execution denied."
|
|
1207
|
+
end
|
|
1208
|
+
end
|
|
1209
|
+
end
|
|
1210
|
+
end
|
|
1080
1211
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1212
|
+
# Step 4: wrap with tool result filters when registered.
|
|
1213
|
+
result_filters = _tool_result_filters_for(tool_class)
|
|
1214
|
+
return resolved if result_filters.empty?
|
|
1215
|
+
|
|
1216
|
+
effective_name4 = resolved.new.name
|
|
1086
1217
|
Class.new(resolved) do
|
|
1087
|
-
tool_name
|
|
1218
|
+
tool_name effective_name4
|
|
1088
1219
|
define_method(:call) do |args|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
else
|
|
1092
|
-
"Tool execution denied."
|
|
1093
|
-
end
|
|
1220
|
+
result = super(args)
|
|
1221
|
+
result_filters.inject(result) { |val, f| f.call(val, tool_name: name, args: args) }
|
|
1094
1222
|
end
|
|
1095
1223
|
end
|
|
1096
1224
|
end
|