phronomy 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4410424efcdcdf0ab529106ba2c872bddae9decc322995f37065f426255a05b
|
|
4
|
+
data.tar.gz: c9ae0dff7f184244b92fc91c585536f767aded5ff6ea8ecaafe86221863738e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e7afa1749dc1431e27e225dfe7a8eafebb2e781c0e6a6ca6e0bdda9712c22b4b5b68d3a9897bc92b466026c698070045774d79a0197ad0463da3f81ff103b36c
|
|
7
|
+
data.tar.gz: be93a29c2b98b2069ef912815e847f0e0115de07bb435e36fbcb834433757fc72442d7c5db129c9f97dd891113ecf75c46606a6295b2e9f3357831c24246d974
|
data/.mutant.yml
CHANGED
|
@@ -12,10 +12,11 @@ includes:
|
|
|
12
12
|
requires:
|
|
13
13
|
- phronomy
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
matcher:
|
|
16
|
+
subjects:
|
|
17
|
+
- Phronomy::WorkflowContext
|
|
18
|
+
- Phronomy::WorkflowRunner
|
|
19
|
+
- Phronomy::Tool::Base
|
|
20
|
+
- Phronomy::Context::TokenBudget
|
|
21
|
+
- Phronomy::Context::TokenEstimator
|
|
22
|
+
- Phronomy::VectorStore::InMemory
|
data/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,104 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
11
11
|
|
|
12
12
|
### Added
|
|
13
13
|
|
|
14
|
+
- **`Phronomy::Diagnostics` and `SchedulerReentrancyError`** (#278, #279):
|
|
15
|
+
`Phronomy::Diagnostics` exposes a snapshot of current scheduler state
|
|
16
|
+
(`pending_count`, `active_tasks`, `pool_utilization`, etc.) for debugging and
|
|
17
|
+
monitoring. `SchedulerReentrancyError` is raised when a scheduler operation is
|
|
18
|
+
attempted from within a scheduler callback, preventing deadlocks.
|
|
19
|
+
`Phronomy.configure { |c| c.scheduler_debug = true }` enables verbose scheduler
|
|
20
|
+
logging.
|
|
21
|
+
|
|
22
|
+
- **`task_id` / `parent_task_id` on `InvocationContext`** (#277):
|
|
23
|
+
Every task spawned via `Task.spawn` now carries a `task_id` (a random UUID) and
|
|
24
|
+
an optional `parent_task_id`. These fields enable hierarchical task-tree tracing
|
|
25
|
+
and are forwarded automatically by `TaskGroup`.
|
|
26
|
+
|
|
27
|
+
- **`Phronomy::Metrics` — task-centric observability snapshot** (#276):
|
|
28
|
+
`Phronomy::Metrics.snapshot` returns a hash with scheduler statistics:
|
|
29
|
+
`tasks_started`, `tasks_completed`, `tasks_failed`, `pool_queue_depth`, and
|
|
30
|
+
`pool_active_threads`. Intended for metrics export and health-check endpoints.
|
|
31
|
+
|
|
32
|
+
- **`Phronomy::Testing::FakeClock` and `FakeScheduler`** (#273):
|
|
33
|
+
Two test helpers for deterministic concurrency testing.
|
|
34
|
+
`FakeClock` exposes `advance(seconds)` to control the passage of time without
|
|
35
|
+
sleeping. `FakeScheduler` replaces the real scheduler in specs, providing
|
|
36
|
+
synchronous execution and `flush` / `drain` helpers to drive task completion.
|
|
37
|
+
|
|
38
|
+
- **`ScopePolicy` and approval gate integration** (#270):
|
|
39
|
+
`Phronomy::Tool::ScopePolicy` is a callable that maps `(tool_class, scope, agent)`
|
|
40
|
+
to `:allow`, `:approve`, or `:reject`. The default policy (`ScopePolicy::DEFAULT`)
|
|
41
|
+
automatically routes tools declaring high-risk scopes (`:write`, `:admin`,
|
|
42
|
+
`:external_network`, `:filesystem`, `:process`, `:external_process`) through the
|
|
43
|
+
existing approval gate; tools with `scope :read_only` or no scope are allowed
|
|
44
|
+
unconditionally. Per-agent policy overrides are available via
|
|
45
|
+
`agent.scope_policy = my_policy`.
|
|
46
|
+
**Behaviour change**: tools with the above scopes that previously executed without
|
|
47
|
+
an approval handler will now be **rejected** unless an approval handler is
|
|
48
|
+
registered or the agent uses a custom permissive policy.
|
|
49
|
+
|
|
50
|
+
- **`PromptInjectionGuardrail`, `Tool::Base#redact_params`, and `#max_result_size`** (#271):
|
|
51
|
+
`Phronomy::Guardrail::PromptInjectionGuardrail` is a built-in `InputGuardrail`
|
|
52
|
+
subclass that detects prompt-injection patterns in user input.
|
|
53
|
+
`Tool::Base.redact_params(*names)` marks parameter names as sensitive; their
|
|
54
|
+
values are replaced with `"[REDACTED]"` in log and trace output.
|
|
55
|
+
`Tool::Base.max_result_size(n)` sets a per-tool character limit; results
|
|
56
|
+
exceeding the limit are truncated and a warning is logged. The global fallback is
|
|
57
|
+
`Phronomy.configure { |c| c.tool_result_max_size = n }` (default: no limit).
|
|
58
|
+
|
|
59
|
+
- **`execution_mode` DSL on `Tool::Base`** (#263):
|
|
60
|
+
`Tool::Base.execution_mode` accepts `:cooperative`, `:blocking_io` (default),
|
|
61
|
+
`:cpu_bound`, or `:external_process`. Tools marked `:blocking_io` (the default)
|
|
62
|
+
are dispatched through `BlockingAdapterPool` when a `Runtime` is available,
|
|
63
|
+
keeping the scheduler thread unblocked. Tools marked `:cooperative` are called
|
|
64
|
+
directly on the scheduler thread (suitable for pure in-memory operations).
|
|
65
|
+
|
|
66
|
+
- **`invoke_async` and `call_async` — async entry points** (#262):
|
|
67
|
+
`Agent::Base#invoke_async(input, **opts)` returns a `Phronomy::Task` wrapping
|
|
68
|
+
`#invoke`. `Workflow#invoke_async(input, config:)` does the same for workflows.
|
|
69
|
+
`Tool::Base#call_async(args, cancellation_token:)` returns a `Task` wrapping
|
|
70
|
+
`#call`. All three are backward-compatible with existing synchronous callers.
|
|
71
|
+
|
|
72
|
+
- **`LLMAdapter` abstraction** (#266):
|
|
73
|
+
`Phronomy::LLMAdapter::Base` decouples the agent pipeline from RubyLLM.
|
|
74
|
+
`Phronomy::LLMAdapter::RubyLLM` (registered by default) wraps the existing
|
|
75
|
+
integration. Custom adapters can be registered via
|
|
76
|
+
`Phronomy.configure { |c| c.llm_adapter = MyAdapter }` for testing or
|
|
77
|
+
alternative LLM backends.
|
|
78
|
+
|
|
79
|
+
- **`BlockingAdapterPool` backpressure limits** (#268):
|
|
80
|
+
`BlockingAdapterPool` now enforces configurable `pool_size` (default: 10) and
|
|
81
|
+
`queue_size` (default: 100) limits. Tasks submitted when the queue is full raise
|
|
82
|
+
`Phronomy::BackpressureError` immediately instead of growing the queue without
|
|
83
|
+
bound.
|
|
84
|
+
|
|
85
|
+
- **Cooperative scheduler fairness** (#269):
|
|
86
|
+
The scheduler measures per-task lag and emits starvation and dispatch warnings
|
|
87
|
+
via `Phronomy.configuration.logger` when tasks wait longer than configured
|
|
88
|
+
thresholds. Configurable via `scheduler_starvation_warn_ms` and
|
|
89
|
+
`scheduler_dispatch_warn_ms`.
|
|
90
|
+
|
|
91
|
+
- **Workflow entry actions awaitable with Task** (#264):
|
|
92
|
+
Entry action lambdas may now return a `Phronomy::Task`. The FSMSession awaits
|
|
93
|
+
the task on a background thread and posts `:action_completed` (with the resulting
|
|
94
|
+
`WorkflowContext`) or `:state_completed` back to the EventLoop without blocking
|
|
95
|
+
it. Backward-compatible: lambdas that return a `WorkflowContext` or `nil`
|
|
96
|
+
continue to work as before.
|
|
97
|
+
|
|
98
|
+
- **`Task`, `TaskGroup`, `AsyncQueue`, `Deadline`, `InvocationContext`, `Runtime` concurrency abstractions** (#255):
|
|
99
|
+
Six new concurrency primitives form the foundation of the async execution layer.
|
|
100
|
+
`Task` wraps a callable with cancellation, timeout (`Deadline`), and context
|
|
101
|
+
propagation (`InvocationContext`). `TaskGroup` runs tasks concurrently and waits
|
|
102
|
+
for all to finish (or the first failure). `AsyncQueue` is a bounded, cancellable
|
|
103
|
+
queue. `Runtime` is the top-level façade that resolves a `BlockingAdapterPool`
|
|
104
|
+
and provides `blocking_io { }` and `cpu_bound { }` dispatch helpers.
|
|
105
|
+
|
|
106
|
+
- **`BlockingAdapterPool`** (#256):
|
|
107
|
+
A bounded thread pool that isolates blocking I/O (LLM calls, database queries,
|
|
108
|
+
HTTP requests) from the cooperative scheduler thread. Default pool size is 10
|
|
109
|
+
threads with a queue depth of 100. Replaces direct `Thread.new` calls in core
|
|
110
|
+
agent and tool paths.
|
|
111
|
+
|
|
14
112
|
- **`VectorStore#size` — document count for all backends, contract coverage for RedisSearch and Pgvector** (#240):
|
|
15
113
|
`VectorStore::Base` gains `#size` as an abstract method; `InMemory`, `RedisSearch`,
|
|
16
114
|
and `Pgvector` all implement it. `RedisSearch#size` queries `FT.INFO num_docs`;
|
|
@@ -128,9 +226,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
128
226
|
`dispatch_parallel` and `fan_out` accept `cancellation_token:` and automatically
|
|
129
227
|
inject it into every worker task's config unless the task already supplies its own.
|
|
130
228
|
|
|
229
|
+
### Removed
|
|
230
|
+
|
|
231
|
+
- **BREAKING: `Agent::Base#run_as_child` drops `&result_writer` block parameter** (#265):
|
|
232
|
+
The optional block form `run_as_child(input, ctx: ctx) { |r| ctx.answer = r[:output] }`
|
|
233
|
+
is no longer supported. The result is now delivered **exclusively** as the
|
|
234
|
+
`:child_completed` event payload `{ output:, messages:, usage: }`. The parent
|
|
235
|
+
Workflow task is the sole owner of the `WorkflowContext`; no background thread
|
|
236
|
+
writes to it directly. Callers that were using the block to write back into the
|
|
237
|
+
context must update their workflow design (e.g. read the result in the target
|
|
238
|
+
state's entry action after the transition, or store output through an external
|
|
239
|
+
shared resource if needed).
|
|
240
|
+
|
|
241
|
+
- **BREAKING (internal): `AgentFSM#initialize` drops `result_writer:` keyword** (#265):
|
|
242
|
+
Direct callers of `AgentFSM.new(result_writer: ...)` must remove that keyword.
|
|
243
|
+
This class is considered internal; gem consumers should use `run_as_child` instead.
|
|
244
|
+
|
|
131
245
|
### Changed
|
|
132
246
|
|
|
133
|
-
- **`
|
|
247
|
+
- **`AgentFSM`, `ParallelToolChat`, and `Orchestrator` use `Task`/`TaskGroup` instead of bare `Thread.new`** (#257, #258, #259):
|
|
248
|
+
All three components now spawn async work through the `Task` and `TaskGroup`
|
|
249
|
+
abstractions. This enables cancellation propagation, context threading, and
|
|
250
|
+
`BlockingAdapterPool` routing. No public API changes; behaviour is equivalent.
|
|
251
|
+
|
|
252
|
+
- **`Thread.current[:phronomy_*]` context propagation replaced with explicit `InvocationContext`** (#260):
|
|
253
|
+
Thread-local keys `phronomy_event_loop_thread`, `phronomy_cancellation_token`,
|
|
254
|
+
and `phronomy_context_version_caches` are no longer used as the primary
|
|
255
|
+
propagation channel. `InvocationContext` is threaded explicitly through call
|
|
256
|
+
stacks. Importantly, `Tool::Base#call` no longer falls back to
|
|
257
|
+
`Thread.current[:phronomy_cancellation_token]`; cancellation is only observed
|
|
258
|
+
when the caller passes `cancellation_token:` explicitly (or when
|
|
259
|
+
`ParallelToolChat` injects it). Tools that relied on the thread-local fallback
|
|
260
|
+
must be updated.
|
|
261
|
+
|
|
262
|
+
- **`Timeout.timeout` removed from core paths; replaced with `CancellationScope`** (#261):
|
|
263
|
+
`Agent::Base#invoke` and `McpTool::StdioTransport` no longer use `Timeout.timeout`
|
|
264
|
+
(which is unsafe with `Thread.new` and `ensure` blocks). A `CancellationScope`
|
|
265
|
+
with `deadline_in(seconds)` provides equivalent semantics without the thread-
|
|
266
|
+
interruption hazards. `ScopeTimeoutError < TimeoutError` is raised on expiry.
|
|
267
|
+
|
|
268
|
+
- **RAG/VectorStore blocking I/O placed behind `BlockingAdapterPool` async boundary** (#267):
|
|
269
|
+
`KnowledgeSource#fetch` and all three `VectorStore` backends now execute their
|
|
270
|
+
blocking I/O through `Runtime#blocking_io` when a `Runtime` is present. Callers
|
|
271
|
+
in a synchronous context see no change; callers in an EventLoop context benefit
|
|
272
|
+
from non-blocking scheduler behaviour.
|
|
273
|
+
|
|
274
|
+
|
|
134
275
|
The cancellation token (passed via `config: { cancellation_token: token }`) is
|
|
135
276
|
now checked at multiple additional points beyond the initial LLM call boundary:
|
|
136
277
|
before each `KnowledgeSource#fetch` in `build_context` (RAG phase); after each
|
|
@@ -195,6 +336,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
195
336
|
|
|
196
337
|
### Fixed
|
|
197
338
|
|
|
339
|
+
- **`tool_name` preserved in `Orchestrator#prepare_tool_class` anonymous subclass wrapper**:
|
|
340
|
+
When `Orchestrator#prepare_tool_class` wrapped a subagent tool in an anonymous
|
|
341
|
+
subclass (`Class.new(prepared)`), the class-level instance variable `@tool_name`
|
|
342
|
+
was not inherited, causing the wrapper's `tool_name` to return `nil`. RubyLLM
|
|
343
|
+
then registered the tool under a `nil` key, making it unreachable when the LLM
|
|
344
|
+
called it by name. The fix captures the effective name before subclassing and
|
|
345
|
+
calls `tool_name effective_name` explicitly inside the anonymous class body —
|
|
346
|
+
the same pattern already used by the approval-gate wrapper.
|
|
347
|
+
|
|
198
348
|
- **`EventLoop#start` is now idempotent; stale `:__stop__` sentinel race fixed** (#203):
|
|
199
349
|
Calling `start` on an already-running `EventLoop` is now a no-op. Fixed a race condition
|
|
200
350
|
where `stop` setting `@running = false` before the worker thread was scheduled left the
|
data/README.md
CHANGED
|
@@ -22,31 +22,80 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
|
|
|
22
22
|
> **Note**: The `main` branch contains unreleased development work. Pin to a released gem
|
|
23
23
|
> version (`gem "phronomy", "~> 0.x"`) for stability in production.
|
|
24
24
|
|
|
25
|
+
**Core building blocks**
|
|
26
|
+
|
|
25
27
|
| Feature | Stability |
|
|
26
28
|
|---|---|
|
|
27
29
|
| **Workflow** — Stateful, branching workflows with wait_state/send_event | Stable |
|
|
28
|
-
| **Workflow
|
|
29
|
-
| **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 |
|
|
30
|
-
| **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 |
|
|
31
31
|
| **Agent** — ReAct-style tool-calling agents with guardrails and conversation history | Stable |
|
|
32
32
|
| **Before-Completion Hook** — Three-tier LLM parameter injection | Stable |
|
|
33
33
|
| **Context Management** — Token budget calculation, estimation, and pruning | Stable |
|
|
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 |
|
|
36
|
-
| **Multi-agent** — Agent-as-Tool pattern and hub-and-spoke handoff routing | Beta |
|
|
37
|
-
| **GeneratorVerifier** — Generator-Verifier loop with injectable prompt builders/parsers | Beta |
|
|
38
|
-
| **Agent::Orchestrator** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
|
|
39
|
-
| **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + stateful workers with sequential task assignment (worker-local message history persisted across tasks) | Beta |
|
|
40
|
-
| **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
|
|
41
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 |
|
|
42
37
|
| **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
|
|
43
38
|
| **Eval Framework** — Dataset-driven evaluation with multiple scorer types | Beta |
|
|
44
39
|
| **Tracing** — Pluggable span-based observability | Stable |
|
|
45
|
-
| **MCP Tool** — Model Context Protocol server integration | Beta |
|
|
46
40
|
| **Error Taxonomy** — `RateLimitError`, `AuthenticationError`, `ContextLengthError`, `TransportError` (subclasses of `Phronomy::Error`) raised at the agent retry boundary | Beta |
|
|
47
|
-
|
|
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
|
+
| **`Agent::Context::Knowledge::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 |
|
|
50
|
+
| **MCP Tool** — Model Context Protocol server integration | Beta |
|
|
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 |
|
|
48
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 |
|
|
49
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 |
|
|
50
99
|
|
|
51
100
|
## Installation
|
|
52
101
|
|
|
@@ -82,8 +131,8 @@ Install additional gems only for the features you use:
|
|
|
82
131
|
|
|
83
132
|
| Gem | Required for |
|
|
84
133
|
|-----|-------------|
|
|
85
|
-
| `pgvector` | `Phronomy::VectorStore::Pgvector` |
|
|
86
|
-
| `redis` | `Phronomy::VectorStore::RedisSearch` |
|
|
134
|
+
| `pgvector` | `Phronomy::Agent::Context::Knowledge::VectorStore::Pgvector` |
|
|
135
|
+
| `redis` | `Phronomy::Agent::Context::Knowledge::VectorStore::RedisSearch` |
|
|
87
136
|
| `opentelemetry-api` | `Phronomy::Tracing::OpenTelemetryTracer` |
|
|
88
137
|
|
|
89
138
|
## Quick Start
|
|
@@ -150,13 +199,16 @@ puts "Approved: #{final.approved}" # => true
|
|
|
150
199
|
```
|
|
151
200
|
|
|
152
201
|
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
|
|
154
|
-
|
|
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:
|
|
155
205
|
|
|
156
206
|
```ruby
|
|
157
|
-
# EventLoop mode: workflow that runs an agent as a child FSM
|
|
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.
|
|
158
210
|
entry :run_agent, ->(ctx) {
|
|
159
|
-
MyAgent.new.run_as_child(ctx.query, ctx: ctx)
|
|
211
|
+
MyAgent.new.run_as_child(ctx.query, ctx: ctx)
|
|
160
212
|
}
|
|
161
213
|
transition from: :run_agent, on: :child_completed, to: :done
|
|
162
214
|
transition from: :run_agent, on: :child_failed, to: :handle_error
|
|
@@ -222,24 +274,25 @@ rescue Phronomy::GuardrailError => e
|
|
|
222
274
|
end
|
|
223
275
|
```
|
|
224
276
|
|
|
225
|
-
> **
|
|
226
|
-
>
|
|
227
|
-
>
|
|
228
|
-
>
|
|
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).
|
|
229
282
|
|
|
230
283
|
### Knowledge/RAG — Context injection and vector retrieval
|
|
231
284
|
|
|
232
285
|
```ruby
|
|
233
286
|
# Static knowledge (policy files, reference docs)
|
|
234
|
-
policy = Phronomy::
|
|
287
|
+
policy = Phronomy::Agent::Context::Knowledge::Source::StaticKnowledge.new(
|
|
235
288
|
File.read("policy.md"),
|
|
236
289
|
type: :policy,
|
|
237
290
|
source: "policy.md" # exposed to LLM for citation
|
|
238
291
|
)
|
|
239
292
|
|
|
240
293
|
# RAG retrieval from a vector store
|
|
241
|
-
store = Phronomy::VectorStore::InMemory.new
|
|
242
|
-
embeddings = Phronomy::Embeddings::RubyLLMEmbeddings.new(model: "text-embedding-3-small")
|
|
294
|
+
store = Phronomy::Agent::Context::Knowledge::VectorStore::InMemory.new
|
|
295
|
+
embeddings = Phronomy::Agent::Context::Knowledge::Embeddings::RubyLLMEmbeddings.new(model: "text-embedding-3-small")
|
|
243
296
|
|
|
244
297
|
# Add documents before querying
|
|
245
298
|
text1 = "Refunds are processed within 5 business days."
|
|
@@ -247,7 +300,7 @@ text2 = "Contact support@example.com for refund requests."
|
|
|
247
300
|
store.add(id: "doc-1", embedding: embeddings.embed(text1), metadata: { content: text1, source: "policy.md" })
|
|
248
301
|
store.add(id: "doc-2", embedding: embeddings.embed(text2), metadata: { content: text2, source: "policy.md" })
|
|
249
302
|
|
|
250
|
-
rag = Phronomy::
|
|
303
|
+
rag = Phronomy::Agent::Context::Knowledge::Source::RAGKnowledge.new(store: store, embeddings: embeddings, k: 5)
|
|
251
304
|
|
|
252
305
|
# Inject at invocation time
|
|
253
306
|
result = MyAgent.new.invoke("What is the refund policy?",
|
|
@@ -266,8 +319,8 @@ MyAgent.static_knowledge_refresh!
|
|
|
266
319
|
Load and split documents with built-in loaders:
|
|
267
320
|
|
|
268
321
|
```ruby
|
|
269
|
-
chunks = Phronomy::Loader::MarkdownLoader.new.load("docs/guide.md")
|
|
270
|
-
.then { |docs| Phronomy::Splitter::RecursiveSplitter.new(chunk_size: 512).split(docs) }
|
|
322
|
+
chunks = Phronomy::Agent::Context::Knowledge::Loader::MarkdownLoader.new.load("docs/guide.md")
|
|
323
|
+
.then { |docs| Phronomy::Agent::Context::Knowledge::Splitter::RecursiveSplitter.new(chunk_size: 512).split(docs) }
|
|
271
324
|
```
|
|
272
325
|
|
|
273
326
|
### Multi-Agent Handoff — Hub-and-spoke routing
|
|
@@ -407,9 +460,11 @@ class MyOrchestrator < Phronomy::Agent::Orchestrator
|
|
|
407
460
|
end
|
|
408
461
|
```
|
|
409
462
|
|
|
410
|
-
### Workflow
|
|
463
|
+
### Workflow parallel pattern — Concurrent branches
|
|
411
464
|
|
|
412
|
-
Phronomy does not provide a
|
|
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:
|
|
413
468
|
|
|
414
469
|
```ruby
|
|
415
470
|
class EnrichContext
|
|
@@ -426,9 +481,9 @@ app = Phronomy::Workflow.define(EnrichContext) do
|
|
|
426
481
|
summary: Thread.new { Summarizer.call(s) },
|
|
427
482
|
tags: Thread.new { Tagger.call(s) }
|
|
428
483
|
}
|
|
429
|
-
# For
|
|
430
|
-
#
|
|
431
|
-
#
|
|
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.
|
|
432
487
|
threads.each_value(&:join)
|
|
433
488
|
s.merge(summary: threads[:summary].value, tags: Array(threads[:tags].value))
|
|
434
489
|
end
|
|
@@ -535,6 +590,8 @@ Phronomy.configure do |c|
|
|
|
535
590
|
c.trace_pii = false # default; set to true only when trace data contains no PII
|
|
536
591
|
c.logger = nil # optional; any object responding to #warn (e.g. Rails.logger)
|
|
537
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
|
|
538
595
|
end
|
|
539
596
|
```
|
|
540
597
|
|
|
@@ -546,6 +603,66 @@ end
|
|
|
546
603
|
> The default is `false` (PII protection enabled). Set to `true` only when
|
|
547
604
|
> trace data does not contain sensitive information.
|
|
548
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
|
|
663
|
+
end
|
|
664
|
+
```
|
|
665
|
+
|
|
549
666
|
## Context Management
|
|
550
667
|
|
|
551
668
|
Phronomy includes a context window management layer. When model metadata is
|
|
@@ -557,7 +674,7 @@ agents automatically stay within the configured token limit.
|
|
|
557
674
|
Derives the effective token budget from RubyLLM's model registry:
|
|
558
675
|
|
|
559
676
|
```ruby
|
|
560
|
-
budget = Phronomy::
|
|
677
|
+
budget = Phronomy::LlmContextWindow::TokenBudget.new(
|
|
561
678
|
model: "claude-3-5-sonnet-20241022", # looks up context_window + max_output_tokens
|
|
562
679
|
overhead: 500 # extra reservation for tool definitions
|
|
563
680
|
)
|
|
@@ -569,7 +686,7 @@ budget.effective_input_limit # => 191_308
|
|
|
569
686
|
Or supply explicit values (useful for local / unregistered models):
|
|
570
687
|
|
|
571
688
|
```ruby
|
|
572
|
-
budget = Phronomy::
|
|
689
|
+
budget = Phronomy::LlmContextWindow::TokenBudget.new(
|
|
573
690
|
context_window: 32_768,
|
|
574
691
|
max_output_tokens: 4_096
|
|
575
692
|
)
|
|
@@ -583,7 +700,7 @@ class MyAgent < Phronomy::Agent::Base
|
|
|
583
700
|
max_output_tokens 4096 # override max_output_tokens from registry
|
|
584
701
|
context_overhead 600 # extra reservation for system prompt + tools
|
|
585
702
|
invoke_timeout 30 # raise Phronomy::TimeoutError after 30 s (wait timeout, not cancellation)
|
|
586
|
-
max_parallel_tools 4 # cap concurrent tool
|
|
703
|
+
max_parallel_tools 4 # cap concurrent tool executions (default: 10)
|
|
587
704
|
end
|
|
588
705
|
```
|
|
589
706
|
|
|
@@ -599,7 +716,7 @@ registry the budget is silently skipped.
|
|
|
599
716
|
> ```ruby
|
|
600
717
|
> require "tiktoken_ruby"
|
|
601
718
|
> enc = Tiktoken.encoding_for_model("gpt-4o")
|
|
602
|
-
> Phronomy::
|
|
719
|
+
> Phronomy::LlmContextWindow::TokenEstimator.tokenizer = ->(text) { enc.encode(text).length }
|
|
603
720
|
> ```
|
|
604
721
|
|
|
605
722
|
|
|
@@ -624,12 +741,16 @@ blocks always execute.
|
|
|
624
741
|
> - Any external I/O (database query, vector search, HTTP request) inside those calls
|
|
625
742
|
>
|
|
626
743
|
> For deep in-flight safety, complement `CancellationToken` with per-source or
|
|
627
|
-
> per-tool timeouts
|
|
628
|
-
>
|
|
629
|
-
>
|
|
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.
|
|
630
751
|
|
|
631
752
|
```ruby
|
|
632
|
-
token = Phronomy::CancellationToken.new
|
|
753
|
+
token = Phronomy::Concurrency::CancellationToken.new
|
|
633
754
|
|
|
634
755
|
# Cancel from another thread after 5 s
|
|
635
756
|
Thread.new { sleep 5; token.cancel! }
|
|
@@ -641,15 +762,15 @@ rescue Phronomy::CancellationError
|
|
|
641
762
|
end
|
|
642
763
|
|
|
643
764
|
# Hard deadline via monotonic clock (recommended — immune to NTP/DST changes)
|
|
644
|
-
token = Phronomy::CancellationToken.timeout_after(30)
|
|
765
|
+
token = Phronomy::Concurrency::CancellationToken.timeout_after(30)
|
|
645
766
|
result = MyAgent.new.invoke("...", config: { cancellation_token: token })
|
|
646
767
|
|
|
647
768
|
# Hard deadline via wall-clock (legacy — still supported)
|
|
648
|
-
token = Phronomy::CancellationToken.new(deadline: Time.now + 30)
|
|
769
|
+
token = Phronomy::Concurrency::CancellationToken.new(deadline: Time.now + 30)
|
|
649
770
|
result = MyAgent.new.invoke("...", config: { cancellation_token: token })
|
|
650
771
|
|
|
651
772
|
# Propagate to all parallel workers via dispatch_parallel / fan_out
|
|
652
|
-
token = Phronomy::CancellationToken.new
|
|
773
|
+
token = Phronomy::Concurrency::CancellationToken.new
|
|
653
774
|
Thread.new { sleep 10; token.cancel! }
|
|
654
775
|
|
|
655
776
|
orchestrator.dispatch_parallel(
|
|
@@ -740,9 +861,11 @@ span attributes by default (`trace_pii: false`). To include full content in trac
|
|
|
740
861
|
Phronomy configuration. Evaluate whether your tracing backend (OTLP collector, Jaeger,
|
|
741
862
|
Honeycomb, etc.) meets your data-retention and privacy requirements.
|
|
742
863
|
|
|
743
|
-
**Prompt injection** — Phronomy provides
|
|
744
|
-
|
|
745
|
-
|
|
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).
|
|
746
869
|
|
|
747
870
|
**Tool and MCP security** — Tools can perform real-world side effects (database
|
|
748
871
|
writes, API calls, file deletion). Treat tool execution as a privileged operation:
|
data/Rakefile
CHANGED
|
@@ -7,4 +7,37 @@ RSpec::Core::RakeTask.new(:spec)
|
|
|
7
7
|
|
|
8
8
|
require "standard/rake"
|
|
9
9
|
|
|
10
|
+
# Verify that @api private classes do not leak into the public YARD output.
|
|
11
|
+
# Any class or module without @api private that ends up in the public doc must
|
|
12
|
+
# have a corresponding entry in the Features table in README.md.
|
|
13
|
+
#
|
|
14
|
+
# Usage: bundle exec rake yard_check
|
|
15
|
+
desc "Build YARD docs excluding @api private items and check for undocumented public APIs"
|
|
16
|
+
task :yard_check do
|
|
17
|
+
require "yard"
|
|
18
|
+
YARD::Registry.clear
|
|
19
|
+
YARD.parse(Dir["lib/**/*.rb"])
|
|
20
|
+
|
|
21
|
+
undocumented = []
|
|
22
|
+
YARD::Registry.all(:class, :module).each do |obj|
|
|
23
|
+
next if obj.visibility == :private
|
|
24
|
+
next if obj.tag(:api)&.name == "private"
|
|
25
|
+
next if obj.docstring.blank?
|
|
26
|
+
|
|
27
|
+
# Classes/modules with no docstring that are not @api private are worth
|
|
28
|
+
# noting, but only raise on truly undocumented public objects.
|
|
29
|
+
if obj.docstring.empty?
|
|
30
|
+
undocumented << obj.path
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
unless undocumented.empty?
|
|
35
|
+
warn "The following public classes/modules have no YARD documentation:\n" \
|
|
36
|
+
" #{undocumented.join("\n ")}\n" \
|
|
37
|
+
"Either add a docstring or mark them @api private."
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
puts "yard_check passed — no undocumented public classes/modules found."
|
|
41
|
+
end
|
|
42
|
+
|
|
10
43
|
task default: %i[spec standard]
|
data/benchmark/baseline.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"workflow_context_merge": 124364.81010472385,
|
|
3
3
|
"workflow_define": 2179.945274115319,
|
|
4
4
|
"tool_params_schema_definition": 19534379.159046534,
|
|
5
|
-
"dispatch_parallel_10":
|
|
5
|
+
"dispatch_parallel_10": 886.0,
|
|
6
6
|
"cancellation_token_cancelled": 4335060.97443425,
|
|
7
7
|
"cancellation_token_raise_if_cancelled_noop": 3566903.189098373,
|
|
8
8
|
"trim_context_remove_2000": 1761.5700678986254
|
|
@@ -12,9 +12,9 @@ BenchAsmMessage = Struct.new(:content)
|
|
|
12
12
|
|
|
13
13
|
def make_assembler(n_messages:, n_chunks:, with_budget: false)
|
|
14
14
|
budget = if with_budget
|
|
15
|
-
Phronomy::
|
|
15
|
+
Phronomy::LlmContextWindow::TokenBudget.new(context_window: 4096, max_output_tokens: 512)
|
|
16
16
|
end
|
|
17
|
-
asm = Phronomy::
|
|
17
|
+
asm = Phronomy::LlmContextWindow::Assembler.new(budget: budget)
|
|
18
18
|
asm.add_instruction("You are a helpful assistant. Answer the user's question.")
|
|
19
19
|
n_chunks.times do |i|
|
|
20
20
|
asm.add_knowledge("Fact #{i}: The capital of country #{i} is City #{i}.", type: :entity, trusted: true)
|