phronomy 0.6.0 → 0.7.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +21 -0
  3. data/CHANGELOG.md +338 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +242 -27
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/SECURITY.md +80 -0
  8. data/benchmark/baseline.json +9 -0
  9. data/benchmark/bench_agent_invoke.rb +105 -0
  10. data/benchmark/bench_context_assembler.rb +46 -0
  11. data/benchmark/bench_regression.rb +171 -0
  12. data/benchmark/bench_token_estimator.rb +44 -0
  13. data/benchmark/bench_tool_schema.rb +69 -0
  14. data/benchmark/bench_vector_store.rb +39 -0
  15. data/benchmark/bench_workflow.rb +55 -0
  16. data/benchmark/run_all.rb +118 -0
  17. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  18. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  19. data/docs/decisions/003-event-loop-singleton.md +48 -0
  20. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
  21. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  22. data/docs/decisions/006-no-built-in-guardrails.md +48 -0
  23. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  24. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  25. data/docs/decisions/009-state-store-abstraction.md +141 -0
  26. data/lib/phronomy/agent/base.rb +194 -12
  27. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  28. data/lib/phronomy/agent/checkpoint.rb +1 -0
  29. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  30. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  31. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  32. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  33. data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
  34. data/lib/phronomy/agent/fsm.rb +15 -0
  35. data/lib/phronomy/agent/handoff.rb +3 -0
  36. data/lib/phronomy/agent/orchestrator.rb +123 -11
  37. data/lib/phronomy/agent/parallel_tool_chat.rb +21 -4
  38. data/lib/phronomy/agent/react_agent.rb +8 -6
  39. data/lib/phronomy/agent/runner.rb +2 -0
  40. data/lib/phronomy/agent/shared_state.rb +11 -0
  41. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  42. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  43. data/lib/phronomy/cancellation_token.rb +92 -0
  44. data/lib/phronomy/configuration.rb +26 -2
  45. data/lib/phronomy/context/assembler.rb +6 -0
  46. data/lib/phronomy/context/compaction_context.rb +2 -0
  47. data/lib/phronomy/context/context_version_cache.rb +2 -0
  48. data/lib/phronomy/context/token_budget.rb +3 -0
  49. data/lib/phronomy/context/token_estimator.rb +9 -2
  50. data/lib/phronomy/context/trigger_context.rb +1 -0
  51. data/lib/phronomy/context/trim_context.rb +4 -0
  52. data/lib/phronomy/embeddings/base.rb +5 -2
  53. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  54. data/lib/phronomy/eval/comparison.rb +2 -0
  55. data/lib/phronomy/eval/dataset.rb +4 -0
  56. data/lib/phronomy/eval/metrics.rb +6 -0
  57. data/lib/phronomy/eval/runner.rb +2 -0
  58. data/lib/phronomy/eval/scorer/base.rb +1 -0
  59. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  60. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  61. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  62. data/lib/phronomy/event_loop.rb +114 -7
  63. data/lib/phronomy/fsm_session.rb +8 -1
  64. data/lib/phronomy/generator_verifier.rb +2 -0
  65. data/lib/phronomy/guardrail/base.rb +3 -0
  66. data/lib/phronomy/knowledge_source/base.rb +6 -2
  67. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  68. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  69. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  70. data/lib/phronomy/loader/base.rb +1 -0
  71. data/lib/phronomy/loader/csv_loader.rb +2 -0
  72. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  73. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  74. data/lib/phronomy/output_parser/base.rb +1 -0
  75. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  76. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  77. data/lib/phronomy/prompt_template.rb +5 -0
  78. data/lib/phronomy/runnable.rb +20 -3
  79. data/lib/phronomy/splitter/base.rb +2 -0
  80. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  81. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  82. data/lib/phronomy/state_store/base.rb +48 -0
  83. data/lib/phronomy/state_store/in_memory.rb +62 -0
  84. data/lib/phronomy/tool/agent_tool.rb +1 -0
  85. data/lib/phronomy/tool/base.rb +189 -27
  86. data/lib/phronomy/tool/mcp_tool.rb +68 -13
  87. data/lib/phronomy/tracing/base.rb +3 -0
  88. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  89. data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
  90. data/lib/phronomy/vector_store/base.rb +33 -7
  91. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  92. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  93. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  94. data/lib/phronomy/version.rb +1 -1
  95. data/lib/phronomy/workflow.rb +96 -7
  96. data/lib/phronomy/workflow_context.rb +54 -4
  97. data/lib/phronomy/workflow_runner.rb +35 -7
  98. data/lib/phronomy.rb +70 -1
  99. data/scripts/api_snapshot.rb +91 -0
  100. data/scripts/check_api_annotations.rb +68 -0
  101. data/scripts/check_private_enforcement.rb +93 -0
  102. data/scripts/check_readme_runnable.rb +98 -0
  103. data/scripts/run_mutation.sh +46 -0
  104. metadata +45 -2
data/README.md CHANGED
@@ -1,13 +1,26 @@
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.
11
24
 
12
25
  | Feature | Stability |
13
26
  |---|---|
@@ -18,17 +31,22 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
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 |
34
+ | **Knowledge/RAG** — Retrieval sources with pluggable loaders, splitters, and vector stores; `static_knowledge_refresh!` for runtime cache invalidation | Beta |
35
+ | **`VectorStore#size`** — Returns document count for all three backends (InMemory, RedisSearch, Pgvector) | Beta |
22
36
  | **Multi-agent** — Agent-as-Tool pattern and hub-and-spoke handoff routing | Beta |
23
37
  | **GeneratorVerifier** — Generator-Verifier loop with injectable prompt builders/parsers | Beta |
24
38
  | **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 |
39
+ | **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + stateful workers with sequential task assignment (worker-local message history persisted across tasks) | Beta |
26
40
  | **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
27
41
  | **Guardrails** — Input/output validation with custom `InputGuardrail`/`OutputGuardrail` | Beta |
28
42
  | **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
29
43
  | **Eval Framework** — Dataset-driven evaluation with multiple scorer types | Beta |
30
44
  | **Tracing** — Pluggable span-based observability | Stable |
31
45
  | **MCP Tool** — Model Context Protocol server integration | Beta |
46
+ | **Error Taxonomy** — `RateLimitError`, `AuthenticationError`, `ContextLengthError`, `TransportError` (subclasses of `Phronomy::Error`) raised at the agent retry boundary | Beta |
47
+ | **`Phronomy.with_configuration` / `Phronomy.reset_runtime!`** — Scoped configuration override and full runtime reset for test isolation | Beta |
48
+ | **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 |
49
+ | **`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 |
32
50
 
33
51
  ## Installation
34
52
 
@@ -44,17 +62,42 @@ Then run:
44
62
  bundle install
45
63
  ```
46
64
 
65
+ ### RubyLLM setup
66
+
67
+ Phronomy uses [RubyLLM](https://github.com/crmne/ruby_llm) for LLM access.
68
+ Configure your provider credentials before using agents or chains:
69
+
70
+ ```ruby
71
+ RubyLLM.configure do |c|
72
+ c.openai_api_key = ENV["OPENAI_API_KEY"]
73
+ # c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
74
+ end
75
+ ```
76
+
77
+ See the [RubyLLM documentation](https://rubyllm.com) for all supported providers.
78
+
79
+ ### Optional dependencies
80
+
81
+ Install additional gems only for the features you use:
82
+
83
+ | Gem | Required for |
84
+ |-----|-------------|
85
+ | `pgvector` | `Phronomy::VectorStore::Pgvector` |
86
+ | `redis` | `Phronomy::VectorStore::RedisSearch` |
87
+ | `opentelemetry-api` | `Phronomy::Tracing::OpenTelemetryTracer` |
88
+
47
89
  ## Quick Start
48
90
 
49
91
  ### Agent — ReAct tool-calling agent
50
92
 
51
- ```ruby
93
+ ```ruby runnable
52
94
  class WebSearch < Phronomy::Tool::Base
53
95
  description "Search the web"
54
96
  param :query, type: :string, desc: "Search query"
55
97
 
56
98
  def execute(query:)
57
- # ... call a search API
99
+ # Replace with a real search API call (e.g., SerpAPI, Tavily)
100
+ "Mock search result for: #{query}"
58
101
  end
59
102
  end
60
103
 
@@ -71,7 +114,7 @@ puts result[:output]
71
114
 
72
115
  ### Workflow — Stateful workflow with wait_state/send_event
73
116
 
74
- ```ruby
117
+ ```ruby runnable
75
118
  class ReviewContext
76
119
  include Phronomy::WorkflowContext
77
120
  field :draft, type: :replace
@@ -79,10 +122,14 @@ class ReviewContext
79
122
  field :approved, type: :replace, default: false
80
123
  end
81
124
 
125
+ # Placeholder callables representing your own implementation
126
+ write_draft = ->(state) { state.merge(draft: "Draft content here") }
127
+ review_draft = ->(state) { state.merge(feedback: "Feedback on: #{state.draft}") }
128
+
82
129
  app = Phronomy::Workflow.define(ReviewContext) do
83
130
  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)) }
131
+ state :write, action: write_draft
132
+ state :review, action: review_draft
86
133
  wait_state :awaiting_approval # halts here for human decision
87
134
  state :finalize, action: ->(s) { s.merge(approved: true) }
88
135
  transition from: :write, to: :review
@@ -102,6 +149,19 @@ final = app.send_event(state: state, event: :approve)
102
149
  puts "Approved: #{final.approved}" # => true
103
150
  ```
104
151
 
152
+ In EventLoop mode (`c.event_loop = true`), `Agent#run_as_child` spawns a child agent
153
+ asynchronously. When the child succeeds, `:child_completed` is dispatched; when it fails,
154
+ `:child_failed` is dispatched. Always declare both transitions to avoid a stuck workflow:
155
+
156
+ ```ruby
157
+ # EventLoop mode: workflow that runs an agent as a child FSM
158
+ entry :run_agent, ->(ctx) {
159
+ MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
160
+ }
161
+ transition from: :run_agent, on: :child_completed, to: :done
162
+ transition from: :run_agent, on: :child_failed, to: :handle_error
163
+ ```
164
+
105
165
  ### Multi-Agent — Agent-as-Tool pattern
106
166
 
107
167
  Wrap sub-agents as `Tool::Base` subclasses so the orchestrator LLM can call them on demand.
@@ -116,6 +176,11 @@ class ResearchTool < Phronomy::Tool::Base
116
176
  end
117
177
  end
118
178
 
179
+ class WriterAgent < Phronomy::Agent::Base
180
+ model "gpt-4o"
181
+ instructions "You are a professional technical writer."
182
+ end
183
+
119
184
  class WriteTool < Phronomy::Tool::Base
120
185
  description "Write a technical blog post given research notes and a writing brief."
121
186
  param :instructions, type: :string, desc: "Writing brief including research notes"
@@ -137,6 +202,9 @@ puts result[:output]
137
202
 
138
203
  ### Guardrails — Input/output validation
139
204
 
205
+ Call `fail!(reason)` inside `check` to reject — it raises `Phronomy::GuardrailError`.
206
+ When a guardrail rejects, `invoke` raises instead of returning an output.
207
+
140
208
  ```ruby
141
209
  class NoSensitiveDataGuardrail < Phronomy::Guardrail::InputGuardrail
142
210
  def check(input)
@@ -146,8 +214,19 @@ end
146
214
 
147
215
  agent = ResearchAgent.new
148
216
  agent.add_input_guardrail(NoSensitiveDataGuardrail.new)
217
+
218
+ begin
219
+ agent.invoke("Charge 4111-1111-1111-1111")
220
+ rescue Phronomy::GuardrailError => e
221
+ puts e.message # => "Credit card numbers are not allowed"
222
+ end
149
223
  ```
150
224
 
225
+ > **Limitations:** Phronomy ships no built-in guardrail implementations. There is no
226
+ > built-in prompt injection detector, PII scanner, or content classifier. All guardrail
227
+ > logic must be implemented by the application. Reference implementations for common
228
+ > patterns are available in `phronomy-examples` (example 06).
229
+
151
230
  ### Knowledge/RAG — Context injection and vector retrieval
152
231
 
153
232
  ```ruby
@@ -161,6 +240,13 @@ policy = Phronomy::KnowledgeSource::StaticKnowledge.new(
161
240
  # RAG retrieval from a vector store
162
241
  store = Phronomy::VectorStore::InMemory.new
163
242
  embeddings = Phronomy::Embeddings::RubyLLMEmbeddings.new(model: "text-embedding-3-small")
243
+
244
+ # Add documents before querying
245
+ text1 = "Refunds are processed within 5 business days."
246
+ text2 = "Contact support@example.com for refund requests."
247
+ store.add(id: "doc-1", embedding: embeddings.embed(text1), metadata: { content: text1, source: "policy.md" })
248
+ store.add(id: "doc-2", embedding: embeddings.embed(text2), metadata: { content: text2, source: "policy.md" })
249
+
164
250
  rag = Phronomy::KnowledgeSource::RAGKnowledge.new(store: store, embeddings: embeddings, k: 5)
165
251
 
166
252
  # Inject at invocation time
@@ -168,6 +254,15 @@ result = MyAgent.new.invoke("What is the refund policy?",
168
254
  config: { knowledge_sources: [policy, rag] })
169
255
  ```
170
256
 
257
+ `static_knowledge_refresh!` invalidates the class-level cache of *static* knowledge sources
258
+ (not RAG stores). Call it when the underlying file or content has changed:
259
+
260
+ ```ruby
261
+ # Static knowledge sources are cached at the class level after the first fetch.
262
+ # Call refresh! when the underlying content changes (e.g. after reloading policy.md).
263
+ MyAgent.static_knowledge_refresh!
264
+ ```
265
+
171
266
  Load and split documents with built-in loaders:
172
267
 
173
268
  ```ruby
@@ -211,7 +306,7 @@ Phronomy.configure do |c|
211
306
  end
212
307
  ```
213
308
 
214
- Hooks are called in order — global → class → instance — and deep-merged.
309
+ Hooks are called in order — global → class → instance — and shallow-merged (`Hash#merge`; last hook wins on key conflicts).
215
310
 
216
311
  ### GeneratorVerifier — Generator-Verifier loop with custom prompt builders
217
312
 
@@ -290,19 +385,21 @@ class MyOrchestrator < Phronomy::Agent::Orchestrator
290
385
  instructions "Orchestrate."
291
386
 
292
387
  def run(query)
293
- # Heterogeneous agents in parallel (cap at 4 threads; skip failures)
388
+ # Heterogeneous agents in parallel (cap at 4 threads; skip failures; 30 s timeout)
294
389
  results = dispatch_parallel(
295
390
  {agent: SearchAgent, input: "topic A"},
296
391
  {agent: AnalysisAgent, input: query},
297
392
  max_concurrency: 4,
298
- on_error: :skip
393
+ on_error: :skip,
394
+ timeout: 30
299
395
  )
300
396
 
301
397
  # Fan-out — same agent, multiple inputs
302
398
  translations = fan_out(
303
399
  agent: TranslationAgent,
304
400
  inputs: %w[Hello World],
305
- max_concurrency: 2
401
+ max_concurrency: 2,
402
+ timeout: 20
306
403
  )
307
404
 
308
405
  results.compact.map { |r| r[:output] }.join("\n")
@@ -324,13 +421,16 @@ end
324
421
  app = Phronomy::Workflow.define(EnrichContext) do
325
422
  initial :enrich
326
423
  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]))
424
+ # Use Thread#value to collect results safely — avoids concurrent Hash writes
425
+ threads = {
426
+ summary: Thread.new { Summarizer.call(s) },
427
+ tags: Thread.new { Tagger.call(s) }
428
+ }
429
+ # For production use, wrap with Timeout.timeout to avoid unbounded waits:
430
+ # require "timeout"
431
+ # Timeout.timeout(30) { threads.each_value(&:join) }
432
+ threads.each_value(&:join)
433
+ s.merge(summary: threads[:summary].value, tags: Array(threads[:tags].value))
334
434
  end
335
435
  transition from: :enrich, to: :__finish__
336
436
  end
@@ -419,22 +519,38 @@ puts result2[:output] # => "Your name is Alice."
419
519
  `result[:messages]` contains the complete message history after each invocation.
420
520
  Persist it however suits your application (in-memory hash, Redis, ActiveRecord, etc.).
421
521
 
522
+ > **Note on `thread_id`**: `thread_id` is a correlation identifier used internally for
523
+ > checkpoint/compaction context and EventLoop routing. It does **not** automatically persist or
524
+ > restore conversation history — you must pass `messages:` explicitly on each turn as shown above.
525
+
422
526
 
423
527
  ## Configuration
424
528
 
425
529
  ```ruby
426
530
  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
531
+ c.default_model = "gpt-4o-mini"
532
+ c.recursion_limit = 25
533
+ c.tracer = Phronomy::Tracing::NullTracer.new
534
+ c.before_completion = nil # optional; global hook lambda
535
+ c.trace_pii = false # default; set to true only when trace data contains no PII
536
+ c.logger = nil # optional; any object responding to #warn (e.g. Rails.logger)
537
+ c.event_loop_stop_grace_seconds = 5 # seconds to wait for sessions to drain on EventLoop#stop(drain: true)
431
538
  end
432
539
  ```
433
540
 
541
+ `c.logger` receives framework diagnostic messages (e.g. unreachable-state warnings from
542
+ `Workflow.define`). When `nil` (default), messages are written to `$stderr` via `Kernel#warn`.
543
+
544
+ > **Note**: When `trace_pii = false`, both the _input_ and the _output_ (LLM
545
+ > responses and tool results) are replaced with `[REDACTED]` in trace spans.
546
+ > The default is `false` (PII protection enabled). Set to `true` only when
547
+ > trace data does not contain sensitive information.
548
+
434
549
  ## Context Management
435
550
 
436
- Phronomy includes a context window management layer so agents automatically
437
- stay within the token limits of the underlying model.
551
+ Phronomy includes a context window management layer. When model metadata is
552
+ available (either from the built-in registry or via an explicit `context_window:` setting),
553
+ agents automatically stay within the configured token limit.
438
554
 
439
555
  ### TokenBudget
440
556
 
@@ -466,12 +582,82 @@ class MyAgent < Phronomy::Agent::Base
466
582
  model "gpt-4o"
467
583
  max_output_tokens 4096 # override max_output_tokens from registry
468
584
  context_overhead 600 # extra reservation for system prompt + tools
585
+ invoke_timeout 30 # raise Phronomy::TimeoutError after 30 s (wait timeout, not cancellation)
586
+ max_parallel_tools 4 # cap concurrent tool-call threads (default: 10)
469
587
  end
470
588
  ```
471
589
 
472
590
  `Agent::Base#invoke` builds a `TokenBudget` automatically. When the model is not in the
473
591
  registry the budget is silently skipped.
474
592
 
593
+ > **Note on CJK languages**: The default `TokenEstimator` uses a character-ratio heuristic
594
+ > calibrated for ASCII/Latin text (4 chars/token). For Chinese, Japanese, and Korean text,
595
+ > actual token counts are approximately **4× higher** than the estimate because CJK
596
+ > characters are typically 1 token each. For accurate CJK token counting, supply a
597
+ > tokenizer-backed callable:
598
+ >
599
+ > ```ruby
600
+ > require "tiktoken_ruby"
601
+ > enc = Tiktoken.encoding_for_model("gpt-4o")
602
+ > Phronomy::Context::TokenEstimator.tokenizer = ->(text) { enc.encode(text).length }
603
+ > ```
604
+
605
+
606
+ ### CancellationToken — Cooperative cancellation
607
+
608
+ Pass a `CancellationToken` to any agent via `config: { cancellation_token: token }`.
609
+ Cancellation is checked at multiple granular checkpoints: before the LLM call, before
610
+ each RAG knowledge-source fetch, after each streaming chunk, before each parallel
611
+ tool-call batch, and after each `before_completion` hook. `CancellationError` is
612
+ raised immediately and is never retried. No threads are force-killed — `ensure`
613
+ blocks always execute.
614
+
615
+ > **Cooperative cancellation — not preemptive**
616
+ >
617
+ > Phronomy uses _cooperative boundary cancellation_. The token is polled at the
618
+ > checkpoints listed above; it is **not** injected as a signal into a running
619
+ > operation. This means the following are **not** interrupted mid-execution:
620
+ >
621
+ > - A single `KnowledgeSource#fetch` that is already blocking (e.g. HTTP call)
622
+ > - A single `chat.ask` call that is not streaming
623
+ > - A single `tool.execute` call that is already running
624
+ > - Any external I/O (database query, vector search, HTTP request) inside those calls
625
+ >
626
+ > For deep in-flight safety, complement `CancellationToken` with per-source or
627
+ > per-tool timeouts (e.g. `Net::HTTP#read_timeout`, `Timeout.timeout`, connection
628
+ > pool limits). Ruby's GVL prevents fully preemptive cancellation without
629
+ > `Thread#kill`, which Phronomy avoids by default due to resource safety concerns.
630
+
631
+ ```ruby
632
+ token = Phronomy::CancellationToken.new
633
+
634
+ # Cancel from another thread after 5 s
635
+ Thread.new { sleep 5; token.cancel! }
636
+
637
+ begin
638
+ result = MyAgent.new.invoke("...", config: { cancellation_token: token })
639
+ rescue Phronomy::CancellationError
640
+ puts "cancelled"
641
+ end
642
+
643
+ # Hard deadline via monotonic clock (recommended — immune to NTP/DST changes)
644
+ token = Phronomy::CancellationToken.timeout_after(30)
645
+ result = MyAgent.new.invoke("...", config: { cancellation_token: token })
646
+
647
+ # Hard deadline via wall-clock (legacy — still supported)
648
+ token = Phronomy::CancellationToken.new(deadline: Time.now + 30)
649
+ result = MyAgent.new.invoke("...", config: { cancellation_token: token })
650
+
651
+ # Propagate to all parallel workers via dispatch_parallel / fan_out
652
+ token = Phronomy::CancellationToken.new
653
+ Thread.new { sleep 10; token.cancel! }
654
+
655
+ orchestrator.dispatch_parallel(
656
+ {agent: SearchAgent, input: "topic A"},
657
+ {agent: AnalysisAgent, input: "topic B"},
658
+ cancellation_token: token
659
+ )
660
+ ```
475
661
 
476
662
  ## Examples
477
663
 
@@ -542,6 +728,35 @@ bin/console
542
728
 
543
729
  Bug reports and pull requests are welcome on GitHub at https://github.com/Raizo-TCS/phronomy.
544
730
 
731
+ ## Security & Privacy
732
+
733
+ **API credentials** — Phronomy does not store or transmit your LLM API keys. All
734
+ credentials are handled by RubyLLM and passed directly to the provider.
735
+
736
+ **Tracing and PII** — When tracing is enabled (`Phronomy::Tracing::OpenTelemetryTracer`
737
+ or a custom tracer), agent inputs and LLM outputs are replaced with `[REDACTED]` in
738
+ span attributes by default (`trace_pii: false`). To include full content in traces
739
+ (e.g., for debugging in a non-production environment), set `trace_pii: true` in your
740
+ Phronomy configuration. Evaluate whether your tracing backend (OTLP collector, Jaeger,
741
+ Honeycomb, etc.) meets your data-retention and privacy requirements.
742
+
743
+ **Prompt injection** — Phronomy provides no built-in prompt injection detection.
744
+ Applications that process untrusted user input should implement their own input
745
+ guardrails (see the Guardrails section above).
746
+
747
+ **Tool and MCP security** — Tools can perform real-world side effects (database
748
+ writes, API calls, file deletion). Treat tool execution as a privileged operation:
749
+ use the interrupt/approval mechanism for high-risk tools (e.g., payment processing,
750
+ file deletion) rather than allowing fully autonomous execution. MCP servers are
751
+ external trust boundaries: connect only to servers you control. A compromised MCP
752
+ server can inject instructions that manipulate agent behavior (tool-level prompt
753
+ injection). Avoid passing secrets as direct tool parameters — if `trace_pii: true`
754
+ is set, tool arguments are captured in trace spans.
755
+
756
+ **Vulnerability reports** — Please report security vulnerabilities privately via
757
+ GitHub's [Security Advisories](https://github.com/Raizo-TCS/phronomy/security/advisories)
758
+ rather than opening a public issue.
759
+
545
760
  ## License
546
761
 
547
762
  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
data/SECURITY.md ADDED
@@ -0,0 +1,80 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ | Version | Supported |
6
+ |---------|-----------|
7
+ | Latest release (main branch) | ✅ |
8
+ | Older versions | ❌ — please upgrade |
9
+
10
+ Only the latest released version of `phronomy` receives security patches. If you
11
+ are running an older version, please upgrade before filing a report.
12
+
13
+ ---
14
+
15
+ ## Reporting a Vulnerability
16
+
17
+ **Please do NOT open a public GitHub Issue for security vulnerabilities.**
18
+
19
+ Use [GitHub's private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability)
20
+ instead:
21
+
22
+ 1. Navigate to the [Security tab](https://github.com/Raizo-TCS/phronomy/security)
23
+ of this repository.
24
+ 2. Click **"Report a vulnerability"**.
25
+ 3. Fill in the advisory form with as much detail as possible.
26
+
27
+ This creates a private draft advisory visible only to maintainers, keeping the
28
+ details confidential until a fix is prepared and released.
29
+
30
+ ---
31
+
32
+ ## Response Timeline
33
+
34
+ | Milestone | Target |
35
+ |-----------|--------|
36
+ | Acknowledgement of report | Within **7 days** |
37
+ | Triage and initial assessment | Within **14 days** |
38
+ | Patch release (critical / high severity) | Within **30 days** |
39
+ | Patch release (medium / low severity) | Best effort; typically within **60 days** |
40
+
41
+ If you do not receive an acknowledgement within 7 days, please follow up by
42
+ opening a **public** Issue with the subject "Security report follow-up (no
43
+ response)" — do **not** include vulnerability details in the public Issue.
44
+
45
+ ---
46
+
47
+ ## Scope
48
+
49
+ **In scope:**
50
+
51
+ - Vulnerabilities in the `phronomy` gem source code (`lib/`, `spec/`).
52
+ - Dependency vulnerabilities that affect gem consumers when `phronomy` is used as intended.
53
+ - Information disclosure via tracing/logging APIs (e.g. `trace_pii: false` bypass).
54
+ - Approval gate bypasses (tool execution without the registered approval handler).
55
+
56
+ **Out of scope:**
57
+
58
+ - Security of consumer applications built on top of `phronomy`.
59
+ - Vulnerabilities in the LLM provider (OpenAI, Anthropic, etc.) or in `ruby_llm`.
60
+ - Attacks that require an attacker to already have write access to the host system.
61
+ - Prompt injection via LLM output — the gem forwards LLM output faithfully; prompt
62
+ injection resistance is the responsibility of the LLM provider and the application.
63
+
64
+ ---
65
+
66
+ ## Disclosure Policy
67
+
68
+ - Maintainers will coordinate with you on the release date and credit you in the
69
+ `CHANGELOG.md` entry and GitHub release notes.
70
+ - If you wish to remain anonymous, let us know in the advisory.
71
+ - We follow a **coordinated disclosure** model: the advisory will be made public
72
+ after a patch is released (or after 90 days, whichever comes first).
73
+
74
+ ---
75
+
76
+ ## Credit
77
+
78
+ Security reporters are credited in the `CHANGELOG.md` entry for the patch release,
79
+ in the GitHub Security Advisory, and in the release notes — unless they request
80
+ anonymity.
@@ -0,0 +1,9 @@
1
+ {
2
+ "workflow_context_merge": 124364.81010472385,
3
+ "workflow_define": 2179.945274115319,
4
+ "tool_params_schema_definition": 19534379.159046534,
5
+ "dispatch_parallel_10": 1483.2255243486482,
6
+ "cancellation_token_cancelled": 4335060.97443425,
7
+ "cancellation_token_raise_if_cancelled_noop": 3566903.189098373,
8
+ "trim_context_remove_2000": 1761.5700678986254
9
+ }