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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -1134
  3. data/README.md +68 -1280
  4. data/benchmark/bench_regression.rb +25 -5
  5. data/docs/changelog/0.14-and-earlier.md +1137 -0
  6. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  7. data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
  8. data/docs/features.md +87 -0
  9. data/docs/getting-started.md +351 -0
  10. data/docs/migrations/0.15.md +35 -0
  11. data/docs/migrations/0.16.md +43 -0
  12. data/docs/runtime-and-concurrency.md +258 -0
  13. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  14. data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
  15. data/lib/phronomy/agent/async_event_api.rb +3 -3
  16. data/lib/phronomy/agent/base.rb +35 -19
  17. data/lib/phronomy/agent/context/capability/base.rb +13 -3
  18. data/lib/phronomy/agent/execution_coordinator.rb +6 -6
  19. data/lib/phronomy/agent/shared_state.rb +2 -0
  20. data/lib/phronomy/agent/tool_executor.rb +29 -71
  21. data/lib/phronomy/agent/tool_invocation.rb +97 -47
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  23. data/lib/phronomy/configuration.rb +5 -29
  24. data/lib/phronomy/diagnostics.rb +12 -41
  25. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  26. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  27. data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
  28. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  29. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  31. data/lib/phronomy/engine/event_loop.rb +89 -190
  32. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  33. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  34. data/lib/phronomy/engine/runtime.rb +45 -158
  35. data/lib/phronomy/engine/task.rb +136 -277
  36. data/lib/phronomy/llm_adapter/base.rb +14 -14
  37. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  38. data/lib/phronomy/llm_adapter.rb +2 -2
  39. data/lib/phronomy/metrics.rb +15 -30
  40. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  41. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  42. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  43. data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
  44. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
  45. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  46. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  47. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  48. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  49. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  50. data/lib/phronomy/testing/eval/runner.rb +52 -0
  51. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  52. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  53. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  54. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  55. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  56. data/lib/phronomy/testing/eval.rb +9 -0
  57. data/lib/phronomy/testing/fake_clock.rb +6 -53
  58. data/lib/phronomy/testing.rb +2 -6
  59. data/lib/phronomy/tools/agent.rb +141 -6
  60. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  61. data/lib/phronomy/vector_store/base.rb +2 -2
  62. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  63. data/lib/phronomy/version.rb +1 -1
  64. data/lib/phronomy/workflow_runner.rb +2 -4
  65. data/lib/phronomy.rb +7 -4
  66. data/scripts/api_snapshot.rb +4 -4
  67. metadata +23 -31
  68. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  69. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  70. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  71. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  72. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  73. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  74. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  75. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  76. data/lib/phronomy/engine/task/backend.rb +0 -80
  77. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  78. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  79. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  80. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  81. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  82. data/lib/phronomy/engine/task_group.rb +0 -193
  83. data/lib/phronomy/eval/comparison.rb +0 -47
  84. data/lib/phronomy/eval/dataset.rb +0 -45
  85. data/lib/phronomy/eval/eval_case.rb +0 -17
  86. data/lib/phronomy/eval/eval_result.rb +0 -29
  87. data/lib/phronomy/eval/metrics.rb +0 -66
  88. data/lib/phronomy/eval/runner.rb +0 -94
  89. data/lib/phronomy/eval/scorer/base.rb +0 -22
  90. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  91. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  92. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  93. data/lib/phronomy/eval/scorer.rb +0 -9
  94. data/lib/phronomy/eval.rb +0 -7
  95. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  96. 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 inspired by open-source AI agent frameworks.
10
- It provides composable building blocks — Workflows, Agents, Tools, Filters, and Tracing — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
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
- ## Features
13
+ Phronomy is pre-1.0. Pin to a released gem version for production use rather than
14
+ tracking `main` directly.
13
15
 
14
- > **Stability labels** (phronomy is pre-1.0, so `0.x` minor releases may include
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
- **Core building blocks**
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
- | Feature | Stability |
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
- ### RubyLLM setup
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 the [RubyLLM documentation](https://rubyllm.com) for all supported providers. Phronomy does not add another LLM timeout or retry layer.
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
- | Removed Phronomy setting | Replacement |
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
- #### Streaming
212
-
213
- `stream` blocks the calling thread while delivering events; `stream_async` returns a `Task`
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
- agent = ResearchAgent.new
247
- agent.tool_approval_policy { :require_approval }
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
- > **Important**: Approval state is stored in-process (`AgentInvocationRegistry`). It is **not**
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
- app = Phronomy::Workflow.define(ReviewContext) do
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
- event.type == :state_completed
420
- event.payload == nil
421
- ```
422
-
423
- When several transitions have the same source and event, guards are evaluated
424
- in declaration order. The first matching transition is selected, and only that
425
- transition's action runs.
426
-
427
- ### Multi-Agent — Agent-as-Tool pattern
428
-
429
- Wrap sub-agents as `Agent::Context::Capability::Base` subclasses so the orchestrator LLM can call them on demand.
430
-
431
- ```ruby
432
- class ResearchTool < Phronomy::Agent::Context::Capability::Base
433
- description "Research a topic and return key findings as bullet points."
434
- param :topic, type: :string, desc: "The topic to research"
435
-
436
- def execute(topic:)
437
- ResearchAgent.new.invoke(topic)[:output]
438
- end
439
- end
440
-
441
- class WriterAgent < Phronomy::Agent::Base
442
- model "gpt-4o"
443
- instructions "You are a professional technical writer."
444
- end
445
-
446
- class WriteTool < Phronomy::Agent::Context::Capability::Base
447
- description "Write a technical blog post given research notes and a writing brief."
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 all major features are available in the
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 repo, install dependencies:
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
- Run the integration tests (requires a running LLM endpoint):
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 on GitHub at https://github.com/Raizo-TCS/phronomy.
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
- **Tool and MCP security** — Tools can perform real-world side effects (database
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
- **Vulnerability reports** Please report security vulnerabilities privately via
1366
- GitHub's [Security Advisories](https://github.com/Raizo-TCS/phronomy/security/advisories)
1367
- rather than opening a public issue.
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 [MIT License](https://opensource.org/licenses/MIT).
158
+ The gem is available as open source under the terms of the
159
+ [MIT License](https://opensource.org/licenses/MIT).