phronomy 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +22 -0
  3. data/CHANGELOG.md +488 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +374 -36
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/Rakefile +33 -0
  8. data/SECURITY.md +80 -0
  9. data/benchmark/baseline.json +9 -0
  10. data/benchmark/bench_agent_invoke.rb +105 -0
  11. data/benchmark/bench_context_assembler.rb +46 -0
  12. data/benchmark/bench_regression.rb +172 -0
  13. data/benchmark/bench_token_estimator.rb +44 -0
  14. data/benchmark/bench_tool_schema.rb +69 -0
  15. data/benchmark/bench_vector_store.rb +39 -0
  16. data/benchmark/bench_workflow.rb +55 -0
  17. data/benchmark/run_all.rb +118 -0
  18. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  19. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  20. data/docs/decisions/003-event-loop-singleton.md +48 -0
  21. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
  22. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  23. data/docs/decisions/006-no-built-in-guardrails.md +66 -0
  24. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  25. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  26. data/docs/decisions/009-state-store-abstraction.md +141 -0
  27. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  28. data/lib/phronomy/agent/base.rb +416 -49
  29. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  30. data/lib/phronomy/agent/checkpoint.rb +1 -0
  31. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  32. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  33. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  34. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  35. data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
  36. data/lib/phronomy/agent/fsm.rb +44 -52
  37. data/lib/phronomy/agent/handoff.rb +3 -0
  38. data/lib/phronomy/agent/orchestrator.rb +191 -54
  39. data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
  40. data/lib/phronomy/agent/react_agent.rb +16 -6
  41. data/lib/phronomy/agent/runner.rb +2 -0
  42. data/lib/phronomy/agent/shared_state.rb +11 -0
  43. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  44. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  45. data/lib/phronomy/async_queue.rb +155 -0
  46. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  47. data/lib/phronomy/cancellation_scope.rb +123 -0
  48. data/lib/phronomy/cancellation_token.rb +133 -0
  49. data/lib/phronomy/concurrency_gate.rb +155 -0
  50. data/lib/phronomy/configuration.rb +168 -2
  51. data/lib/phronomy/context/assembler.rb +6 -0
  52. data/lib/phronomy/context/compaction_context.rb +2 -0
  53. data/lib/phronomy/context/context_version_cache.rb +2 -0
  54. data/lib/phronomy/context/token_budget.rb +3 -0
  55. data/lib/phronomy/context/token_estimator.rb +9 -2
  56. data/lib/phronomy/context/trigger_context.rb +1 -0
  57. data/lib/phronomy/context/trim_context.rb +4 -0
  58. data/lib/phronomy/deadline.rb +63 -0
  59. data/lib/phronomy/diagnostics.rb +62 -0
  60. data/lib/phronomy/embeddings/base.rb +22 -2
  61. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  62. data/lib/phronomy/eval/comparison.rb +2 -0
  63. data/lib/phronomy/eval/dataset.rb +4 -0
  64. data/lib/phronomy/eval/metrics.rb +6 -0
  65. data/lib/phronomy/eval/runner.rb +11 -9
  66. data/lib/phronomy/eval/scorer/base.rb +1 -0
  67. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  68. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  69. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  70. data/lib/phronomy/event_loop.rb +275 -30
  71. data/lib/phronomy/fsm_session.rb +57 -4
  72. data/lib/phronomy/generator_verifier.rb +2 -0
  73. data/lib/phronomy/guardrail/base.rb +3 -0
  74. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  75. data/lib/phronomy/invocation_context.rb +152 -0
  76. data/lib/phronomy/knowledge_source/base.rb +24 -2
  77. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  78. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  79. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  80. data/lib/phronomy/llm_adapter/base.rb +104 -0
  81. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  82. data/lib/phronomy/llm_adapter.rb +20 -0
  83. data/lib/phronomy/loader/base.rb +1 -0
  84. data/lib/phronomy/loader/csv_loader.rb +2 -0
  85. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  86. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  87. data/lib/phronomy/metrics.rb +38 -0
  88. data/lib/phronomy/output_parser/base.rb +1 -0
  89. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  90. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  91. data/lib/phronomy/prompt_template.rb +5 -0
  92. data/lib/phronomy/runnable.rb +20 -3
  93. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  94. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  95. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  96. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  97. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  98. data/lib/phronomy/runtime/scheduler.rb +98 -0
  99. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  100. data/lib/phronomy/runtime/task_registry.rb +48 -0
  101. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  102. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  103. data/lib/phronomy/runtime/timer_service.rb +42 -0
  104. data/lib/phronomy/runtime.rb +374 -0
  105. data/lib/phronomy/splitter/base.rb +2 -0
  106. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  107. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  108. data/lib/phronomy/state_store/base.rb +48 -0
  109. data/lib/phronomy/state_store/in_memory.rb +62 -0
  110. data/lib/phronomy/task/backend.rb +80 -0
  111. data/lib/phronomy/task/fiber_backend.rb +157 -0
  112. data/lib/phronomy/task/immediate_backend.rb +89 -0
  113. data/lib/phronomy/task/thread_backend.rb +84 -0
  114. data/lib/phronomy/task.rb +275 -0
  115. data/lib/phronomy/task_group.rb +265 -0
  116. data/lib/phronomy/testing/fake_clock.rb +109 -0
  117. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  118. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  119. data/lib/phronomy/testing.rb +12 -0
  120. data/lib/phronomy/tool/agent_tool.rb +1 -0
  121. data/lib/phronomy/tool/base.rb +298 -28
  122. data/lib/phronomy/tool/mcp_tool.rb +103 -17
  123. data/lib/phronomy/tool/scope_policy.rb +50 -0
  124. data/lib/phronomy/tool_executor.rb +106 -0
  125. data/lib/phronomy/tracing/base.rb +3 -0
  126. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  127. data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
  128. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  129. data/lib/phronomy/vector_store/base.rb +40 -7
  130. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  131. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  132. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  133. data/lib/phronomy/version.rb +1 -1
  134. data/lib/phronomy/workflow.rb +147 -11
  135. data/lib/phronomy/workflow_context.rb +83 -6
  136. data/lib/phronomy/workflow_runner.rb +106 -7
  137. data/lib/phronomy.rb +112 -1
  138. data/scripts/api_snapshot.rb +91 -0
  139. data/scripts/check_api_annotations.rb +68 -0
  140. data/scripts/check_private_enforcement.rb +93 -0
  141. data/scripts/check_readme_runnable.rb +98 -0
  142. data/scripts/run_mutation.sh +46 -0
  143. metadata +83 -2
data/README.md CHANGED
@@ -1,35 +1,102 @@
1
1
  # Phronomy
2
2
 
3
+ > **⚠️ Development Notice**
4
+ > This project is primarily developed and maintained by **AI coding agents**.
5
+ > As a result, `main` receives frequent, large, and unannounced changes.
6
+ > External contributors should expect significant churn and potential conflicts at any time.
7
+ > We apologise for the instability this may cause.
8
+
3
9
  **Phronomy** is a Ruby AI agent framework inspired by open-source AI agent frameworks.
4
10
  It provides composable building blocks — Workflows, Agents, Tools, Guardrails, RAG, and Tracing — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
5
11
 
6
12
  ## Features
7
13
 
8
- > **Stability labels**: `Stable` production-ready, semver-protected API.
9
- > `Beta` functional but the API may change in a minor release.
10
- > `Experimental` — subject to breaking changes without notice.
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.
24
+
25
+ **Core building blocks**
11
26
 
12
27
  | Feature | Stability |
13
28
  |---|---|
14
29
  | **Workflow** — Stateful, branching workflows with wait_state/send_event | Stable |
15
- | **Workflow EventLoop Mode** — Opt-in event-driven execution: `Phronomy.configure { \|c\| c.event_loop = true }` | Experimental |
16
- | **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#run_as_child` (child-FSM pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
17
- | **Workflow Parallel Node** — Concurrent branches via application-level threads | Beta |
30
+ | **Workflow action_timeout** — Per-state `action_timeout:` keyword on `state` DSL; cancels Task-returning entry actions that exceed the limit and raises `Phronomy::ActionTimeoutError` | Beta |
18
31
  | **Agent** — ReAct-style tool-calling agents with guardrails and conversation history | Stable |
19
32
  | **Before-Completion Hook** — Three-tier LLM parameter injection | Stable |
20
33
  | **Context Management** — Token budget calculation, estimation, and pruning | Stable |
21
- | **Knowledge/RAG** — Retrieval sources with pluggable loaders, splitters, and vector stores | Beta |
22
- | **Multi-agent** — Agent-as-Tool pattern and hub-and-spoke handoff routing | Beta |
23
- | **GeneratorVerifier** — Generator-Verifier loop with injectable prompt builders/parsers | Beta |
24
- | **Agent::Orchestrator** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
25
- | **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + stateful worker pool with task queue (worker-local message history per run) | Beta |
26
- | **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
27
34
  | **Guardrails** — Input/output validation with custom `InputGuardrail`/`OutputGuardrail` | Beta |
35
+ | **`PromptInjectionGuardrail`** — Built-in `InputGuardrail` subclass that detects prompt-injection patterns; usable standalone or as part of a guardrail chain | Beta |
36
+ | **`Tool::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 |
28
37
  | **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
29
38
  | **Eval Framework** — Dataset-driven evaluation with multiple scorer types | Beta |
30
39
  | **Tracing** — Pluggable span-based observability | Stable |
40
+ | **Error Taxonomy** — `RateLimitError`, `AuthenticationError`, `ContextLengthError`, `TransportError` (subclasses of `Phronomy::Error`) raised at the agent retry boundary | Beta |
41
+
42
+ **Knowledge and integration**
43
+
44
+ | Feature | Stability |
45
+ |---|---|
46
+ | **Knowledge/RAG** — Retrieval sources with pluggable loaders, splitters, and vector stores; `static_knowledge_refresh!` for runtime cache invalidation | Beta |
47
+ | **`VectorStore#size`** — Returns document count for all three backends (InMemory, RedisSearch, Pgvector) | Beta |
48
+ | **`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 |
49
+ | **Parallel RAG multi-source fetch** — `Agent#build_context` fetches all `knowledge_sources` concurrently via `TaskGroup`; `config[:rag_failure_policy]` `:skip` (default) silently ignores failed sources so the agent answers with partial context, `:fail` surfaces the first error; per-source latency is emitted to `Phronomy.configuration.logger` at debug level | Beta |
31
50
  | **MCP Tool** — Model Context Protocol server integration | Beta |
32
51
 
52
+ **Execution and reliability**
53
+
54
+ | Feature | Stability |
55
+ |---|---|
56
+ | **Workflow EventLoop Mode** — Opt-in event-driven execution: `Phronomy.configure { \|c\| c.event_loop = true }` | Experimental |
57
+ | **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#run_as_child` (child-FSM pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
58
+ | **`invoke_async` / `call_async`** — `Agent::Base#invoke_async` and `Workflow#invoke_async` return a `Task`; `Tool::Base#call_async` similarly; compatible with EventLoop and standalone contexts | Experimental |
59
+ | **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 | Experimental |
60
+ | **`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 |
61
+ | **`execution_mode` DSL on `Tool::Base`** — Declares how a tool's `execute` should be dispatched: `:cooperative` (same scheduler thread), `:blocking_io` (default; offloaded to `BlockingAdapterPool`), `:cpu_bound`, `:external_process` | Experimental |
62
+ | **`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 |
63
+ | **`ConcurrencyGate` — unified backpressure** — Counting semaphore that enforces per-resource concurrency caps (`max_concurrent_agent_tasks`, `max_concurrent_tool_tasks`, `max_concurrent_workflow_tasks`, `max_concurrent_llm_calls`, `max_concurrent_rag_fetches`, `max_concurrent_vector_searches`); configured via `Phronomy.configure`; backpressure behaviour follows the global `backpressure` setting (`:wait`, `:raise`/`:reject`, `:timeout`); `nil` cap = unlimited (default) | 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 and pool counters; task-centric keys: `active_agent_tasks`, `active_tool_tasks`, `active_workflow_tasks`, `active_rag_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`; pool/event-loop keys remain for backward compatibility; `Runtime#task_snapshot` exposes task-centric metrics directly | Beta |
66
+ | **`Phronomy.with_configuration` / `Phronomy.reset_runtime!`** — Scoped configuration override and full runtime reset for test isolation | Beta |
67
+
68
+ **Agent patterns**
69
+
70
+ | Feature | Stability |
71
+ |---|---|
72
+ | **Workflow parallel pattern** — Concurrent branches via application-level threads (no built-in parallel primitive; see the Workflow section for the recommended pattern) | Beta |
73
+ | **Multi-agent** — Agent-as-Tool pattern and hub-and-spoke handoff routing | Beta |
74
+ | **GeneratorVerifier** — Generator-Verifier loop with injectable prompt builders/parsers | Beta |
75
+ | **Agent::Orchestrator** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
76
+ | **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + stateful workers with sequential task assignment (worker-local message history persisted across tasks) | Beta |
77
+ | **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
78
+ | **`ScopePolicy`** — Configurable policy callable that maps (tool, scope, agent) to `:allow`/`:approve`/`:reject`; default policy auto-routes high-risk scopes through the approval gate | Experimental |
79
+
80
+ > **Public API boundary**: The tables above are the complete list of classes, modules, and features
81
+ > intended for gem consumers. Every entry has an associated stability label.
82
+ > All other classes, modules, and methods — including everything in the
83
+ > [Advanced / Internal APIs](#advanced--internal-apis) section below — are
84
+ > marked `@api private` in source and may change without notice. Do not
85
+ > depend on internal APIs in application code.
86
+
87
+ ## Advanced / Internal APIs
88
+
89
+ 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.
90
+
91
+ > These APIs are subject to change without the same backwards-compatibility guarantees as the stable public API.
92
+
93
+ | Feature | Stability |
94
+ |---|---|
95
+ | **`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 |
96
+ | **`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 |
97
+ | **`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 |
98
+ | **`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 |
99
+
33
100
  ## Installation
34
101
 
35
102
  Add to your Gemfile:
@@ -44,17 +111,42 @@ Then run:
44
111
  bundle install
45
112
  ```
46
113
 
114
+ ### RubyLLM setup
115
+
116
+ Phronomy uses [RubyLLM](https://github.com/crmne/ruby_llm) for LLM access.
117
+ Configure your provider credentials before using agents or chains:
118
+
119
+ ```ruby
120
+ RubyLLM.configure do |c|
121
+ c.openai_api_key = ENV["OPENAI_API_KEY"]
122
+ # c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
123
+ end
124
+ ```
125
+
126
+ See the [RubyLLM documentation](https://rubyllm.com) for all supported providers.
127
+
128
+ ### Optional dependencies
129
+
130
+ Install additional gems only for the features you use:
131
+
132
+ | Gem | Required for |
133
+ |-----|-------------|
134
+ | `pgvector` | `Phronomy::VectorStore::Pgvector` |
135
+ | `redis` | `Phronomy::VectorStore::RedisSearch` |
136
+ | `opentelemetry-api` | `Phronomy::Tracing::OpenTelemetryTracer` |
137
+
47
138
  ## Quick Start
48
139
 
49
140
  ### Agent — ReAct tool-calling agent
50
141
 
51
- ```ruby
142
+ ```ruby runnable
52
143
  class WebSearch < Phronomy::Tool::Base
53
144
  description "Search the web"
54
145
  param :query, type: :string, desc: "Search query"
55
146
 
56
147
  def execute(query:)
57
- # ... call a search API
148
+ # Replace with a real search API call (e.g., SerpAPI, Tavily)
149
+ "Mock search result for: #{query}"
58
150
  end
59
151
  end
60
152
 
@@ -71,7 +163,7 @@ puts result[:output]
71
163
 
72
164
  ### Workflow — Stateful workflow with wait_state/send_event
73
165
 
74
- ```ruby
166
+ ```ruby runnable
75
167
  class ReviewContext
76
168
  include Phronomy::WorkflowContext
77
169
  field :draft, type: :replace
@@ -79,10 +171,14 @@ class ReviewContext
79
171
  field :approved, type: :replace, default: false
80
172
  end
81
173
 
174
+ # Placeholder callables representing your own implementation
175
+ write_draft = ->(state) { state.merge(draft: "Draft content here") }
176
+ review_draft = ->(state) { state.merge(feedback: "Feedback on: #{state.draft}") }
177
+
82
178
  app = Phronomy::Workflow.define(ReviewContext) do
83
179
  initial :write
84
- state :write, action: ->(s) { s.merge(draft: Writer.call(s)) }
85
- state :review, action: ->(s) { s.merge(feedback: Reviewer.call(s.draft)) }
180
+ state :write, action: write_draft
181
+ state :review, action: review_draft
86
182
  wait_state :awaiting_approval # halts here for human decision
87
183
  state :finalize, action: ->(s) { s.merge(approved: true) }
88
184
  transition from: :write, to: :review
@@ -102,6 +198,22 @@ final = app.send_event(state: state, event: :approve)
102
198
  puts "Approved: #{final.approved}" # => true
103
199
  ```
104
200
 
201
+ In EventLoop mode (`c.event_loop = true`), `Agent#run_as_child` spawns a child agent
202
+ asynchronously. When the child succeeds, `:child_completed` is dispatched with the result
203
+ `{ output:, messages:, usage: }` as its payload; when it fails, `:child_failed` is
204
+ dispatched. Always declare both transitions to avoid a stuck workflow:
205
+
206
+ ```ruby
207
+ # EventLoop mode: workflow that runs an agent as a child FSM.
208
+ # The result { output:, messages:, usage: } arrives as the :child_completed event
209
+ # payload — write it back to the context in the target state's entry action.
210
+ entry :run_agent, ->(ctx) {
211
+ MyAgent.new.run_as_child(ctx.query, ctx: ctx)
212
+ }
213
+ transition from: :run_agent, on: :child_completed, to: :done
214
+ transition from: :run_agent, on: :child_failed, to: :handle_error
215
+ ```
216
+
105
217
  ### Multi-Agent — Agent-as-Tool pattern
106
218
 
107
219
  Wrap sub-agents as `Tool::Base` subclasses so the orchestrator LLM can call them on demand.
@@ -116,6 +228,11 @@ class ResearchTool < Phronomy::Tool::Base
116
228
  end
117
229
  end
118
230
 
231
+ class WriterAgent < Phronomy::Agent::Base
232
+ model "gpt-4o"
233
+ instructions "You are a professional technical writer."
234
+ end
235
+
119
236
  class WriteTool < Phronomy::Tool::Base
120
237
  description "Write a technical blog post given research notes and a writing brief."
121
238
  param :instructions, type: :string, desc: "Writing brief including research notes"
@@ -137,6 +254,9 @@ puts result[:output]
137
254
 
138
255
  ### Guardrails — Input/output validation
139
256
 
257
+ Call `fail!(reason)` inside `check` to reject — it raises `Phronomy::GuardrailError`.
258
+ When a guardrail rejects, `invoke` raises instead of returning an output.
259
+
140
260
  ```ruby
141
261
  class NoSensitiveDataGuardrail < Phronomy::Guardrail::InputGuardrail
142
262
  def check(input)
@@ -146,8 +266,20 @@ end
146
266
 
147
267
  agent = ResearchAgent.new
148
268
  agent.add_input_guardrail(NoSensitiveDataGuardrail.new)
269
+
270
+ begin
271
+ agent.invoke("Charge 4111-1111-1111-1111")
272
+ rescue Phronomy::GuardrailError => e
273
+ puts e.message # => "Credit card numbers are not allowed"
274
+ end
149
275
  ```
150
276
 
277
+ > **Note:** Phronomy includes `PromptInjectionGuardrail`, a built-in pattern-based
278
+ > input guardrail that detects common injection patterns (see the feature table above).
279
+ > PII scanning and content classification are **not** provided by the framework;
280
+ > that logic must be implemented by the application. Reference implementations for
281
+ > common patterns are available in `phronomy-examples` (example 06).
282
+
151
283
  ### Knowledge/RAG — Context injection and vector retrieval
152
284
 
153
285
  ```ruby
@@ -161,6 +293,13 @@ policy = Phronomy::KnowledgeSource::StaticKnowledge.new(
161
293
  # RAG retrieval from a vector store
162
294
  store = Phronomy::VectorStore::InMemory.new
163
295
  embeddings = Phronomy::Embeddings::RubyLLMEmbeddings.new(model: "text-embedding-3-small")
296
+
297
+ # Add documents before querying
298
+ text1 = "Refunds are processed within 5 business days."
299
+ text2 = "Contact support@example.com for refund requests."
300
+ store.add(id: "doc-1", embedding: embeddings.embed(text1), metadata: { content: text1, source: "policy.md" })
301
+ store.add(id: "doc-2", embedding: embeddings.embed(text2), metadata: { content: text2, source: "policy.md" })
302
+
164
303
  rag = Phronomy::KnowledgeSource::RAGKnowledge.new(store: store, embeddings: embeddings, k: 5)
165
304
 
166
305
  # Inject at invocation time
@@ -168,6 +307,15 @@ result = MyAgent.new.invoke("What is the refund policy?",
168
307
  config: { knowledge_sources: [policy, rag] })
169
308
  ```
170
309
 
310
+ `static_knowledge_refresh!` invalidates the class-level cache of *static* knowledge sources
311
+ (not RAG stores). Call it when the underlying file or content has changed:
312
+
313
+ ```ruby
314
+ # Static knowledge sources are cached at the class level after the first fetch.
315
+ # Call refresh! when the underlying content changes (e.g. after reloading policy.md).
316
+ MyAgent.static_knowledge_refresh!
317
+ ```
318
+
171
319
  Load and split documents with built-in loaders:
172
320
 
173
321
  ```ruby
@@ -211,7 +359,7 @@ Phronomy.configure do |c|
211
359
  end
212
360
  ```
213
361
 
214
- Hooks are called in order — global → class → instance — and deep-merged.
362
+ Hooks are called in order — global → class → instance — and shallow-merged (`Hash#merge`; last hook wins on key conflicts).
215
363
 
216
364
  ### GeneratorVerifier — Generator-Verifier loop with custom prompt builders
217
365
 
@@ -290,19 +438,21 @@ class MyOrchestrator < Phronomy::Agent::Orchestrator
290
438
  instructions "Orchestrate."
291
439
 
292
440
  def run(query)
293
- # Heterogeneous agents in parallel (cap at 4 threads; skip failures)
441
+ # Heterogeneous agents in parallel (cap at 4 threads; skip failures; 30 s timeout)
294
442
  results = dispatch_parallel(
295
443
  {agent: SearchAgent, input: "topic A"},
296
444
  {agent: AnalysisAgent, input: query},
297
445
  max_concurrency: 4,
298
- on_error: :skip
446
+ on_error: :skip,
447
+ timeout: 30
299
448
  )
300
449
 
301
450
  # Fan-out — same agent, multiple inputs
302
451
  translations = fan_out(
303
452
  agent: TranslationAgent,
304
453
  inputs: %w[Hello World],
305
- max_concurrency: 2
454
+ max_concurrency: 2,
455
+ timeout: 20
306
456
  )
307
457
 
308
458
  results.compact.map { |r| r[:output] }.join("\n")
@@ -310,9 +460,11 @@ class MyOrchestrator < Phronomy::Agent::Orchestrator
310
460
  end
311
461
  ```
312
462
 
313
- ### Workflow Parallel Node — Concurrent branches
463
+ ### Workflow parallel pattern — Concurrent branches
314
464
 
315
- Phronomy does not provide a built-in parallel abstraction. Use application-level Ruby threads inside a `state` action:
465
+ Phronomy does not provide a dedicated parallel-node primitive. The recommended
466
+ pattern for concurrent branches is to use application-level Ruby threads inside
467
+ a `state` action:
316
468
 
317
469
  ```ruby
318
470
  class EnrichContext
@@ -324,13 +476,16 @@ end
324
476
  app = Phronomy::Workflow.define(EnrichContext) do
325
477
  initial :enrich
326
478
  state :enrich, action: ->(s) do
327
- results = {}
328
- threads = [
329
- Thread.new { results[:summary] = Summarizer.call(s) },
330
- Thread.new { results[:tags] = Tagger.call(s) }
331
- ]
332
- threads.each { |t| t.join(10) } # 10-second timeout
333
- s.merge(summary: results[:summary], tags: Array(results[:tags]))
479
+ # Use Thread#value to collect results safely — avoids concurrent Hash writes
480
+ threads = {
481
+ summary: Thread.new { Summarizer.call(s) },
482
+ tags: Thread.new { Tagger.call(s) }
483
+ }
484
+ # For bounded waits, use Thread#join(timeout_seconds); nil means timed out — handle explicitly.
485
+ # Do not use Timeout.timeout or Thread#kill — both inject async exceptions that bypass cleanup.
486
+ # Prefer CancellationToken for cooperative cancellation of Phronomy-managed tasks.
487
+ threads.each_value(&:join)
488
+ s.merge(summary: threads[:summary].value, tags: Array(threads[:tags].value))
334
489
  end
335
490
  transition from: :enrich, to: :__finish__
336
491
  end
@@ -419,22 +574,100 @@ puts result2[:output] # => "Your name is Alice."
419
574
  `result[:messages]` contains the complete message history after each invocation.
420
575
  Persist it however suits your application (in-memory hash, Redis, ActiveRecord, etc.).
421
576
 
577
+ > **Note on `thread_id`**: `thread_id` is a correlation identifier used internally for
578
+ > checkpoint/compaction context and EventLoop routing. It does **not** automatically persist or
579
+ > restore conversation history — you must pass `messages:` explicitly on each turn as shown above.
580
+
422
581
 
423
582
  ## Configuration
424
583
 
425
584
  ```ruby
426
585
  Phronomy.configure do |c|
427
- c.default_model = "gpt-4o-mini"
428
- c.recursion_limit = 25
429
- c.tracer = Phronomy::Tracing::NullTracer.new
430
- c.before_completion = nil # optional; global hook lambda
586
+ c.default_model = "gpt-4o-mini"
587
+ c.recursion_limit = 25
588
+ c.tracer = Phronomy::Tracing::NullTracer.new
589
+ c.before_completion = nil # optional; global hook lambda
590
+ c.trace_pii = false # default; set to true only when trace data contains no PII
591
+ c.logger = nil # optional; any object responding to #warn (e.g. Rails.logger)
592
+ c.event_loop_stop_grace_seconds = 5 # seconds to wait for sessions to drain on EventLoop#stop(drain: true)
593
+ c.runtime_backend = :thread # :thread (default); :immediate (tests, synchronous); :fiber (experimental validation only); :cooperative (deprecated alias for :immediate)
594
+ c.strict_runtime_guards = false # when true, raises on invoke-inside-task
595
+ end
596
+ ```
597
+
598
+ `c.logger` receives framework diagnostic messages (e.g. unreachable-state warnings from
599
+ `Workflow.define`). When `nil` (default), messages are written to `$stderr` via `Kernel#warn`.
600
+
601
+ > **Note**: When `trace_pii = false`, both the _input_ and the _output_ (LLM
602
+ > responses and tool results) are replaced with `[REDACTED]` in trace spans.
603
+ > The default is `false` (PII protection enabled). Set to `true` only when
604
+ > trace data does not contain sensitive information.
605
+
606
+ ## Sync vs Async API
607
+
608
+ Phronomy provides both synchronous and asynchronous invocation APIs.
609
+ Understanding when to use each prevents scheduler stalls and hidden deadlocks.
610
+
611
+ | Context | Recommended API |
612
+ |---------|----------------|
613
+ | Top-level application code, Rails controller, background job | `agent.invoke(input)` — blocks the calling thread until done |
614
+ | Inside a `Runtime#spawn` block, `TaskGroup`, Workflow action, Tool `execute` | `agent.invoke_async(input).await` — non-blocking within the scheduler |
615
+
616
+ ### Why this matters
617
+
618
+ `invoke` is a synchronous wrapper that calls `invoke_async` and then _blocks_ the calling
619
+ thread until the task completes. When called from **inside** an active scheduler task, the
620
+ calling task blocks the scheduler thread, preventing other tasks from making progress — a
621
+ hidden deadlock when all scheduler threads are occupied.
622
+
623
+ ### Runtime guard
624
+
625
+ Phronomy detects this pattern automatically:
626
+
627
+ ```ruby
628
+ # Default (soft mode): logs a warning and continues
629
+ Phronomy.configure { |c| c.strict_runtime_guards = false }
630
+
631
+ # Strict mode: raises SchedulerReentrancyError immediately
632
+ Phronomy.configure { |c| c.strict_runtime_guards = true }
633
+ ```
634
+
635
+ You can also query the current context directly:
636
+
637
+ ```ruby
638
+ Phronomy::Runtime.in_scheduler_context? # => true if called from inside a task
639
+ ```
640
+
641
+ ### Migration: invoke → invoke_async
642
+
643
+ ```ruby
644
+ # Before (blocks scheduler if called from inside a task)
645
+ result = my_agent.invoke("Hello")
646
+
647
+ # After (safe inside tasks and TaskGroups)
648
+ result = my_agent.invoke_async("Hello").await
649
+ ```
650
+
651
+ ### :immediate backend (synchronous / test mode)
652
+
653
+ The `:immediate` backend runs tasks synchronously using `FakeScheduler`
654
+ (backed by `Task::ImmediateBackend`). Blocking I/O is isolated in `BlockingAdapterPool`.
655
+ To switch back to the default thread-per-task backend:
656
+
657
+ ```ruby
658
+ Phronomy.configure { |c| c.runtime_backend = :thread }
659
+ # or per-example using SchedulerHelpers:
660
+ include Phronomy::Testing::SchedulerHelpers
661
+ with_fake_scheduler do |sched|
662
+ # all spawns run synchronously; sched.event_log records every lifecycle event
431
663
  end
432
664
  ```
433
665
 
434
666
  ## Context Management
435
667
 
436
- Phronomy includes a context window management layer so agents automatically
437
- stay within the token limits of the underlying model.
668
+ Phronomy includes a context window management layer. When model metadata is
669
+ available (either from the built-in registry or via an explicit `context_window:` setting),
670
+ agents automatically stay within the configured token limit.
438
671
 
439
672
  ### TokenBudget
440
673
 
@@ -466,12 +699,86 @@ class MyAgent < Phronomy::Agent::Base
466
699
  model "gpt-4o"
467
700
  max_output_tokens 4096 # override max_output_tokens from registry
468
701
  context_overhead 600 # extra reservation for system prompt + tools
702
+ invoke_timeout 30 # raise Phronomy::TimeoutError after 30 s (wait timeout, not cancellation)
703
+ max_parallel_tools 4 # cap concurrent tool executions (default: 10)
469
704
  end
470
705
  ```
471
706
 
472
707
  `Agent::Base#invoke` builds a `TokenBudget` automatically. When the model is not in the
473
708
  registry the budget is silently skipped.
474
709
 
710
+ > **Note on CJK languages**: The default `TokenEstimator` uses a character-ratio heuristic
711
+ > calibrated for ASCII/Latin text (4 chars/token). For Chinese, Japanese, and Korean text,
712
+ > actual token counts are approximately **4× higher** than the estimate because CJK
713
+ > characters are typically 1 token each. For accurate CJK token counting, supply a
714
+ > tokenizer-backed callable:
715
+ >
716
+ > ```ruby
717
+ > require "tiktoken_ruby"
718
+ > enc = Tiktoken.encoding_for_model("gpt-4o")
719
+ > Phronomy::Context::TokenEstimator.tokenizer = ->(text) { enc.encode(text).length }
720
+ > ```
721
+
722
+
723
+ ### CancellationToken — Cooperative cancellation
724
+
725
+ Pass a `CancellationToken` to any agent via `config: { cancellation_token: token }`.
726
+ Cancellation is checked at multiple granular checkpoints: before the LLM call, before
727
+ each RAG knowledge-source fetch, after each streaming chunk, before each parallel
728
+ tool-call batch, and after each `before_completion` hook. `CancellationError` is
729
+ raised immediately and is never retried. No threads are force-killed — `ensure`
730
+ blocks always execute.
731
+
732
+ > **Cooperative cancellation — not preemptive**
733
+ >
734
+ > Phronomy uses _cooperative boundary cancellation_. The token is polled at the
735
+ > checkpoints listed above; it is **not** injected as a signal into a running
736
+ > operation. This means the following are **not** interrupted mid-execution:
737
+ >
738
+ > - A single `KnowledgeSource#fetch` that is already blocking (e.g. HTTP call)
739
+ > - A single `chat.ask` call that is not streaming
740
+ > - A single `tool.execute` call that is already running
741
+ > - Any external I/O (database query, vector search, HTTP request) inside those calls
742
+ >
743
+ > For deep in-flight safety, complement `CancellationToken` with per-source or
744
+ > per-tool timeouts. Prefer library-native timeouts such as `Net::HTTP#read_timeout`,
745
+ > database `statement_timeout`, or Redis client timeout — these signal the I/O layer
746
+ > to abort cleanly. Avoid `Timeout.timeout` unless you understand its async-exception
747
+ > risks: it injects `Timeout::Error` at an arbitrary execution point (the same
748
+ > mechanism as `Thread#kill`), which Phronomy avoids by default due to resource
749
+ > safety concerns. Ruby's GVL prevents fully preemptive cancellation without such
750
+ > risky interruption.
751
+
752
+ ```ruby
753
+ token = Phronomy::CancellationToken.new
754
+
755
+ # Cancel from another thread after 5 s
756
+ Thread.new { sleep 5; token.cancel! }
757
+
758
+ begin
759
+ result = MyAgent.new.invoke("...", config: { cancellation_token: token })
760
+ rescue Phronomy::CancellationError
761
+ puts "cancelled"
762
+ end
763
+
764
+ # Hard deadline via monotonic clock (recommended — immune to NTP/DST changes)
765
+ token = Phronomy::CancellationToken.timeout_after(30)
766
+ result = MyAgent.new.invoke("...", config: { cancellation_token: token })
767
+
768
+ # Hard deadline via wall-clock (legacy — still supported)
769
+ token = Phronomy::CancellationToken.new(deadline: Time.now + 30)
770
+ result = MyAgent.new.invoke("...", config: { cancellation_token: token })
771
+
772
+ # Propagate to all parallel workers via dispatch_parallel / fan_out
773
+ token = Phronomy::CancellationToken.new
774
+ Thread.new { sleep 10; token.cancel! }
775
+
776
+ orchestrator.dispatch_parallel(
777
+ {agent: SearchAgent, input: "topic A"},
778
+ {agent: AnalysisAgent, input: "topic B"},
779
+ cancellation_token: token
780
+ )
781
+ ```
475
782
 
476
783
  ## Examples
477
784
 
@@ -542,6 +849,37 @@ bin/console
542
849
 
543
850
  Bug reports and pull requests are welcome on GitHub at https://github.com/Raizo-TCS/phronomy.
544
851
 
852
+ ## Security & Privacy
853
+
854
+ **API credentials** — Phronomy does not store or transmit your LLM API keys. All
855
+ credentials are handled by RubyLLM and passed directly to the provider.
856
+
857
+ **Tracing and PII** — When tracing is enabled (`Phronomy::Tracing::OpenTelemetryTracer`
858
+ or a custom tracer), agent inputs and LLM outputs are replaced with `[REDACTED]` in
859
+ span attributes by default (`trace_pii: false`). To include full content in traces
860
+ (e.g., for debugging in a non-production environment), set `trace_pii: true` in your
861
+ Phronomy configuration. Evaluate whether your tracing backend (OTLP collector, Jaeger,
862
+ Honeycomb, etc.) meets your data-retention and privacy requirements.
863
+
864
+ **Prompt injection** — Phronomy provides `PromptInjectionGuardrail`, a built-in
865
+ pattern-based input guardrail that detects common injection patterns (ignore/override
866
+ instructions, role-switching phrases, etc.). It is a useful starting point, not a
867
+ comprehensive defence; applications processing untrusted input should layer additional
868
+ custom guardrails as needed (see the Guardrails section above).
869
+
870
+ **Tool and MCP security** — Tools can perform real-world side effects (database
871
+ writes, API calls, file deletion). Treat tool execution as a privileged operation:
872
+ use the interrupt/approval mechanism for high-risk tools (e.g., payment processing,
873
+ file deletion) rather than allowing fully autonomous execution. MCP servers are
874
+ external trust boundaries: connect only to servers you control. A compromised MCP
875
+ server can inject instructions that manipulate agent behavior (tool-level prompt
876
+ injection). Avoid passing secrets as direct tool parameters — if `trace_pii: true`
877
+ is set, tool arguments are captured in trace spans.
878
+
879
+ **Vulnerability reports** — Please report security vulnerabilities privately via
880
+ GitHub's [Security Advisories](https://github.com/Raizo-TCS/phronomy/security/advisories)
881
+ rather than opening a public issue.
882
+
545
883
  ## License
546
884
 
547
885
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,86 @@
1
+ # Release Checklist
2
+
3
+ Use this checklist before every release of the `phronomy` gem.
4
+ Copy it into the GitHub Release draft and check off each item.
5
+
6
+ ---
7
+
8
+ ## Pre-release
9
+
10
+ - [ ] `CHANGELOG.md` updated (Added / Changed / Fixed / Removed / Deprecated / Security)
11
+ - [ ] Version bumped in `lib/phronomy/version.rb`
12
+ - [ ] Stability table in `README.md` reflects any API additions, removals, or promotions
13
+ - [ ] `@api private` annotations are consistent with the README stability table (Issue #205)
14
+ - [ ] Public API compatibility snapshot regenerated if any Stable API changed:
15
+ ```bash
16
+ bundle exec ruby scripts/api_snapshot.rb --write
17
+ ```
18
+ (Issue #210)
19
+ - [ ] Migration notes or deprecation warnings added for any breaking changes
20
+
21
+ ---
22
+
23
+ ## Quality Gates (all must pass before tagging)
24
+
25
+ - [ ] `bundle exec rspec --format documentation` — 0 failures
26
+ - [ ] `bundle exec rspec --tag integration` — 0 failures, all expected pending
27
+ - [ ] `ruby scripts/check_japanese.rb` — exit 0 (no Japanese in source)
28
+ - [ ] `bundle exec standardrb` — 0 offenses
29
+ - [ ] `COVERAGE=1 bundle exec rspec` — coverage above configured threshold (Issue #207)
30
+ - [ ] CI green on all Ruby matrix versions (3.2 / 3.3 / 3.4 / head)
31
+
32
+ ---
33
+
34
+ ## Security Review
35
+
36
+ - [ ] `SECURITY.md` is up to date (supported versions table, contact info)
37
+ - [ ] No new `trace_pii`-sensitive data paths introduced without redaction
38
+ - [ ] No new `requires_approval` tools missing the approval gate
39
+ - [ ] No secrets, credentials, or PII in tool descriptions, schema strings, or spec fixtures
40
+ - [ ] Dependency audit passes: `bundle exec bundler-audit check --update`
41
+
42
+ ---
43
+
44
+ ## Release Steps
45
+
46
+ > **Do not use `gem push` directly.** The GitHub Actions release workflow handles
47
+ > gem publication. Follow the steps below exactly.
48
+
49
+ 1. Commit the version bump:
50
+ ```bash
51
+ git commit -m "bump version to X.Y.Z"
52
+ git push origin main
53
+ ```
54
+ 2. Create and push the tag:
55
+ ```bash
56
+ git tag vX.Y.Z
57
+ git push origin vX.Y.Z
58
+ ```
59
+ 3. Trigger the release workflow:
60
+ ```bash
61
+ gh workflow run release.yml --field tag=vX.Y.Z
62
+ ```
63
+ 4. Monitor the workflow run:
64
+ ```bash
65
+ gh run list --workflow release.yml --limit 3
66
+ ```
67
+ 5. Verify the gem appears on RubyGems: `gem search phronomy`
68
+
69
+ ---
70
+
71
+ ## Post-release
72
+
73
+ - [ ] `phronomy-examples` `Gemfile` updated to the new version
74
+ ```bash
75
+ cd ../phronomy-examples && bundle update phronomy
76
+ ```
77
+ - [ ] `phronomy-examples` tests pass after the update
78
+ - [ ] GitHub Release description includes the relevant CHANGELOG excerpt
79
+
80
+ ---
81
+
82
+ ## Reference Issues
83
+
84
+ - #205 — `@api private` annotation policy
85
+ - #207 — SimpleCov coverage gate
86
+ - #210 — Public API compatibility snapshot