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