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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. 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 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** — 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
- ### RubyLLM setup
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 the [RubyLLM documentation](https://rubyllm.com) for all supported providers. Phronomy does not add another LLM timeout or retry layer.
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
- | Removed Phronomy setting | Replacement |
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 WebSearch
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
- #### Streaming
191
-
192
- `stream` blocks the calling thread while delivering events; `stream_async` returns a `Task`
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
- Phronomy.configure do |c|
896
- c.default_model = "gpt-4o-mini"
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
- `c.logger` receives framework diagnostic messages (e.g. unreachable-state warnings from
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 provides both synchronous and asynchronous invocation APIs.
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
- Canonical Journal
1087
-
1088
- Context Policy
1089
-
1090
- LLM Call Manifest
1091
-
1092
- Runtime Projection
1093
-
1094
- RubyLLM / Provider
1095
- ```
1096
-
1097
- The **Journal** is the canonical append-only record of logical execution facts observed by Phronomy.
1098
-
1099
- The **Manifest** is the canonical logical input fixed for one particular LLM Call.
1100
-
1101
- Context-window management therefore does not trim or rewrite the Agent's canonical history. Instead, Phronomy selects the subset of available context needed for each LLM Call and records that selection in the Manifest.
1102
-
1103
- This distinction allows old history to remain available even when it does not fit in the current model's context window.
1104
-
1105
- Tool protocol dependencies are preserved during selection. For example, an assistant message containing Tool Calls and the corresponding Tool-role messages are selected as a protocol-safe unit rather than independently pruning messages in a way that would create an invalid LLM conversation.
1106
-
1107
- When the available budget is insufficient, optional historical context can be omitted from the current Manifest. Required context is never silently removed merely to satisfy the budget. If the required input cannot fit, Phronomy raises `ContextBudgetExceededError`.
1108
-
1109
- ### Context-window configuration
1110
-
1111
- Phronomy derives the effective context budget from RubyLLM model metadata when available.
1112
-
1113
- For local or otherwise unregistered models, the context window can be declared explicitly:
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 all major features are available in the
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 repo, install dependencies:
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
- Run the integration tests (requires a running LLM endpoint):
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 on GitHub at https://github.com/Raizo-TCS/phronomy.
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
- **Tool and MCP security** — Tools can perform real-world side effects (database
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
- **Vulnerability reports** Please report security vulnerabilities privately via
1334
- GitHub's [Security Advisories](https://github.com/Raizo-TCS/phronomy/security/advisories)
1335
- 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.
1336
155
 
1337
156
  ## License
1338
157
 
1339
- 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).