phronomy 0.17.0 → 0.18.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 +97 -1134
- data/README.md +68 -1280
- data/benchmark/bench_regression.rb +25 -5
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
- data/docs/features.md +87 -0
- data/docs/getting-started.md +351 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/runtime-and-concurrency.md +258 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +35 -19
- data/lib/phronomy/agent/context/capability/base.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +6 -6
- data/lib/phronomy/agent/shared_state.rb +2 -0
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +97 -47
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/configuration.rb +5 -29
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +89 -190
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +45 -158
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
- data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +2 -4
- data/lib/phronomy.rb +7 -4
- data/scripts/api_snapshot.rb +4 -4
- metadata +23 -31
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -193
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
data/README.md
CHANGED
|
@@ -6,104 +6,28 @@
|
|
|
6
6
|
> External contributors should expect significant churn and potential conflicts at any time.
|
|
7
7
|
> We apologise for the instability this may cause.
|
|
8
8
|
|
|
9
|
-
**Phronomy** is a Ruby AI agent framework
|
|
10
|
-
|
|
9
|
+
**Phronomy** is a Ruby AI agent framework for stateful Agents, Workflows, Tools,
|
|
10
|
+
context management, filtering, tracing, and multi-agent coordination. Large Language
|
|
11
|
+
Model (LLM) access is provided through [RubyLLM](https://github.com/crmne/ruby_llm).
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
Phronomy is pre-1.0. Pin to a released gem version for production use rather than
|
|
14
|
+
tracking `main` directly.
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
> breaking changes even to `Stable` APIs; patch releases (`0.x.y`) are non-breaking):
|
|
16
|
-
> - `Stable` — API is considered complete and suitable for production use. Breaking changes
|
|
17
|
-
> within a minor release are avoided, and any breaking changes in a minor bump are noted
|
|
18
|
-
> in `CHANGELOG.md`.
|
|
19
|
-
> - `Beta` — Functionality is complete and tested, but the API may change in a minor version release (0.x). Use with awareness that signatures or behaviour may evolve.
|
|
20
|
-
> - `Experimental` — Functionality may be incomplete or subject to breaking changes at any time without notice. Not recommended for production use.
|
|
21
|
-
>
|
|
22
|
-
> **Note**: The `main` branch contains unreleased development work. Pin to a released gem
|
|
23
|
-
> version (`gem "phronomy", "~> 0.x"`) for stability in production.
|
|
16
|
+
## Core concepts
|
|
24
17
|
|
|
25
|
-
**
|
|
18
|
+
- **Agent** — stateful, persistence-backed LLM agent with canonical execution history.
|
|
19
|
+
- **Workflow** — state-machine-driven application workflow with explicit events and wait states.
|
|
20
|
+
- **Tool / Capability** — callable application capability exposed to an Agent.
|
|
21
|
+
- **EventLoop + FSMSession** — the framework control plane for logical lifecycle coordination.
|
|
22
|
+
- **OffloadPool** — bounded operating-system-thread execution boundary for synchronous work that must not run on EventLoop.
|
|
23
|
+
- **Task** — thread-free completion handle for asynchronous Phronomy lifecycles.
|
|
24
|
+
- **Journal / Context Policy / Manifest** — canonical history plus per-LLM-call context selection.
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|---|---|
|
|
29
|
-
| **Workflow** — Stateful, branching workflows with wait_state/send_event | Stable |
|
|
30
|
-
| **Agent** — Stateful ReAct-style tool-calling agents with stable `agent_id`, persistence-backed execution state, canonical execution history, guardrails, and conversation context | Stable |
|
|
31
|
-
| **Before-LLM-Input Hook** — Three-tier per-call LLM input customization via `before_llm_input` and `LLMInputPatch` | Stable |
|
|
32
|
-
| **Context Management** — Canonical Journal + per-LLM-call Manifest architecture with token-budget-aware selection and protocol-safe Tool Call / Tool message dependencies. Context selection never deletes canonical execution history | Stable |
|
|
33
|
-
| **Filters** — Input/output transformation and blocking via `Filter::Base`; call `block!(reason)` to reject and raise `FilterBlockError` | Beta |
|
|
34
|
-
| **`PromptInjectionFilter`** — Built-in `Filter::Base` subclass that detects prompt-injection patterns; usable standalone or as part of a filter chain | Beta |
|
|
35
|
-
| **`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 |
|
|
36
|
-
| **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
|
|
37
|
-
| **Eval Framework** — Dataset-driven evaluation with multiple scorer types | Beta |
|
|
38
|
-
| **Tracing** — Pluggable span-based observability | Stable |
|
|
39
|
-
| **Error Taxonomy** — final RubyLLM/provider errors are translated to `RateLimitError`, `AuthenticationError`, `ContextLengthError`, and `TransportError` without replaying the Agent invocation | Beta |
|
|
40
|
-
|
|
41
|
-
**Knowledge and integration**
|
|
42
|
-
|
|
43
|
-
| Feature | Stability |
|
|
44
|
-
|---|---|
|
|
45
|
-
| **Knowledge** — Journal-backed persistent Agent context registered with `knowledge:` / `add_knowledge`; selected per LLM call by Context Policy; `clear_knowledge!` logically resets retained Knowledge without deleting Journal history | Beta |
|
|
46
|
-
| **`VectorStore#size`** — Returns document count for all three backends (InMemory, RedisSearch, Pgvector) | Beta |
|
|
47
|
-
| **`VectorStore::AsyncBackend` mixin** — Pluggable async interface for `VectorStore`; default pool-backed implementations for `search_async`, `add_async`, `remove_async`, `clear_async`; backends with native async drivers override individual methods to bypass `BlockingAdapterPool` entirely; all existing backends remain unchanged | Beta |
|
|
48
|
-
| **MCP Tool** — `Phronomy::Tools::Mcp`: Model Context Protocol server integration via the official `mcp` gem; `Phronomy::Tools::Agent`: wraps an agent class as a callable tool via `from_agent` | Beta |
|
|
49
|
-
| **Vector Search Tool** — `Phronomy::Tools::VectorSearch`: wraps a `VectorStore` and `Embeddings` adapter as a callable agent tool via `from_store` | Beta |
|
|
50
|
-
|
|
51
|
-
| **Execution and reliability** | |
|
|
52
|
-
|---|---|
|
|
53
|
-
| **EventLoop** — Runtime-owned event-driven execution core shared by all Agent invocations, Tool invocations, and Workflow sessions. Not configurable; EventLoop is always active when the Runtime is running. `Phronomy::EventLoop` itself is an internal API | Beta |
|
|
54
|
-
| **`invoke` / `invoke_async`** — `Agent::Base#invoke` blocks the calling thread and returns the final result Hash; `Agent::Base#invoke_async` returns a `Phronomy::Task` immediately without blocking; `Workflow#invoke` and `Workflow#invoke_async` follow the same contract | Stable |
|
|
55
|
-
| **Agent async events** — `invoke_async(..., on_event:)` and `stream_async(..., on_event:)` share `:tool_call`, `:tool_result`, `:approval_required`, `:done`, `:error`, `:timeout`, and `:cancelled`; streaming adds `:token` | Beta |
|
|
56
|
-
| **`stream` / `stream_async`** — callbacks execute on the EventLoop thread and must return quickly; the block form remains a compatibility alias for `on_event:` | Beta |
|
|
57
|
-
| **`stream_callback_error_policy`** — Backward-compatible setting shared by `invoke_async` and `stream_async` terminal `on_event:` callbacks: `:report` (default) preserves the Agent result, while `:fail_task` fails the returned Task with `Phronomy::StreamCallbackError`; Agent execution errors are never replaced by callback errors | Beta |
|
|
58
|
-
| **`invoke_async` / `call_async`** — `Agent::Base#invoke_async` and `Workflow#invoke_async` return a `Task`; `Agent::Context::Capability::Base#call_async` similarly; compatible with EventLoop and standalone contexts | Stable |
|
|
59
|
-
| **`Task#map`** — transforms a Task's completed value and propagates failure/cancellation. `Task#map` remains available for application-level Task composition, but Workflow entry and transition actions must not return a Task | Stable |
|
|
60
|
-
| **CancellationToken** — Cooperative cancellation via `cancel!`/`cancelled?`/`raise_if_cancelled!`; `timeout_after(seconds)` for monotonic-clock deadlines; passed as `config: { cancellation_token: token }` to agents and `dispatch_parallel`; injected into `tool.execute` when the method declares a `cancellation_token:` keyword; bridged to `MCP::Cancellation` in `Phronomy::Tools::Mcp#execute` | Experimental |
|
|
61
|
-
| **`execution_mode` DSL on `Agent::Context::Capability::Base`** — Declares how a tool's `execute` should be dispatched: `:cooperative` (same scheduler thread), `:blocking_io` (default; offloaded to `BlockingAdapterPool`), `:cpu_bound`, `:external_process`; Tool-specific timeout/retry belongs to the Tool implementation or its client | Experimental |
|
|
62
|
-
| **`blocking_io_pool_size` / `blocking_io_queue_size`** — Configure the default `BlockingAdapterPool` via `Phronomy.configure { \|c\| c.blocking_io_pool_size = 20; c.blocking_io_queue_size = 200 }`; all LLM calls, MCP tool calls, and other blocking I/O share this pool; defaults: `pool_size: 10`, `queue_size: 100` | Beta |
|
|
63
|
-
| **`invocation_context:` keyword on `Agent#invoke` / `Workflow#invoke`** — Pass a `Phronomy::InvocationContext` directly; `thread_id`, `cancellation_token`, and `deadline`-based timeout are derived from it; `task_id` / `parent_task_id` appear in trace spans automatically; `config:` keys remain supported as backward-compat aliases | Beta |
|
|
64
|
-
| **Cooperative scheduler yield points** — `Runtime#yield` (cooperative yield; yields the current task's time slice); `Runtime#yield_if_needed(every: N)` (thread-local counter, yields every N calls); CPU-bound detection when `blocking_detect_threshold_ms` is set (warns and increments `non_yield_threshold_violation_count` when a task runs longer than the threshold without yielding); `starvation_threshold_ms` configuration field (default: 50ms) | Beta |
|
|
65
|
-
| **`Phronomy::Metrics`** — `Phronomy::Metrics.snapshot` returns task-tree, pool, EventLoop, and queue counters; task-centric keys: `active_agent_tasks`, `active_tool_tasks`, `active_workflow_tasks`, `active_llm_tasks`, `task_wait_time_p50_ms`, `task_wait_time_p95_ms`, `task_run_time_p50_ms`, `task_run_time_p95_ms`, `cancelled_tasks`, `failed_tasks`, `non_yield_threshold_violation_count`; EventLoop queue keys: `event_loop_queue_depth` (current pending entries), `event_loop_queue_max_depth` (peak since start); a rate-limited warning is emitted when depth reaches 1,000 — events are not dropped | Beta |
|
|
66
|
-
| **`Phronomy.with_configuration` / `Phronomy.reset_runtime!`** — Scoped configuration override; `reset_runtime!` performs a full `Runtime#shutdown` (including EventLoop termination) then resets configuration; intended for test isolation | Beta |
|
|
67
|
-
| **`Runtime#event_loop`** — Returns the Runtime-owned `EventLoop` instance; lazy-initialised on first access; EventLoop lifetime is tied to the owning Runtime | Beta |
|
|
68
|
-
| **`Runtime#shutdown(timeout:, cancel_grace:)`** — Irreversible Runtime shutdown: drains active sessions, terminates the EventLoop dispatcher, then stops pools and timers; returns a `ShutdownResult` with `runtime_outcome` and `cleanup_status` fields | Beta |
|
|
69
|
-
|
|
70
|
-
**Agent patterns**
|
|
71
|
-
|
|
72
|
-
| Feature | Stability |
|
|
73
|
-
|---|---|
|
|
74
|
-
| **Workflow parallel pattern** — Concurrent branches via application-level threads (no built-in parallel primitive; see the Workflow section for the recommended pattern) | Beta |
|
|
75
|
-
| **Multi-agent** — Agent-as-Tool pattern and hub-and-spoke handoff routing | Beta |
|
|
76
|
-
| **GeneratorVerifier** — Generator-Verifier loop with injectable prompt builders/parsers | Beta |
|
|
77
|
-
| **`Phronomy::MultiAgent::Orchestrator`** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
|
|
78
|
-
| **`Phronomy::MultiAgent::TeamCoordinator`** — Agent teams pattern: LLM coordinator + stateful workers with sequential task assignment (worker-local message history persisted across tasks) | Beta |
|
|
79
|
-
| **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
|
|
80
|
-
| **Human-in-the-loop approval** — `Agent::Base#invoke` returns `{ suspended: true, execution_id: String, approval_request: Phronomy::Agent::ToolApprovalRequest }` when approval is required. `#approve` / `#approve_async` resume that execution. Suspended execution state is stored in Persistence, but durable activation rehydration after a process restart is not yet supported | Beta |
|
|
81
|
-
| **`tool_approval_policy`** — Instance-level callable that maps each `ToolApprovalRequest` to `:allow`, `:require_approval`, or `:reject`; set on the agent instance before invoking | Beta |
|
|
82
|
-
| **`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 |
|
|
83
|
-
|
|
84
|
-
> **Public API boundary**: The table above lists the primary public features
|
|
85
|
-
> intended for gem consumers. Every entry has an associated stability label.
|
|
86
|
-
> All other classes, modules, and methods — including everything in the
|
|
87
|
-
> [Advanced / Internal APIs](#advanced--internal-apis) section below — are
|
|
88
|
-
> marked `@api private` in source and may change without notice. Do not
|
|
89
|
-
> depend on internal APIs in application code.
|
|
90
|
-
|
|
91
|
-
## Advanced / Internal APIs
|
|
92
|
-
|
|
93
|
-
The APIs listed below are intended for advanced use cases, framework internals, and test infrastructure. Typical application code does not need to interact with them directly.
|
|
94
|
-
|
|
95
|
-
> These APIs are subject to change without the same backwards-compatibility guarantees as the stable public API.
|
|
96
|
-
|
|
97
|
-
| Feature | Stability |
|
|
98
|
-
|---|---|
|
|
99
|
-
| **`Phronomy::Diagnostics`** — Snapshot of scheduler internals for debug/monitoring; `SchedulerReentrancyError` raised on invalid re-entrant scheduler use; `Runtime.in_scheduler_context?` returns `true` when called from inside a scheduler task | Experimental |
|
|
100
|
-
| **`Phronomy::Testing::FakeClock` / `FakeScheduler` / `SchedulerHelpers`** — Test helpers for deterministic concurrency specs: `FakeClock#advance(seconds)` controls time; `FakeScheduler` runs tasks synchronously and records `event_log`; `FakeScheduler#assert_order` / `#assert_cancelled` for ordering assertions; `FakeClock#advance_to_next_timer` fires the next pending callback; `Testing::SchedulerHelpers#with_fake_scheduler` replaces the global Runtime for the duration of a block | Beta |
|
|
101
|
-
| **`Configuration#runtime_backend`** — `:thread` (default, one OS thread per task), `:immediate` (tests — tasks run synchronously, no extra threads), `:fiber` (**EXPERIMENTAL** — validation backend only: runs tasks as Ruby Fibers on a cooperative scheduler to verify that framework components are truly non-blocking; **not for production use** and not a planned production replacement for `:thread`; no preemptive scheduling will be added) | Beta |
|
|
102
|
-
| **`Configuration#strict_runtime_guards`** — When `true`, calling `Agent#invoke` from inside a scheduler task raises `SchedulerReentrancyError`; when `false` (default) a warning is logged instead | Beta |
|
|
26
|
+
See [Features and Application Programming Interface (API) stability](docs/features.md) for the full feature matrix.
|
|
103
27
|
|
|
104
28
|
## Installation
|
|
105
29
|
|
|
106
|
-
Add to your Gemfile:
|
|
30
|
+
Add Phronomy to your Gemfile:
|
|
107
31
|
|
|
108
32
|
```ruby
|
|
109
33
|
gem "phronomy"
|
|
@@ -115,75 +39,21 @@ Then run:
|
|
|
115
39
|
bundle install
|
|
116
40
|
```
|
|
117
41
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
Phronomy uses [RubyLLM](https://github.com/crmne/ruby_llm) for LLM access.
|
|
121
|
-
Configure your provider credentials before using agents or chains:
|
|
42
|
+
Configure RubyLLM with the provider credentials and transport policy required by
|
|
43
|
+
your application. Phronomy does not add another LLM transport retry/timeout layer.
|
|
122
44
|
|
|
123
45
|
```ruby
|
|
124
46
|
RubyLLM.configure do |c|
|
|
125
47
|
c.openai_api_key = ENV["OPENAI_API_KEY"]
|
|
126
|
-
# c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
127
|
-
|
|
128
|
-
# RubyLLM owns LLM transport timeout and retry policy.
|
|
129
48
|
c.request_timeout = 120
|
|
130
49
|
c.max_retries = 3
|
|
131
|
-
c.retry_interval = 0.1
|
|
132
|
-
c.retry_backoff_factor = 2
|
|
133
|
-
c.retry_interval_randomness = 0.5
|
|
134
50
|
end
|
|
135
51
|
```
|
|
136
52
|
|
|
137
|
-
See
|
|
138
|
-
|
|
139
|
-
### Execution-policy migration for 0.15
|
|
53
|
+
See [Getting started](docs/getting-started.md) for installation details, optional
|
|
54
|
+
dependencies, stateful Agent setup, streaming, and Workflow examples.
|
|
140
55
|
|
|
141
|
-
|
|
142
|
-
|---|---|
|
|
143
|
-
| `retry_policy` | RubyLLM transport retry, or explicit application orchestration |
|
|
144
|
-
| `invoke_timeout` | `InvocationContext#deadline` or `cancellation_token` when the caller needs a root deadline |
|
|
145
|
-
| `config[:llm_timeout]` | `RubyLLM.configure { |c| c.request_timeout = ... }` |
|
|
146
|
-
| Tool `retry_on` | Tool/client-specific retry with explicit idempotency guarantees |
|
|
147
|
-
| `config[:tool_timeout]` | Tool/client-native timeout |
|
|
148
|
-
| `max_parallel_tools` | No replacement; `parallel_tool_execution` remains an on/off mode |
|
|
149
|
-
| `InvocationContext#provider_limits` | Configure the provider client directly |
|
|
150
|
-
| `stream_queue_max_size` | No replacement; the shared EventLoop queue is unbounded by design. Monitor `Metrics.snapshot[:event_loop_queue_depth]` instead |
|
|
151
|
-
|
|
152
|
-
### 0.16 cleanup migration
|
|
153
|
-
|
|
154
|
-
The following compatibility-only APIs have been removed from the active contract:
|
|
155
|
-
|
|
156
|
-
| Removed API | Current contract |
|
|
157
|
-
|---|---|
|
|
158
|
-
| `context_overhead` | Manifest-first assembly budgets actual mandatory + selected content |
|
|
159
|
-
| Tool `on_error :return_empty` | Use `:raise` or `:suppress` |
|
|
160
|
-
| `dispatch_parallel(..., force_kill:)` / `fan_out(..., force_kill:)` | Cooperative cancellation; no force-kill switch |
|
|
161
|
-
| `runtime_backend = :cooperative` | Use `:thread`, `:immediate`, or experimental `:fiber` explicitly |
|
|
162
|
-
| `Runtime.instance = ...` | Runtime replacement is test/internal infrastructure, not a public setter |
|
|
163
|
-
| `TaskGroup.new(runtime: nil)` | Runtime is required |
|
|
164
|
-
| `tools ToolA, ToolB` | Use `tools(ToolA => nil, ToolB => nil)` |
|
|
165
|
-
| `CancellationToken.new(deadline: Time...)` | Use `CancellationToken.timeout_after(seconds)` for token deadlines |
|
|
166
|
-
| `StaticKnowledge` / `EntityKnowledge` / `Knowledge::Base` / `Phronomy::KnowledgeSource` | Register plain persistent Knowledge with `knowledge:` or `add_knowledge` |
|
|
167
|
-
| `static_knowledge*` class APIs | Persistent Knowledge belongs to Agent instances and is Journal-backed |
|
|
168
|
-
| `clear_memory!` | Use `clear_knowledge!`; conversation history is controlled independently with `clear_transcript!` |
|
|
169
|
-
|
|
170
|
-
The legacy `build_context` / `LlmContextWindow::Assembler` extension path is no
|
|
171
|
-
longer an active API. Stateful Agent input is assembled through the canonical
|
|
172
|
-
Journal → Context Policy → LLM Input Manifest pipeline.
|
|
173
|
-
|
|
174
|
-
### Optional dependencies
|
|
175
|
-
|
|
176
|
-
Install additional gems only for the features you use:
|
|
177
|
-
|
|
178
|
-
| Gem | Required for |
|
|
179
|
-
|-----|-------------|
|
|
180
|
-
| `pgvector` | `Phronomy::VectorStore::Pgvector` |
|
|
181
|
-
| `redis` | `Phronomy::VectorStore::RedisSearch` |
|
|
182
|
-
| `opentelemetry-api` | `Phronomy::Tracing::OpenTelemetryTracer` |
|
|
183
|
-
|
|
184
|
-
## Quick Start
|
|
185
|
-
|
|
186
|
-
### Agent — ReAct tool-calling agent
|
|
56
|
+
## Quick start
|
|
187
57
|
|
|
188
58
|
```ruby runnable
|
|
189
59
|
class WebSearch < Phronomy::Agent::Context::Capability::Base
|
|
@@ -191,7 +61,6 @@ class WebSearch < Phronomy::Agent::Context::Capability::Base
|
|
|
191
61
|
param :query, type: :string, desc: "Search query"
|
|
192
62
|
|
|
193
63
|
def execute(query:)
|
|
194
|
-
# Replace with a real search API call (e.g., SerpAPI, Tavily)
|
|
195
64
|
"Mock search result for: #{query}"
|
|
196
65
|
end
|
|
197
66
|
end
|
|
@@ -208,1164 +77,83 @@ result = ResearchAgent.new.invoke("What happened in AI research this week?")
|
|
|
208
77
|
puts result[:output]
|
|
209
78
|
```
|
|
210
79
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
immediately. Callbacks always execute on the **EventLoop thread** — keep them lightweight and
|
|
215
|
-
do not call blocking I/O or synchronous Agent APIs inside a callback.
|
|
216
|
-
|
|
217
|
-
```ruby
|
|
218
|
-
# Synchronous streaming — blocks until done
|
|
219
|
-
result = ResearchAgent.new.stream("What happened in AI research this week?") do |event|
|
|
220
|
-
case event.type
|
|
221
|
-
when :token then print event.payload[:content]
|
|
222
|
-
when :tool_call then puts "\n[Calling: #{event.payload[:tool_call].name}]"
|
|
223
|
-
when :tool_result then puts "[Tool done]"
|
|
224
|
-
when :done then puts "\n---"
|
|
225
|
-
when :approval_required
|
|
226
|
-
# Approval needed — handle via approve_async (see Human-in-the-loop section)
|
|
227
|
-
when :error then warn "Error: #{event.payload[:error].message}"
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
puts result[:output]
|
|
231
|
-
|
|
232
|
-
# Non-blocking streaming — returns Task immediately
|
|
233
|
-
task = ResearchAgent.new.stream_async("Summarise AI news") do |event|
|
|
234
|
-
broadcast_to_websocket(event) if event.type == :token # must return quickly
|
|
235
|
-
end
|
|
236
|
-
result = task.wait_result
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
#### Human-in-the-loop approval
|
|
240
|
-
|
|
241
|
-
When a tool is configured with `requires_approval true` and no `:allow` policy is set,
|
|
242
|
-
`invoke` suspends and returns `{ suspended: true, agent_invocation_id:, approval_request: }`.
|
|
243
|
-
Resume via `approve` (synchronous) or `approve_async` (non-blocking, safe inside callbacks):
|
|
80
|
+
For non-blocking top-level use, call `invoke_async` and keep the returned
|
|
81
|
+
`Phronomy::Task`. Inside Phronomy lifecycle callbacks, do not block waiting for
|
|
82
|
+
another Task; continue through explicit events instead.
|
|
244
83
|
|
|
245
84
|
```ruby
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
result = agent.invoke("Run the search")
|
|
250
|
-
if result[:suspended]
|
|
251
|
-
request = result[:approval_request]
|
|
252
|
-
puts "Tool: #{request.items.first.tool_name}"
|
|
253
|
-
|
|
254
|
-
# From top-level code — synchronous
|
|
255
|
-
final = agent.approve(
|
|
256
|
-
result[:agent_invocation_id],
|
|
257
|
-
approval_request_id: request.id,
|
|
258
|
-
approved: true
|
|
259
|
-
)
|
|
260
|
-
puts final[:output]
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
# Inside a stream callback — use approve_async to avoid EventLoop re-entry
|
|
264
|
-
agent.stream_async("Run it") do |event|
|
|
265
|
-
if event.type == :approval_required
|
|
266
|
-
req = event.payload[:request]
|
|
267
|
-
approve_task = agent.approve_async(
|
|
268
|
-
req.agent_invocation_id,
|
|
269
|
-
approval_request_id: req.id,
|
|
270
|
-
approved: true
|
|
271
|
-
)
|
|
272
|
-
# approve_task resolves when the resumed invocation completes
|
|
273
|
-
end
|
|
274
|
-
end
|
|
85
|
+
task = ResearchAgent.new.invoke_async("Research Ruby AI frameworks")
|
|
86
|
+
result = task.wait_result # top-level/external caller only
|
|
275
87
|
```
|
|
276
88
|
|
|
277
|
-
|
|
278
|
-
> persisted across process restarts and is **not** shared between pods or processes.
|
|
279
|
-
|
|
280
|
-
### Workflow — Stateful workflow with wait_state/send_event
|
|
281
|
-
|
|
282
|
-
```ruby runnable
|
|
283
|
-
class ReviewContext
|
|
284
|
-
include Phronomy::WorkflowContext
|
|
285
|
-
field :draft, type: :replace
|
|
286
|
-
field :feedback, type: :replace
|
|
287
|
-
field :approved, type: :replace, default: false
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
# Placeholder callables representing your own implementation
|
|
291
|
-
write_draft = ->(state) { state.merge(draft: "Draft content here") }
|
|
292
|
-
review_draft = ->(state) { state.merge(feedback: "Feedback on: #{state.draft}") }
|
|
89
|
+
## Runtime model
|
|
293
90
|
|
|
294
|
-
|
|
295
|
-
initial :write
|
|
296
|
-
state :write, action: write_draft
|
|
297
|
-
state :review, action: review_draft
|
|
298
|
-
wait_state :awaiting_approval # halts here for human decision
|
|
299
|
-
state :finalize, action: ->(s) { s.merge(approved: true) }
|
|
300
|
-
transition from: :write, to: :review
|
|
301
|
-
transition from: :review, to: :awaiting_approval
|
|
302
|
-
transition from: :finalize, to: :__finish__
|
|
303
|
-
transition from: :awaiting_approval, on: :approve, to: :finalize
|
|
304
|
-
transition from: :awaiting_approval, on: :reject, to: :write
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
# First run — halts at :awaiting_approval
|
|
308
|
-
state = app.invoke({ draft: "" }, config: { thread_id: "doc-1" })
|
|
309
|
-
puts "Halted: #{state.halted?}" # => true
|
|
310
|
-
puts "Draft: #{state.draft}"
|
|
311
|
-
|
|
312
|
-
# Resume after human approval — pass the halted state and the event name
|
|
313
|
-
final = app.send_event(state: state, event: :approve)
|
|
314
|
-
puts "Approved: #{final.approved}" # => true
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
Start the Agent as an asynchronous activity of the active state, then
|
|
318
|
-
map its lifecycle event to an application-defined Workflow event:
|
|
319
|
-
|
|
320
|
-
```ruby
|
|
321
|
-
class TranslationContext
|
|
322
|
-
include Phronomy::WorkflowContext
|
|
323
|
-
|
|
324
|
-
field :query
|
|
325
|
-
field :answer
|
|
326
|
-
field :error
|
|
327
|
-
|
|
328
|
-
def handle_fsm_event(event)
|
|
329
|
-
case event.type
|
|
330
|
-
when :translation_completed
|
|
331
|
-
self.answer = event.payload[:answer]
|
|
332
|
-
when :translation_failed
|
|
333
|
-
self.error = event.payload[:error]
|
|
334
|
-
end
|
|
335
|
-
false
|
|
336
|
-
end
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
workflow = nil
|
|
340
|
-
|
|
341
|
-
workflow = Phronomy::Workflow.define(TranslationContext) do
|
|
342
|
-
initial :translate
|
|
343
|
-
|
|
344
|
-
state :translate, action: ->(ctx) {
|
|
345
|
-
TranslationAgent.new.invoke_async(
|
|
346
|
-
ctx.query,
|
|
347
|
-
on_event: ->(event) {
|
|
348
|
-
case event.type
|
|
349
|
-
when :done
|
|
350
|
-
workflow.signal(
|
|
351
|
-
thread_id: ctx.thread_id,
|
|
352
|
-
event: :translation_completed,
|
|
353
|
-
payload: {answer: event.payload[:output]}
|
|
354
|
-
)
|
|
355
|
-
when :error, :timeout, :cancelled
|
|
356
|
-
workflow.signal(
|
|
357
|
-
thread_id: ctx.thread_id,
|
|
358
|
-
event: :translation_failed,
|
|
359
|
-
payload: {error: event.payload[:error]}
|
|
360
|
-
)
|
|
361
|
-
end
|
|
362
|
-
}
|
|
363
|
-
)
|
|
364
|
-
ctx
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
state :done
|
|
368
|
-
state :failed
|
|
369
|
-
|
|
370
|
-
transition from: :translate, on: :translation_completed, to: :done
|
|
371
|
-
transition from: :translate, on: :translation_failed, to: :failed
|
|
372
|
-
end
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
The application owns payload interpretation, correlation, and field updates.
|
|
376
|
-
Phronomy does not automatically copy Agent results into WorkflowContext.
|
|
377
|
-
|
|
378
|
-
Transitions may define an `action:` callback in addition to a `guard:`:
|
|
379
|
-
|
|
380
|
-
```ruby
|
|
381
|
-
transition(
|
|
382
|
-
from: :review,
|
|
383
|
-
on: :approved,
|
|
384
|
-
to: :publish,
|
|
385
|
-
guard: ->(context, event) {
|
|
386
|
-
event.payload[:request_id] == context.request_id
|
|
387
|
-
},
|
|
388
|
-
action: ->(context, event) {
|
|
389
|
-
context.merge(approved_by: event.payload[:reviewer])
|
|
390
|
-
}
|
|
391
|
-
)
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
The callback order for a successful transition is:
|
|
395
|
-
|
|
396
|
-
```text
|
|
397
|
-
source exit callbacks
|
|
398
|
-
-> selected transition action
|
|
399
|
-
-> target entry callbacks
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
Transition actions may accept either `(context)` or `(context, event)`. A
|
|
403
|
-
returned Workflow context replaces the current context before target entry
|
|
404
|
-
callbacks run. Returning `nil` or another non-context value preserves the
|
|
405
|
-
current context.
|
|
406
|
-
|
|
407
|
-
Like entry actions, transition actions are synchronous Run-to-Completion
|
|
408
|
-
callbacks. They may start asynchronous work and register a listener that later
|
|
409
|
-
calls `Workflow#signal`, but they must return the context or `nil` immediately.
|
|
410
|
-
Returning `Phronomy::Task` raises
|
|
411
|
-
`Phronomy::InvalidAsyncTransitionActionError`; Phronomy does not implicitly
|
|
412
|
-
await it.
|
|
413
|
-
|
|
414
|
-
For a transition with `on:`, the two-argument action receives the external
|
|
415
|
-
`Phronomy::Event`. For an automatic transition without `on:`, it receives the
|
|
416
|
-
internal event:
|
|
91
|
+
Phronomy uses one explicit lifecycle model:
|
|
417
92
|
|
|
418
93
|
```text
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
param :instructions, type: :string, desc: "Writing brief including research notes"
|
|
449
|
-
|
|
450
|
-
def execute(instructions:)
|
|
451
|
-
WriterAgent.new.invoke(instructions)[:output]
|
|
452
|
-
end
|
|
453
|
-
end
|
|
454
|
-
|
|
455
|
-
class OrchestratorAgent < Phronomy::Agent::Base
|
|
456
|
-
model "gpt-4o"
|
|
457
|
-
instructions "Use the research tool first, then the write tool to produce a blog post."
|
|
458
|
-
tools(ResearchTool => nil, WriteTool => nil)
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
result = OrchestratorAgent.new.invoke("Write a blog post about Ruby 3.4 features")
|
|
462
|
-
puts result[:output]
|
|
463
|
-
```
|
|
464
|
-
|
|
465
|
-
### Filters — Input/output transformation and blocking
|
|
466
|
-
|
|
467
|
-
Filters sit between user input and the LLM (input filters) or between the LLM response and the caller (output filters).
|
|
468
|
-
A filter may **transform** the value (return the modified value) or **block** it (call `block!(reason)`, which raises `Phronomy::FilterBlockError`).
|
|
469
|
-
|
|
470
|
-
```ruby
|
|
471
|
-
class NoCreditCardFilter < Phronomy::Filter::Base
|
|
472
|
-
def call(value, **_context)
|
|
473
|
-
block!("Credit card numbers are not allowed") if value.match?(/\d{4}-\d{4}-\d{4}-\d{4}/)
|
|
474
|
-
value
|
|
475
|
-
end
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
agent = ResearchAgent.new
|
|
479
|
-
agent.add_input_filter(NoCreditCardFilter.new)
|
|
480
|
-
|
|
481
|
-
begin
|
|
482
|
-
agent.invoke("Charge 4111-1111-1111-1111")
|
|
483
|
-
rescue Phronomy::FilterBlockError => e
|
|
484
|
-
puts e.message # => "Credit card numbers are not allowed"
|
|
485
|
-
end
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
> **Note:** Phronomy includes `PromptInjectionFilter`, a built-in pattern-based
|
|
489
|
-
> input filter that detects common injection patterns (see the feature table above).
|
|
490
|
-
> PII scanning and content classification are **not** provided by the framework;
|
|
491
|
-
> that logic must be implemented by the application. Reference implementations for
|
|
492
|
-
> common patterns are available in `phronomy-examples` (example 06).
|
|
493
|
-
|
|
494
|
-
### Knowledge — Persistent Agent context
|
|
495
|
-
|
|
496
|
-
Knowledge is plain logical content retained by one Agent and considered by
|
|
497
|
-
Context Policy for future LLM calls. It is not a separate source-object type and
|
|
498
|
-
it is not automatically mandatory.
|
|
499
|
-
|
|
500
|
-
Register initial Knowledge when creating the Agent:
|
|
501
|
-
|
|
502
|
-
```ruby
|
|
503
|
-
policy_text = File.read("policy.md")
|
|
504
|
-
|
|
505
|
-
agent = ResearchAgent.new(
|
|
506
|
-
knowledge: [
|
|
507
|
-
policy_text,
|
|
508
|
-
"Customer tier: enterprise"
|
|
509
|
-
]
|
|
510
|
-
)
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
Add durable Knowledge later:
|
|
514
|
-
|
|
515
|
-
```ruby
|
|
516
|
-
agent.add_knowledge(
|
|
517
|
-
"Customer locale: ja-JP",
|
|
518
|
-
metadata: {"origin" => "customer_profile"}
|
|
519
|
-
)
|
|
520
|
-
```
|
|
521
|
-
|
|
522
|
-
Persistent Knowledge is Journal-backed, survives `Agent.load`, and is excluded
|
|
523
|
-
from the public conversation transcript. `clear_knowledge!` logically
|
|
524
|
-
invalidates earlier Knowledge while keeping the append-only Journal intact.
|
|
525
|
-
|
|
526
|
-
```ruby
|
|
527
|
-
agent.clear_knowledge!
|
|
528
|
-
```
|
|
529
|
-
|
|
530
|
-
For request-scoped retrieval results that should not be persisted, use
|
|
531
|
-
`before_llm_input` `segment_candidates` instead.
|
|
532
|
-
|
|
533
|
-
Load and split documents with built-in loaders when the application needs an
|
|
534
|
-
acquisition/RAG pipeline:
|
|
535
|
-
|
|
536
|
-
```ruby
|
|
537
|
-
chunks = Phronomy::VectorStore::Loader::MarkdownLoader.new.load("docs/guide.md")
|
|
538
|
-
.then { |docs| Phronomy::VectorStore::Splitter::RecursiveSplitter.new(chunk_size: 512).split(docs) }
|
|
539
|
-
```
|
|
540
|
-
|
|
541
|
-
The application decides whether retrieved/extracted information becomes durable
|
|
542
|
-
Agent Knowledge (`add_knowledge`) or per-call Context (`before_llm_input`).
|
|
543
|
-
|
|
544
|
-
### Multi-Agent Handoff — Hub-and-spoke routing
|
|
545
|
-
|
|
546
|
-
```ruby
|
|
547
|
-
triage = TriageAgent.new
|
|
548
|
-
billing = BillingAgent.new
|
|
549
|
-
support = SupportAgent.new
|
|
550
|
-
|
|
551
|
-
runner = Phronomy::Agent::Runner.new(
|
|
552
|
-
agents: [triage, billing, support],
|
|
553
|
-
routes: { triage => [billing, support] }
|
|
554
|
-
)
|
|
555
|
-
|
|
556
|
-
result = runner.invoke("I need help with my invoice")
|
|
557
|
-
puts result[:output] # final answer
|
|
558
|
-
puts result[:agent].class # => BillingAgent
|
|
559
|
-
```
|
|
560
|
-
|
|
561
|
-
### Before-LLM-Input Hook — Per-call LLM input customization
|
|
562
|
-
|
|
563
|
-
`before_llm_input` runs before every LLM call and allows an application to
|
|
564
|
-
customize that call without mutating the Agent, RubyLLM chat, or canonical
|
|
565
|
-
Journal state directly.
|
|
566
|
-
|
|
567
|
-
Hooks can be configured at three levels:
|
|
568
|
-
|
|
569
|
-
1. global — applies to every Agent
|
|
570
|
-
2. class — applies to every instance of one Agent definition
|
|
571
|
-
3. instance — applies only to one Agent instance
|
|
572
|
-
|
|
573
|
-
They run in that order: global → class → instance.
|
|
574
|
-
|
|
575
|
-
A hook receives an immutable `Phronomy::Agent::LLMInputBuildContext` containing
|
|
576
|
-
metadata about the LLM call:
|
|
577
|
-
|
|
578
|
-
- `agent_id`
|
|
579
|
-
- `agent_definition_id`
|
|
580
|
-
- `definition_version`
|
|
581
|
-
- `config`
|
|
582
|
-
- `call_sequence`
|
|
583
|
-
|
|
584
|
-
The hook does not receive the mutable Agent instance, RubyLLM messages, or
|
|
585
|
-
`RubyLLM::Chat`.
|
|
586
|
-
|
|
587
|
-
Return either:
|
|
588
|
-
|
|
589
|
-
- `nil` to leave the call unchanged, or
|
|
590
|
-
- a `Phronomy::Agent::LLMInputPatch`
|
|
591
|
-
|
|
592
|
-
### Class-level hook
|
|
593
|
-
|
|
594
|
-
```ruby
|
|
595
|
-
class MyAgent < Phronomy::Agent::Base
|
|
596
|
-
agent_definition id: "my-agent", version: 1
|
|
597
|
-
|
|
598
|
-
model "gpt-4o"
|
|
599
|
-
|
|
600
|
-
before_llm_input ->(ctx) {
|
|
601
|
-
Phronomy::Agent::LLMInputPatch.new(
|
|
602
|
-
model_config_patch: {
|
|
603
|
-
temperature: ctx.config[:precise] ? 0.0 : 0.7
|
|
604
|
-
}
|
|
605
|
-
)
|
|
606
|
-
}
|
|
607
|
-
end
|
|
608
|
-
```
|
|
609
|
-
|
|
610
|
-
### Instance-level hook
|
|
611
|
-
|
|
612
|
-
```ruby
|
|
613
|
-
agent = MyAgent.new
|
|
614
|
-
|
|
615
|
-
agent.before_llm_input = ->(_ctx) {
|
|
616
|
-
Phronomy::Agent::LLMInputPatch.new(
|
|
617
|
-
model_config_patch: {
|
|
618
|
-
max_output_tokens: 512
|
|
619
|
-
}
|
|
620
|
-
)
|
|
621
|
-
}
|
|
622
|
-
```
|
|
623
|
-
|
|
624
|
-
### Global hook
|
|
625
|
-
|
|
626
|
-
```ruby
|
|
627
|
-
Phronomy.configure do |config|
|
|
628
|
-
config.before_llm_input = ->(_ctx) {
|
|
629
|
-
Phronomy::Agent::LLMInputPatch.new(
|
|
630
|
-
model_config_patch: {
|
|
631
|
-
temperature: 0.3
|
|
632
|
-
}
|
|
633
|
-
)
|
|
634
|
-
}
|
|
635
|
-
end
|
|
636
|
-
```
|
|
637
|
-
|
|
638
|
-
When multiple hooks provide `model_config_patch`, patches are merged in hook
|
|
639
|
-
order and later values win on key conflicts.
|
|
640
|
-
|
|
641
|
-
`LLMInputPatch` can also supply `segment_candidates` for additional per-call
|
|
642
|
-
context. Those candidates enter the same Context Policy selection path as
|
|
643
|
-
persistent/history candidates and are not automatically mandatory. They are
|
|
644
|
-
not persisted to the Journal.
|
|
645
|
-
|
|
646
|
-
```ruby
|
|
647
|
-
Phronomy::Agent::LLMInputPatch.new(
|
|
648
|
-
segment_candidates: [
|
|
649
|
-
{
|
|
650
|
-
content: "The customer is on the enterprise plan.",
|
|
651
|
-
category: :knowledge,
|
|
652
|
-
role: :user
|
|
653
|
-
}
|
|
654
|
-
]
|
|
655
|
-
)
|
|
656
|
-
```
|
|
657
|
-
|
|
658
|
-
The Journal remains the canonical record of observed execution history and
|
|
659
|
-
persistent Knowledge. `before_llm_input` customizes only the logical candidate
|
|
660
|
-
set for a particular LLM call.
|
|
661
|
-
|
|
662
|
-
### GeneratorVerifier — Generator-Verifier loop with custom prompt builders
|
|
663
|
-
|
|
664
|
-
```ruby
|
|
665
|
-
pipeline = Phronomy::GeneratorVerifier.new(
|
|
666
|
-
draft_agent: PolicyDraftAgent,
|
|
667
|
-
review_agent: PolicyReviewAgent,
|
|
668
|
-
|
|
669
|
-
# Full control over the LLM dialogue — supply your own prompts.
|
|
670
|
-
draft_prompt_builder: ->(input, feedback) {
|
|
671
|
-
base = "Answer precisely: #{input}"
|
|
672
|
-
feedback ? "#{base}\n\nPrevious feedback: #{feedback}" : base
|
|
673
|
-
},
|
|
674
|
-
review_prompt_builder: ->(input, draft, citations) {
|
|
675
|
-
"Is this draft accurate? Draft: #{draft}"
|
|
676
|
-
},
|
|
677
|
-
|
|
678
|
-
confidence_threshold: 0.7,
|
|
679
|
-
max_iterations: 3,
|
|
680
|
-
raise_if_untrusted: false # set true to raise LowConfidenceError
|
|
681
|
-
)
|
|
682
|
-
|
|
683
|
-
result = pipeline.invoke("What is the refund policy?")
|
|
684
|
-
puts result.output # final answer
|
|
685
|
-
puts result.trusted? # true when confidence >= 0.7
|
|
686
|
-
puts result.confidence # Float 0.0–1.0
|
|
687
|
-
result.citations.each { |c| puts "#{c[:source]}: #{c[:excerpt]}" }
|
|
688
|
-
```
|
|
689
|
-
|
|
690
|
-
Optionally inject a custom result parser to decode non-JSON LLM output:
|
|
691
|
-
|
|
692
|
-
```ruby
|
|
693
|
-
pipeline = Phronomy::GeneratorVerifier.new(
|
|
694
|
-
# ... (required params as shown above)
|
|
695
|
-
draft_result_parser: ->(text) { my_custom_draft_parser(text) },
|
|
696
|
-
review_result_parser: ->(text) { my_custom_review_parser(text) }
|
|
697
|
-
)
|
|
698
|
-
```
|
|
699
|
-
|
|
700
|
-
Raise on low confidence:
|
|
701
|
-
|
|
702
|
-
```ruby
|
|
703
|
-
begin
|
|
704
|
-
result = pipeline.invoke("question")
|
|
705
|
-
rescue Phronomy::LowConfidenceError => e
|
|
706
|
-
puts "Untrusted (confidence #{e.result.confidence}): #{e.result.output}"
|
|
707
|
-
end
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
### MultiAgent::Orchestrator — Parallel subagent dispatch
|
|
711
|
-
|
|
712
|
-
> **Note:** Use `max_concurrency:` to cap concurrent workers and `on_error:`
|
|
713
|
-
> to control failure handling (`:raise` re-raises the first error after all
|
|
714
|
-
> tasks complete; `:skip` fills failed slots with `nil`). For very large
|
|
715
|
-
> fan-outs consider additional rate-limiting at the application level.
|
|
716
|
-
|
|
717
|
-
```ruby
|
|
718
|
-
class ResearchOrchestrator < Phronomy::MultiAgent::Orchestrator
|
|
719
|
-
model "gpt-4o"
|
|
720
|
-
instructions "Coordinate research tasks by dispatching to specialised agents."
|
|
721
|
-
|
|
722
|
-
# Each subagent is automatically exposed as an LLM-callable tool.
|
|
723
|
-
subagent :searcher, SearchAgent
|
|
724
|
-
subagent :summarizer, SummaryAgent, on_error: :skip
|
|
725
|
-
end
|
|
726
|
-
|
|
727
|
-
result = ResearchOrchestrator.new.invoke("Research the latest AI news.")
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
Programmatic parallel dispatch (no LLM loop):
|
|
731
|
-
|
|
732
|
-
```ruby
|
|
733
|
-
class MyOrchestrator < Phronomy::MultiAgent::Orchestrator
|
|
734
|
-
model "gpt-4o"
|
|
735
|
-
instructions "Orchestrate."
|
|
736
|
-
|
|
737
|
-
def run(query)
|
|
738
|
-
results = dispatch_parallel(
|
|
739
|
-
{agent: SearchAgent, input: "topic A"},
|
|
740
|
-
{agent: AnalysisAgent, input: query},
|
|
741
|
-
max_concurrency: 4,
|
|
742
|
-
on_error: :skip,
|
|
743
|
-
timeout: 30
|
|
744
|
-
)
|
|
745
|
-
|
|
746
|
-
translations = fan_out(
|
|
747
|
-
agent: TranslationAgent,
|
|
748
|
-
inputs: %w[Hello World],
|
|
749
|
-
max_concurrency: 2,
|
|
750
|
-
timeout: 20
|
|
751
|
-
)
|
|
752
|
-
|
|
753
|
-
results.compact.map { |r| r[:output] }.join("\n")
|
|
754
|
-
end
|
|
755
|
-
end
|
|
756
|
-
```
|
|
757
|
-
|
|
758
|
-
### Workflow parallel pattern — Concurrent branches
|
|
759
|
-
|
|
760
|
-
Phronomy does not provide a dedicated parallel-node primitive. The recommended
|
|
761
|
-
pattern for concurrent branches is to use application-level Ruby threads inside
|
|
762
|
-
a `state` action:
|
|
763
|
-
|
|
764
|
-
```ruby
|
|
765
|
-
class EnrichContext
|
|
766
|
-
include Phronomy::WorkflowContext
|
|
767
|
-
field :summary, type: :replace
|
|
768
|
-
field :tags, type: :append, default: -> { [] }
|
|
769
|
-
end
|
|
770
|
-
|
|
771
|
-
app = Phronomy::Workflow.define(EnrichContext) do
|
|
772
|
-
initial :enrich
|
|
773
|
-
state :enrich, action: ->(s) do
|
|
774
|
-
threads = {
|
|
775
|
-
summary: Thread.new { Summarizer.call(s) },
|
|
776
|
-
tags: Thread.new { Tagger.call(s) }
|
|
777
|
-
}
|
|
778
|
-
threads.each_value(&:join)
|
|
779
|
-
s.merge(summary: threads[:summary].value, tags: Array(threads[:tags].value))
|
|
780
|
-
end
|
|
781
|
-
transition from: :enrich, to: :__finish__
|
|
782
|
-
end
|
|
783
|
-
|
|
784
|
-
state = app.invoke({}, config: { thread_id: "t1" })
|
|
785
|
-
```
|
|
786
|
-
|
|
787
|
-
### Output Parser — Structured LLM responses
|
|
788
|
-
|
|
789
|
-
```ruby
|
|
790
|
-
parser = Phronomy::OutputParser::JsonParser.new
|
|
791
|
-
data = parser.parse('```json\n{"name":"Alice","score":0.9}\n```')
|
|
792
|
-
# => { name: "Alice", score: 0.9 }
|
|
793
|
-
|
|
794
|
-
PersonSchema = Struct.new(:name, :age, keyword_init: true)
|
|
795
|
-
parser = Phronomy::OutputParser::StructuredParser.new(PersonSchema)
|
|
796
|
-
person = parser.parse('{"name":"Alice","age":30}')
|
|
797
|
-
# => #<struct PersonSchema name="Alice", age=30>
|
|
798
|
-
```
|
|
799
|
-
|
|
800
|
-
### Eval Framework — Dataset-driven quality evaluation
|
|
801
|
-
|
|
802
|
-
```ruby
|
|
803
|
-
dataset = Phronomy::Eval::Dataset.from_array([
|
|
804
|
-
{ input: "Capital of France?", expected: "Paris" },
|
|
805
|
-
{ input: "Capital of Japan?", expected: "Tokyo" }
|
|
806
|
-
])
|
|
807
|
-
|
|
808
|
-
agent = MyGeographyAgent.new
|
|
809
|
-
runner = Phronomy::Eval::Runner.new(
|
|
810
|
-
scorer: Phronomy::Eval::Scorer::LlmJudge.new(model: "gpt-4o-mini")
|
|
811
|
-
)
|
|
812
|
-
|
|
813
|
-
results = runner.run(dataset, ->(q) { agent.invoke(q) })
|
|
814
|
-
metrics = Phronomy::Eval::Metrics.new(results)
|
|
815
|
-
|
|
816
|
-
puts "Mean score: #{metrics.mean_score}"
|
|
817
|
-
puts "Pass rate: #{metrics.pass_rate}"
|
|
818
|
-
```
|
|
819
|
-
|
|
820
|
-
### Tracing — Custom observability
|
|
821
|
-
|
|
822
|
-
```ruby
|
|
823
|
-
Phronomy.configure do |c|
|
|
824
|
-
c.tracer = MyCustomTracer.new
|
|
825
|
-
end
|
|
826
|
-
```
|
|
827
|
-
|
|
828
|
-
### MCP Tool — External tool servers
|
|
829
|
-
|
|
830
|
-
> **MCP 1.x required.** Phronomy targets `mcp ~> 1.0`. For supported JSON Schema
|
|
831
|
-
> constructs, error semantics, and client lifecycle contracts, see
|
|
832
|
-
> [`docs/mcp-client.md`](docs/mcp-client.md).
|
|
833
|
-
|
|
834
|
-
```ruby
|
|
835
|
-
search_tool = Phronomy::Tools::Mcp.from_server(
|
|
836
|
-
"stdio://./mcp-server",
|
|
837
|
-
tool_name: "web_search"
|
|
838
|
-
)
|
|
839
|
-
```
|
|
840
|
-
|
|
841
|
-
Call `close` when the tool is no longer needed to shut down the underlying
|
|
842
|
-
child process (stdio transport) or release the HTTP connection:
|
|
843
|
-
|
|
844
|
-
```ruby
|
|
845
|
-
search_tool.close
|
|
846
|
-
```
|
|
847
|
-
|
|
848
|
-
### Agent State and Conversation History
|
|
849
|
-
|
|
850
|
-
Phronomy Agents are stateful objects. Each Agent has a stable `agent_id`, a persistent Agent root, an append-only execution Journal, and zero or more Agent executions.
|
|
851
|
-
|
|
852
|
-
Every concrete Agent definition must declare a stable definition identity:
|
|
853
|
-
|
|
854
|
-
```ruby
|
|
855
|
-
class ResearchAgent < Phronomy::Agent::Base
|
|
856
|
-
agent_definition id: "research-agent", version: 1
|
|
857
|
-
|
|
858
|
-
model "gpt-4o"
|
|
859
|
-
instructions "You are a research assistant."
|
|
860
|
-
end
|
|
861
|
-
```
|
|
862
|
-
|
|
863
|
-
The definition ID identifies the application-level Agent definition. The version is checked when a previously persisted Agent is loaded so that persisted state is not silently interpreted by an incompatible Agent definition.
|
|
864
|
-
|
|
865
|
-
Create and continue using the same Agent instance normally:
|
|
866
|
-
|
|
867
|
-
```ruby
|
|
868
|
-
persistence = Phronomy::Persistence::InMemory.new
|
|
869
|
-
|
|
870
|
-
agent = ResearchAgent.create(
|
|
871
|
-
agent_id: "research-session-42",
|
|
872
|
-
knowledge: ["Customer tier: enterprise"],
|
|
873
|
-
persistence: persistence
|
|
874
|
-
)
|
|
875
|
-
|
|
876
|
-
agent.invoke("My name is Alice.")
|
|
877
|
-
agent.add_knowledge("Customer locale: ja-JP")
|
|
878
|
-
result = agent.invoke("What is my name?")
|
|
879
|
-
|
|
880
|
-
puts result[:output]
|
|
881
|
-
```
|
|
882
|
-
|
|
883
|
-
Conversation history does not need to be passed back through `messages:` on every invocation. The Agent's canonical history is retained in its Journal and selected automatically when later LLM Calls are assembled.
|
|
884
|
-
|
|
885
|
-
A persisted Agent can be loaded again when the same Persistence backend is available:
|
|
886
|
-
|
|
887
|
-
```ruby
|
|
888
|
-
agent = ResearchAgent.load(
|
|
889
|
-
"research-session-42",
|
|
890
|
-
persistence: persistence
|
|
891
|
-
)
|
|
892
|
-
|
|
893
|
-
result = agent.invoke("Continue our previous discussion.")
|
|
894
|
-
```
|
|
895
|
-
|
|
896
|
-
`result[:messages]` remains available as a materialized transcript of the Agent's current conversation history. It is a projection of canonical Agent state, not the authoritative storage mechanism and does not need to be supplied to the next `invoke`.
|
|
897
|
-
|
|
898
|
-
Existing external conversation history can be supplied when a new Agent is created:
|
|
899
|
-
|
|
900
|
-
```ruby
|
|
901
|
-
agent = ResearchAgent.create(
|
|
902
|
-
context: existing_messages,
|
|
903
|
-
knowledge: initial_knowledge,
|
|
904
|
-
persistence: persistence
|
|
905
|
-
)
|
|
906
|
-
```
|
|
907
|
-
|
|
908
|
-
Imported history must satisfy Phronomy's Import contract. User, assistant, and Tool messages are journaled without destroying their logical message boundaries. System instructions are Agent configuration and are not imported as ordinary conversation messages.
|
|
909
|
-
|
|
910
|
-
`thread_id` is an execution correlation identifier. It does not identify the persistent Agent and is not a substitute for `agent_id`.
|
|
911
|
-
|
|
912
|
-
The active conversation and Knowledge views can be advanced independently without deleting the canonical Journal:
|
|
913
|
-
|
|
914
|
-
```ruby
|
|
915
|
-
agent.clear_transcript! # conversation only
|
|
916
|
-
agent.clear_knowledge! # persistent Knowledge only
|
|
917
|
-
agent.reset_context! # both
|
|
918
|
-
```
|
|
919
|
-
|
|
920
|
-
`purge!` is different: it permanently removes the Agent and its persisted execution history from the configured Persistence backend.
|
|
921
|
-
|
|
922
|
-
## Configuration
|
|
923
|
-
|
|
924
|
-
```ruby
|
|
925
|
-
Phronomy.configure do |c|
|
|
926
|
-
c.default_model = "gpt-4o-mini"
|
|
927
|
-
c.recursion_limit = 25
|
|
928
|
-
c.tracer = Phronomy::Tracing::NullTracer.new
|
|
929
|
-
c.before_llm_input = nil # optional global before_llm_input hook
|
|
930
|
-
c.trace_pii = false # default; set to true only when trace data contains no PII
|
|
931
|
-
c.logger = nil # optional; any object responding to #warn (e.g. Rails.logger)
|
|
932
|
-
c.event_loop_stop_grace_seconds = 5 # seconds to wait for sessions to drain on shutdown
|
|
933
|
-
c.runtime_backend = :thread # :thread (default); :immediate (tests, synchronous); :fiber (experimental validation only)
|
|
934
|
-
c.strict_runtime_guards = false # when true, raises SchedulerReentrancyError on invoke-inside-task
|
|
935
|
-
c.stream_callback_error_policy = :report # :report (default) preserves Agent result; :fail_task fails Task with StreamCallbackError
|
|
936
|
-
end
|
|
937
|
-
```
|
|
938
|
-
|
|
939
|
-
`c.logger` receives framework diagnostic messages (e.g. unreachable-state warnings from
|
|
940
|
-
`Workflow.define`, stream callback errors, EventLoop queue backlog warnings). When `nil`
|
|
941
|
-
(default), messages are written to `$stderr` via `Kernel#warn`.
|
|
942
|
-
|
|
943
|
-
> **Note**: When `trace_pii = false`, both the _input_ and the _output_ (LLM
|
|
944
|
-
> responses and tool results) are replaced with `[REDACTED]` in trace spans.
|
|
945
|
-
> The default is `false` (PII protection enabled). Set to `true` only when
|
|
946
|
-
> trace data does not contain sensitive information.
|
|
947
|
-
|
|
948
|
-
## Sync vs Async API
|
|
949
|
-
|
|
950
|
-
Phronomy provides both synchronous and asynchronous invocation APIs.
|
|
951
|
-
Understanding when to use each prevents scheduler stalls and hidden deadlocks.
|
|
952
|
-
|
|
953
|
-
| Context | Recommended API |
|
|
954
|
-
|---------|----------------|
|
|
955
|
-
| Top-level application code, Rails controller, background job | `agent.invoke(input)` — blocks the calling thread until done |
|
|
956
|
-
| Workflow action | Start `invoke_async` and use `Workflow#signal` in the `on_event:` callback to deliver the result as a later Workflow event |
|
|
957
|
-
| Top-level code that wants explicit async | `agent.invoke_async(input).wait_result` — blocks the calling thread until the Task completes |
|
|
958
|
-
| Streaming from top-level code | `agent.stream(input) { |event| ... }` — blocks until done; callbacks run on the EventLoop thread |
|
|
959
|
-
| Streaming non-blocking | `task = agent.stream_async(input) { |event| ... }` — returns Task immediately; callbacks run on the EventLoop thread |
|
|
960
|
-
| Resume approval from top-level code | `agent.approve(id, approval_request_id: r_id, approved: true)` — synchronous; blocks until resumed |
|
|
961
|
-
| Resume approval from an EventLoop callback | `agent.approve_async(id, approval_request_id: r_id, approved: true)` — returns Task; safe to call from a stream callback |
|
|
962
|
-
|
|
963
|
-
### Why this matters
|
|
964
|
-
|
|
965
|
-
`invoke` is a synchronous wrapper around asynchronous Agent execution and blocks
|
|
966
|
-
the calling thread until the Agent finishes. It is appropriate for top-level
|
|
967
|
-
application code such as CLI commands, controller actions, or background jobs.
|
|
968
|
-
|
|
969
|
-
Workflow entry and transition actions have a different contract: they are
|
|
970
|
-
synchronous Run-to-Completion callbacks and must finish promptly.
|
|
971
|
-
|
|
972
|
-
If a Workflow action needs an Agent or another asynchronous operation, start the
|
|
973
|
-
operation asynchronously, register its completion listener, return the Workflow
|
|
974
|
-
context, and use `Workflow#signal` to deliver completion as a later Workflow
|
|
975
|
-
event.
|
|
976
|
-
|
|
977
|
-
Do not call blocking `Agent#invoke` from an EventLoop callback, and do not return
|
|
978
|
-
the `Task` from `Agent#invoke_async` as the result of a Workflow entry or
|
|
979
|
-
transition action.
|
|
980
|
-
|
|
981
|
-
### Runtime guard
|
|
982
|
-
|
|
983
|
-
Phronomy detects this pattern automatically:
|
|
984
|
-
|
|
985
|
-
```ruby
|
|
986
|
-
Phronomy.configure { |c| c.strict_runtime_guards = false }
|
|
987
|
-
Phronomy.configure { |c| c.strict_runtime_guards = true }
|
|
988
|
-
```
|
|
989
|
-
|
|
990
|
-
You can also query the current context directly:
|
|
991
|
-
|
|
992
|
-
```ruby
|
|
993
|
-
Phronomy::Runtime.in_scheduler_context?
|
|
994
|
-
```
|
|
995
|
-
|
|
996
|
-
### Migration: blocking wait → Task mapping
|
|
997
|
-
|
|
998
|
-
```ruby
|
|
999
|
-
result = my_agent.invoke("Hello")
|
|
1000
|
-
result = my_agent.invoke_async("Hello").wait_result
|
|
1001
|
-
```
|
|
1002
|
-
|
|
1003
|
-
### Async work inside a Workflow
|
|
1004
|
-
|
|
1005
|
-
Workflow entry and transition actions are synchronous Run-to-Completion
|
|
1006
|
-
callbacks.
|
|
1007
|
-
|
|
1008
|
-
They may start asynchronous work, but they must return the Workflow context (or
|
|
1009
|
-
`nil`). Returning a `Phronomy::Task` from an entry or transition action is an
|
|
1010
|
-
error.
|
|
1011
|
-
|
|
1012
|
-
When an asynchronous Agent finishes, deliver its result back to the live
|
|
1013
|
-
Workflow as a later event with `Workflow#signal`.
|
|
1014
|
-
|
|
1015
|
-
```ruby
|
|
1016
|
-
class AnswerContext
|
|
1017
|
-
include Phronomy::WorkflowContext
|
|
1018
|
-
|
|
1019
|
-
field :question, type: :replace, default: ""
|
|
1020
|
-
field :answer, type: :replace, default: nil
|
|
1021
|
-
end
|
|
1022
|
-
|
|
1023
|
-
workflow = nil
|
|
1024
|
-
|
|
1025
|
-
workflow = Phronomy::Workflow.define(AnswerContext) do
|
|
1026
|
-
initial :asking
|
|
1027
|
-
|
|
1028
|
-
state :asking
|
|
1029
|
-
state :done
|
|
1030
|
-
|
|
1031
|
-
entry :asking, ->(ctx) {
|
|
1032
|
-
thread_id = ctx.thread_id
|
|
1033
|
-
|
|
1034
|
-
my_agent.invoke_async(
|
|
1035
|
-
ctx.question,
|
|
1036
|
-
on_event: ->(event) {
|
|
1037
|
-
next unless event.type == :done
|
|
1038
|
-
|
|
1039
|
-
workflow.signal(
|
|
1040
|
-
thread_id: thread_id,
|
|
1041
|
-
event: :answer_ready,
|
|
1042
|
-
payload: { answer: event.payload[:output] }
|
|
1043
|
-
)
|
|
1044
|
-
}
|
|
1045
|
-
)
|
|
1046
|
-
|
|
1047
|
-
ctx
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
transition(
|
|
1051
|
-
from: :asking,
|
|
1052
|
-
on: :answer_ready,
|
|
1053
|
-
to: :done,
|
|
1054
|
-
action: ->(ctx, event) {
|
|
1055
|
-
ctx.merge(answer: event.payload[:answer])
|
|
1056
|
-
}
|
|
1057
|
-
)
|
|
1058
|
-
|
|
1059
|
-
transition from: :done, to: :__finish__
|
|
1060
|
-
end
|
|
1061
|
-
```
|
|
1062
|
-
|
|
1063
|
-
The important separation is:
|
|
1064
|
-
|
|
1065
|
-
```text
|
|
1066
|
-
Workflow action
|
|
1067
|
-
│
|
|
1068
|
-
├─ starts asynchronous work
|
|
1069
|
-
│
|
|
1070
|
-
└─ returns context immediately
|
|
1071
|
-
│
|
|
1072
|
-
▼
|
|
1073
|
-
asynchronous Agent
|
|
1074
|
-
│
|
|
1075
|
-
▼
|
|
1076
|
-
on_event / callback
|
|
1077
|
-
│
|
|
1078
|
-
▼
|
|
1079
|
-
Workflow#signal
|
|
1080
|
-
│
|
|
1081
|
-
▼
|
|
1082
|
-
later Workflow event
|
|
1083
|
-
```
|
|
1084
|
-
|
|
1085
|
-
`Task#map` remains a valid Task API for transforming Task results, but a mapped
|
|
1086
|
-
Task must not be returned from a Workflow entry or transition action.
|
|
1087
|
-
|
|
1088
|
-
### :immediate backend (synchronous / test mode)
|
|
1089
|
-
|
|
1090
|
-
The `:immediate` backend runs tasks synchronously using `FakeScheduler`
|
|
1091
|
-
(backed by `Task::ImmediateBackend`). Blocking I/O is isolated in `BlockingAdapterPool`.
|
|
1092
|
-
To switch back to the default thread-per-task backend:
|
|
1093
|
-
|
|
1094
|
-
```ruby
|
|
1095
|
-
Phronomy.configure { |c| c.runtime_backend = :thread }
|
|
1096
|
-
# or per-example using SchedulerHelpers:
|
|
1097
|
-
include Phronomy::Testing::SchedulerHelpers
|
|
1098
|
-
with_fake_scheduler do |sched|
|
|
1099
|
-
# all spawns run synchronously; sched.event_log records every lifecycle event
|
|
1100
|
-
end
|
|
1101
|
-
```
|
|
1102
|
-
|
|
1103
|
-
## Context Management
|
|
1104
|
-
|
|
1105
|
-
Phronomy uses a Manifest-first context architecture for stateful Agents.
|
|
1106
|
-
|
|
1107
|
-
```text
|
|
1108
|
-
Canonical Journal
|
|
1109
|
-
↓
|
|
1110
|
-
Context candidates
|
|
1111
|
-
↓
|
|
1112
|
-
Context Policy
|
|
1113
|
-
↓
|
|
1114
|
-
LLM Call Manifest
|
|
1115
|
-
↓
|
|
1116
|
-
Runtime Projection
|
|
1117
|
-
↓
|
|
1118
|
-
RubyLLM / Provider
|
|
1119
|
-
```
|
|
1120
|
-
|
|
1121
|
-
The **Journal** is the canonical append-only record of logical execution facts
|
|
1122
|
-
observed by Phronomy and persistent Knowledge explicitly registered by the
|
|
1123
|
-
application.
|
|
1124
|
-
|
|
1125
|
-
The **Manifest** is the canonical logical input fixed for one particular LLM
|
|
1126
|
-
Call.
|
|
1127
|
-
|
|
1128
|
-
Context-window management therefore does not trim or rewrite the Agent's
|
|
1129
|
-
canonical history. Phronomy selects the subset of available optional Context
|
|
1130
|
-
needed for each LLM Call and records that selection in the Manifest.
|
|
1131
|
-
|
|
1132
|
-
Persistent Knowledge is an ordinary `:knowledge` Context candidate. It is not
|
|
1133
|
-
concatenated into the mandatory system prompt. Conversation history and
|
|
1134
|
-
Knowledge share the same policy/budget selection path while remaining distinct
|
|
1135
|
-
in public transcript semantics.
|
|
1136
|
-
|
|
1137
|
-
Per-call `before_llm_input` segment candidates also pass through Context Policy
|
|
1138
|
-
and are not written to the Journal.
|
|
1139
|
-
|
|
1140
|
-
Tool protocol dependencies are preserved during selection. An assistant message
|
|
1141
|
-
containing Tool Calls and the corresponding Tool-role messages are selected as
|
|
1142
|
-
a protocol-safe unit rather than independently pruning messages in a way that
|
|
1143
|
-
would create an invalid LLM conversation.
|
|
1144
|
-
|
|
1145
|
-
When the available budget is insufficient, optional history or Knowledge can be
|
|
1146
|
-
omitted from the current Manifest. Required context is never silently removed
|
|
1147
|
-
merely to satisfy the budget. If required input cannot fit, Phronomy raises
|
|
1148
|
-
`ContextBudgetExceededError`.
|
|
1149
|
-
|
|
1150
|
-
### Context-window configuration
|
|
1151
|
-
|
|
1152
|
-
Phronomy derives the effective context budget from RubyLLM model metadata when available.
|
|
1153
|
-
|
|
1154
|
-
For local or otherwise unregistered models, the context window can be declared explicitly:
|
|
1155
|
-
|
|
1156
|
-
```ruby
|
|
1157
|
-
class LocalAgent < Phronomy::Agent::Base
|
|
1158
|
-
agent_definition id: "local-agent", version: 1
|
|
1159
|
-
|
|
1160
|
-
model "local-model"
|
|
1161
|
-
context_window 32_768
|
|
1162
|
-
max_output_tokens 4_096
|
|
1163
|
-
end
|
|
1164
|
-
```
|
|
1165
|
-
|
|
1166
|
-
`context_window` determines the model's total context capacity.
|
|
1167
|
-
|
|
1168
|
-
`max_output_tokens` reserves capacity for the model's output.
|
|
1169
|
-
|
|
1170
|
-
Mandatory instructions, current input and Tool definitions are budgeted from
|
|
1171
|
-
their actual canonical values. `context_overhead` is not part of the current
|
|
1172
|
-
contract.
|
|
1173
|
-
|
|
1174
|
-
The current default Context Policy is framework-managed. Public custom Context
|
|
1175
|
-
Policy APIs, deterministic persistent compaction, and other advanced policy
|
|
1176
|
-
extension points are still evolving and should not yet be treated as stable
|
|
1177
|
-
application APIs.
|
|
1178
|
-
|
|
1179
|
-
> **Note on CJK languages**: The default `TokenEstimator` uses a character-ratio heuristic
|
|
1180
|
-
> calibrated for ASCII/Latin text (4 chars/token). For Chinese, Japanese, and Korean text,
|
|
1181
|
-
> actual token counts are approximately **4× higher** than the estimate because CJK
|
|
1182
|
-
> characters are typically 1 token each. For accurate CJK token counting, supply a
|
|
1183
|
-
> tokenizer-backed callable:
|
|
1184
|
-
>
|
|
1185
|
-
> ```ruby
|
|
1186
|
-
> require "tiktoken_ruby"
|
|
1187
|
-
> enc = Tiktoken.encoding_for_model("gpt-4o")
|
|
1188
|
-
> Phronomy::LlmContextWindow::TokenEstimator.tokenizer = ->(text) { enc.encode(text).length }
|
|
1189
|
-
> ```
|
|
1190
|
-
|
|
1191
|
-
### CancellationToken — Cooperative cancellation
|
|
1192
|
-
|
|
1193
|
-
Pass a `CancellationToken` to any agent via `config: { cancellation_token: token }`.
|
|
1194
|
-
Cancellation is checked at multiple granular checkpoints: before the LLM call,
|
|
1195
|
-
after each streaming chunk, before each parallel tool-call batch, and after each
|
|
1196
|
-
`before_llm_input` hook. `CancellationError` is raised immediately. Phronomy
|
|
1197
|
-
does not replay the complete Agent invocation. No threads are force-killed —
|
|
1198
|
-
`ensure` blocks always execute.
|
|
1199
|
-
|
|
1200
|
-
> **Cooperative cancellation — not preemptive**
|
|
1201
|
-
>
|
|
1202
|
-
> Phronomy uses _cooperative boundary cancellation_. The token is polled at the
|
|
1203
|
-
> checkpoints listed above; it is **not** injected as a signal into a running
|
|
1204
|
-
> operation. This means the following are **not** interrupted mid-execution:
|
|
1205
|
-
>
|
|
1206
|
-
> - An application retrieval/load operation that is already blocking
|
|
1207
|
-
> - A single `chat.ask` call that is not streaming
|
|
1208
|
-
> - A single `tool.execute` call that is already running
|
|
1209
|
-
> - Any external I/O (database query, vector search, HTTP request) inside those calls
|
|
1210
|
-
>
|
|
1211
|
-
> For deep in-flight safety, complement `CancellationToken` with operation-native
|
|
1212
|
-
> timeouts. Prefer library-native timeouts such as `Net::HTTP#read_timeout`,
|
|
1213
|
-
> database `statement_timeout`, or Redis client timeout.
|
|
1214
|
-
|
|
1215
|
-
> **`timeout_after` vs `CancellationScope.deadline_in`**
|
|
1216
|
-
>
|
|
1217
|
-
> `CancellationToken.timeout_after(seconds)` uses lazy clock comparison: `cancelled?`
|
|
1218
|
-
> returns `true` once the deadline elapses, but `on_cancel` callbacks are **not**
|
|
1219
|
-
> fired. Bridges that rely on `on_cancel` — such as the `MCP::Cancellation` bridge
|
|
1220
|
-
> in `Phronomy::Tools::Mcp#execute` — will therefore **not** be triggered on expiry.
|
|
1221
|
-
>
|
|
1222
|
-
> When you need the cancellation to propagate into in-flight I/O (e.g. an MCP
|
|
1223
|
-
> `call_tool` request), use `CancellationScope` instead:
|
|
1224
|
-
>
|
|
1225
|
-
> ```ruby
|
|
1226
|
-
> scope = Phronomy::Concurrency::CancellationScope.new.deadline_in(30)
|
|
1227
|
-
> result = MyAgent.new.invoke("...", config: { cancellation_token: scope.token })
|
|
1228
|
-
> ```
|
|
1229
|
-
>
|
|
1230
|
-
> `CancellationScope#deadline_in` registers a timer in the Runtime timer queue,
|
|
1231
|
-
> which calls `cancel!` on expiry and fires all `on_cancel` callbacks — including
|
|
1232
|
-
> the MCP bridge.
|
|
1233
|
-
|
|
1234
|
-
> **Transport timeout and retry ownership**
|
|
1235
|
-
>
|
|
1236
|
-
> Phronomy does not interpret `config[:llm_timeout]`, `config[:tool_timeout]`,
|
|
1237
|
-
> Agent `retry_policy`, or Tool `retry_on`. Configure LLM transport behavior on
|
|
1238
|
-
> RubyLLM (or another adapter) and configure Tool transport behavior on the Tool's
|
|
1239
|
-
> HTTP/DB/MCP client.
|
|
1240
|
-
>
|
|
1241
|
-
> `InvocationContext#deadline` and `cancellation_token` remain available for a
|
|
1242
|
-
> caller-defined root-operation boundary. They provide cooperative cancellation
|
|
1243
|
-
> across the Phronomy execution tree; they do not replace provider-native socket,
|
|
1244
|
-
> request, statement, or session timeouts.
|
|
1245
|
-
|
|
1246
|
-
```ruby
|
|
1247
|
-
token = Phronomy::Concurrency::CancellationToken.new
|
|
1248
|
-
|
|
1249
|
-
Thread.new { sleep 5; token.cancel! }
|
|
1250
|
-
|
|
1251
|
-
begin
|
|
1252
|
-
result = MyAgent.new.invoke("...", config: { cancellation_token: token })
|
|
1253
|
-
rescue Phronomy::CancellationError
|
|
1254
|
-
puts "cancelled"
|
|
1255
|
-
end
|
|
1256
|
-
|
|
1257
|
-
token = Phronomy::Concurrency::CancellationToken.timeout_after(30)
|
|
1258
|
-
result = MyAgent.new.invoke("...", config: { cancellation_token: token })
|
|
1259
|
-
|
|
1260
|
-
token = Phronomy::Concurrency::CancellationToken.new
|
|
1261
|
-
Thread.new { sleep 10; token.cancel! }
|
|
1262
|
-
|
|
1263
|
-
orchestrator.dispatch_parallel(
|
|
1264
|
-
{agent: SearchAgent, input: "topic A"},
|
|
1265
|
-
{agent: AnalysisAgent, input: "topic B"},
|
|
1266
|
-
cancellation_token: token
|
|
1267
|
-
)
|
|
1268
|
-
```
|
|
94
|
+
Runtime
|
|
95
|
+
├─ EventLoop
|
|
96
|
+
│ └─ FSMSession
|
|
97
|
+
│ ├─ Agent
|
|
98
|
+
│ ├─ Workflow
|
|
99
|
+
│ ├─ ToolInvocation
|
|
100
|
+
│ └─ MultiAgent fan-out
|
|
101
|
+
├─ OffloadPool
|
|
102
|
+
└─ EventLoop-driven timers
|
|
103
|
+
|
|
104
|
+
Task = completion handle
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Logical waiting remains in EventLoop/FSMSession state. Synchronous work that
|
|
108
|
+
would block EventLoop uses the bounded OffloadPool. See
|
|
109
|
+
[Runtime and concurrency](docs/runtime-and-concurrency.md) for the detailed
|
|
110
|
+
contracts, timeout/cancellation semantics, metrics, and callback rules.
|
|
111
|
+
|
|
112
|
+
## Documentation
|
|
113
|
+
|
|
114
|
+
- [Getting started](docs/getting-started.md) — installation, RubyLLM setup, Agent/Workflow basics, persistence, streaming.
|
|
115
|
+
- [Features and API stability](docs/features.md) — public feature matrix and stability labels.
|
|
116
|
+
- [Runtime and concurrency](docs/runtime-and-concurrency.md) — EventLoop, FSMSession, Task, OffloadPool, cancellation, observability.
|
|
117
|
+
- [MCP client](docs/mcp-client.md) — Model Context Protocol (MCP) integration and supported schema subset.
|
|
118
|
+
- [Migration from 0.15-era APIs](docs/migrations/0.15.md).
|
|
119
|
+
- [0.16 cleanup migration](docs/migrations/0.16.md).
|
|
120
|
+
- [Architecture Decision Records](docs/decisions/) — design rationale and superseding decisions.
|
|
121
|
+
- [CHANGELOG](CHANGELOG.md) — current development and recent release history.
|
|
122
|
+
- [Changelog archive: 0.14.0 and earlier](docs/changelog/0.14-and-earlier.md).
|
|
1269
123
|
|
|
1270
124
|
## Examples
|
|
1271
125
|
|
|
1272
|
-
Runnable examples covering
|
|
126
|
+
Runnable examples covering major features are maintained in the
|
|
1273
127
|
[phronomy-examples](https://github.com/Raizo-TCS/phronomy-examples) repository.
|
|
1274
128
|
|
|
1275
|
-
Each example lives in its own numbered directory and can be run with:
|
|
1276
|
-
|
|
1277
|
-
```bash
|
|
1278
|
-
bundle exec ruby NN_example_name/run.rb
|
|
1279
|
-
```
|
|
1280
|
-
|
|
1281
|
-
| # | Directory | What it demonstrates |
|
|
1282
|
-
|---|-----------|----------------------|
|
|
1283
|
-
| 01 | `01_basic_chain/` | PromptTemplate → LLMChain pipeline |
|
|
1284
|
-
| 02 | `02_react_agent/` | ReAct tool-calling agent |
|
|
1285
|
-
| 03 | `03_state_graph/` | Stateful workflow with wait_state/send_event |
|
|
1286
|
-
| 04 | `04_interrupt_resume/` | Human-in-the-loop wait_state and resume |
|
|
1287
|
-
| 05 | `05_multi_agent/` | Multi-agent coordination via Agent-as-Tool |
|
|
1288
|
-
| 06 | `06_guardrails/` | Input/output guardrails |
|
|
1289
|
-
| 07 | `07_tracing/` | Custom observability with Langfuse tracer |
|
|
1290
|
-
| 08 | `08_mcp_tool/` | MCP tool integration |
|
|
1291
|
-
| 10 | `10_context_management/` | Token budget and context pruning |
|
|
1292
|
-
| 11 | `11_agent_streaming/` | Streaming agent responses |
|
|
1293
|
-
| 12 | `12_prompt_template/` | Advanced prompt templates |
|
|
1294
|
-
| 13 | `13_mcp_http_tool/` | HTTP-based MCP tool server |
|
|
1295
|
-
| 14 | `14_code_review/` | Automated code review agent |
|
|
1296
|
-
| 17 | `17_multi_agent_handoff/` | Hub-and-spoke agent routing via Runner |
|
|
1297
|
-
|
|
1298
|
-
The following examples are **app-level demos** (Rails apps or advanced pipelines)
|
|
1299
|
-
that require additional infrastructure (a running Rails server, database, etc.):
|
|
1300
|
-
|
|
1301
|
-
| # | Directory | What it demonstrates |
|
|
1302
|
-
|---|-----------|----------------------|
|
|
1303
|
-
| 09 | `09_rails_chat/` | Rails chat app with ActionCable streaming |
|
|
1304
|
-
| 15 | `15_rails_secure_chat/` | Rails chat with PII guardrails |
|
|
1305
|
-
| 18 | `18_rails_agent_job/` | Rails app with AgentJob + ActionCable streaming |
|
|
1306
|
-
| 19 | `19_trust_pipeline/` | Generator-Verifier pattern with citation tracking, self-review loop and confidence gate |
|
|
1307
|
-
|
|
1308
129
|
## Development
|
|
1309
130
|
|
|
1310
|
-
After checking out the
|
|
131
|
+
After checking out the repository:
|
|
1311
132
|
|
|
1312
133
|
```bash
|
|
1313
134
|
bin/setup
|
|
1314
|
-
```
|
|
1315
|
-
|
|
1316
|
-
Run the unit test suite:
|
|
1317
|
-
|
|
1318
|
-
```bash
|
|
1319
135
|
bundle exec rspec spec/phronomy
|
|
1320
136
|
```
|
|
1321
137
|
|
|
1322
|
-
|
|
138
|
+
Integration tests can be run with:
|
|
1323
139
|
|
|
1324
140
|
```bash
|
|
1325
141
|
bundle exec rspec spec/integration --tag integration
|
|
1326
142
|
```
|
|
1327
143
|
|
|
1328
|
-
Launch an interactive console:
|
|
1329
|
-
|
|
1330
|
-
```bash
|
|
1331
|
-
bin/console
|
|
1332
|
-
```
|
|
1333
|
-
|
|
1334
144
|
## Contributing
|
|
1335
145
|
|
|
1336
|
-
Bug reports and pull requests are welcome
|
|
1337
|
-
|
|
1338
|
-
## Security & Privacy
|
|
1339
|
-
|
|
1340
|
-
**API credentials** — Phronomy does not store or transmit your LLM API keys. All
|
|
1341
|
-
credentials are handled by RubyLLM and passed directly to the provider.
|
|
1342
|
-
|
|
1343
|
-
**Tracing and PII** — When tracing is enabled (`Phronomy::Tracing::OpenTelemetryTracer`
|
|
1344
|
-
or a custom tracer), agent inputs and LLM outputs are replaced with `[REDACTED]` in
|
|
1345
|
-
span attributes by default (`trace_pii: false`). To include full content in traces
|
|
1346
|
-
(e.g., for debugging in a non-production environment), set `trace_pii: true` in your
|
|
1347
|
-
Phronomy configuration. Evaluate whether your tracing backend (OTLP collector, Jaeger,
|
|
1348
|
-
Honeycomb, etc.) meets your data-retention and privacy requirements.
|
|
1349
|
-
|
|
1350
|
-
**Prompt injection** — Phronomy provides `PromptInjectionFilter`, a built-in
|
|
1351
|
-
pattern-based input filter that detects common injection patterns (ignore/override
|
|
1352
|
-
instructions, role-switching phrases, etc.). It is a useful starting point, not a
|
|
1353
|
-
comprehensive defence; applications processing untrusted input should layer additional
|
|
1354
|
-
custom filters as needed (see the Filters section above).
|
|
146
|
+
Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
1355
147
|
|
|
1356
|
-
|
|
1357
|
-
writes, API calls, file deletion). Treat tool execution as a privileged operation:
|
|
1358
|
-
use the interrupt/approval mechanism for high-risk tools (e.g., payment processing,
|
|
1359
|
-
file deletion) rather than allowing fully autonomous execution. MCP servers are
|
|
1360
|
-
external trust boundaries: connect only to servers you control. A compromised MCP
|
|
1361
|
-
server can inject instructions that manipulate agent behavior (tool-level prompt
|
|
1362
|
-
injection). Avoid passing secrets as direct tool parameters — if `trace_pii: true`
|
|
1363
|
-
is set, tool arguments are captured in trace spans.
|
|
148
|
+
## Security and privacy
|
|
1364
149
|
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
150
|
+
- Provider credentials are handled by RubyLLM; Phronomy does not persist LLM API keys.
|
|
151
|
+
- Trace payloads are redacted by default when `trace_pii: false`.
|
|
152
|
+
- Tools and MCP servers are external trust boundaries; apply approval and application-specific policy to side-effecting capabilities.
|
|
153
|
+
- `PromptInjectionFilter` is a useful baseline, not a complete untrusted-input defence.
|
|
154
|
+
- Report vulnerabilities privately through GitHub Security Advisories rather than a public issue.
|
|
1368
155
|
|
|
1369
156
|
## License
|
|
1370
157
|
|
|
1371
|
-
The gem is available as open source under the terms of the
|
|
158
|
+
The gem is available as open source under the terms of the
|
|
159
|
+
[MIT License](https://opensource.org/licenses/MIT).
|