phronomy 0.15.1 → 0.17.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 -9
- data/CHANGELOG.md +159 -28
- data/CONTRIBUTING.md +28 -16
- data/README.md +400 -143
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +7 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +3 -19
- data/benchmark/bench_tool_schema.rb +2 -34
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +44 -46
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
- data/lib/phronomy/agent/agent_root.rb +66 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -475
- data/lib/phronomy/agent/base.rb +351 -514
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +357 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +134 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +60 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +10 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +17 -155
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +9 -115
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/api_snapshot.rb +1 -12
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -13
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
data/README.md
CHANGED
|
@@ -27,9 +27,9 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
|
|
|
27
27
|
| Feature | Stability |
|
|
28
28
|
|---|---|
|
|
29
29
|
| **Workflow** — Stateful, branching workflows with wait_state/send_event | Stable |
|
|
30
|
-
| **Agent** — ReAct-style tool-calling agents with guardrails and conversation
|
|
31
|
-
| **Before-
|
|
32
|
-
| **Context Management** —
|
|
30
|
+
| **Agent** — Stateful ReAct-style tool-calling agents with stable `agent_id`, persistence-backed execution state, canonical execution history, guardrails, and conversation context | Stable |
|
|
31
|
+
| **Before-LLM-Input Hook** — Three-tier per-call LLM input customization via `before_llm_input` and `LLMInputPatch` | Stable |
|
|
32
|
+
| **Context Management** — Canonical Journal + per-LLM-call Manifest architecture with token-budget-aware selection and protocol-safe Tool Call / Tool message dependencies. Context selection never deletes canonical execution history | Stable |
|
|
33
33
|
| **Filters** — Input/output transformation and blocking via `Filter::Base`; call `block!(reason)` to reject and raise `FilterBlockError` | Beta |
|
|
34
34
|
| **`PromptInjectionFilter`** — Built-in `Filter::Base` subclass that detects prompt-injection patterns; usable standalone or as part of a filter chain | Beta |
|
|
35
35
|
| **`Agent::Context::Capability::Base.redact_params` / `.max_result_size`** — Class-level DSL: `redact_params` masks parameter values in log/trace output; `max_result_size` truncates oversized tool results before they reach the LLM | Beta |
|
|
@@ -42,7 +42,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
|
|
|
42
42
|
|
|
43
43
|
| Feature | Stability |
|
|
44
44
|
|---|---|
|
|
45
|
-
| **Knowledge** —
|
|
45
|
+
| **Knowledge** — Journal-backed persistent Agent context registered with `knowledge:` / `add_knowledge`; selected per LLM call by Context Policy; `clear_knowledge!` logically resets retained Knowledge without deleting Journal history | Beta |
|
|
46
46
|
| **`VectorStore#size`** — Returns document count for all three backends (InMemory, RedisSearch, Pgvector) | Beta |
|
|
47
47
|
| **`VectorStore::AsyncBackend` mixin** — Pluggable async interface for `VectorStore`; default pool-backed implementations for `search_async`, `add_async`, `remove_async`, `clear_async`; backends with native async drivers override individual methods to bypass `BlockingAdapterPool` entirely; all existing backends remain unchanged | Beta |
|
|
48
48
|
| **MCP Tool** — `Phronomy::Tools::Mcp`: Model Context Protocol server integration via the official `mcp` gem; `Phronomy::Tools::Agent`: wraps an agent class as a callable tool via `from_agent` | Beta |
|
|
@@ -56,9 +56,8 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
|
|
|
56
56
|
| **`stream` / `stream_async`** — callbacks execute on the EventLoop thread and must return quickly; the block form remains a compatibility alias for `on_event:` | Beta |
|
|
57
57
|
| **`stream_callback_error_policy`** — Backward-compatible setting shared by `invoke_async` and `stream_async` terminal `on_event:` callbacks: `:report` (default) preserves the Agent result, while `:fail_task` fails the returned Task with `Phronomy::StreamCallbackError`; Agent execution errors are never replaced by callback errors | Beta |
|
|
58
58
|
| **`invoke_async` / `call_async`** — `Agent::Base#invoke_async` and `Workflow#invoke_async` return a `Task`; `Agent::Context::Capability::Base#call_async` similarly; compatible with EventLoop and standalone contexts | Stable |
|
|
59
|
-
| **`Task#map`** — transforms a Task's completed value and propagates failure/cancellation
|
|
60
|
-
| **CancellationToken** — Cooperative cancellation via `cancel!`/`cancelled?`/`raise_if_cancelled!`; `timeout_after(seconds)` for monotonic-clock deadlines;
|
|
61
|
-
| **`dispatch_parallel` / `fan_out` `force_kill:` option** — `force_kill: false` (default) leaves timed-out workers running and raises `TimeoutError` immediately; `force_kill: true` restores the old `Thread#kill` behaviour with a `logger.warn` | Beta |
|
|
59
|
+
| **`Task#map`** — transforms a Task's completed value and propagates failure/cancellation. `Task#map` remains available for application-level Task composition, but Workflow entry and transition actions must not return a Task | Stable |
|
|
60
|
+
| **CancellationToken** — Cooperative cancellation via `cancel!`/`cancelled?`/`raise_if_cancelled!`; `timeout_after(seconds)` for monotonic-clock deadlines; passed as `config: { cancellation_token: token }` to agents and `dispatch_parallel`; injected into `tool.execute` when the method declares a `cancellation_token:` keyword; bridged to `MCP::Cancellation` in `Phronomy::Tools::Mcp#execute` | Experimental |
|
|
62
61
|
| **`execution_mode` DSL on `Agent::Context::Capability::Base`** — Declares how a tool's `execute` should be dispatched: `:cooperative` (same scheduler thread), `:blocking_io` (default; offloaded to `BlockingAdapterPool`), `:cpu_bound`, `:external_process`; Tool-specific timeout/retry belongs to the Tool implementation or its client | Experimental |
|
|
63
62
|
| **`blocking_io_pool_size` / `blocking_io_queue_size`** — Configure the default `BlockingAdapterPool` via `Phronomy.configure { \|c\| c.blocking_io_pool_size = 20; c.blocking_io_queue_size = 200 }`; all LLM calls, MCP tool calls, and other blocking I/O share this pool; defaults: `pool_size: 10`, `queue_size: 100` | Beta |
|
|
64
63
|
| **`invocation_context:` keyword on `Agent#invoke` / `Workflow#invoke`** — Pass a `Phronomy::InvocationContext` directly; `thread_id`, `cancellation_token`, and `deadline`-based timeout are derived from it; `task_id` / `parent_task_id` appear in trace spans automatically; `config:` keys remain supported as backward-compat aliases | Beta |
|
|
@@ -78,7 +77,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Filters, an
|
|
|
78
77
|
| **`Phronomy::MultiAgent::Orchestrator`** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
|
|
79
78
|
| **`Phronomy::MultiAgent::TeamCoordinator`** — Agent teams pattern: LLM coordinator + stateful workers with sequential task assignment (worker-local message history persisted across tasks) | Beta |
|
|
80
79
|
| **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
|
|
81
|
-
| **Human-in-the-loop approval** — `Agent::Base#invoke` returns `{ suspended: true,
|
|
80
|
+
| **Human-in-the-loop approval** — `Agent::Base#invoke` returns `{ suspended: true, execution_id: String, approval_request: Phronomy::Agent::ToolApprovalRequest }` when approval is required. `#approve` / `#approve_async` resume that execution. Suspended execution state is stored in Persistence, but durable activation rehydration after a process restart is not yet supported | Beta |
|
|
82
81
|
| **`tool_approval_policy`** — Instance-level callable that maps each `ToolApprovalRequest` to `:allow`, `:require_approval`, or `:reject`; set on the agent instance before invoking | Beta |
|
|
83
82
|
| **`Filter::Base` — unified value filter interface** — `Phronomy::Filter::Base` with a single abstract method `call(value, **context)`; apply to user input (`add_input_filter` / `input_filter` DSL), final LLM output (`add_output_filter` / `output_filter` DSL), or individual tool return values (`add_tool_result_filter(tool_class?, filter)` / `tool_result_filter` DSL); filters transform values and return the result, or raise `Phronomy::FilterBlockError` to reject; filter chains are composable; the same filter instance can be reused across all three sites | Beta |
|
|
84
83
|
|
|
@@ -99,7 +98,7 @@ The APIs listed below are intended for advanced use cases, framework internals,
|
|
|
99
98
|
|---|---|
|
|
100
99
|
| **`Phronomy::Diagnostics`** — Snapshot of scheduler internals for debug/monitoring; `SchedulerReentrancyError` raised on invalid re-entrant scheduler use; `Runtime.in_scheduler_context?` returns `true` when called from inside a scheduler task | Experimental |
|
|
101
100
|
| **`Phronomy::Testing::FakeClock` / `FakeScheduler` / `SchedulerHelpers`** — Test helpers for deterministic concurrency specs: `FakeClock#advance(seconds)` controls time; `FakeScheduler` runs tasks synchronously and records `event_log`; `FakeScheduler#assert_order` / `#assert_cancelled` for ordering assertions; `FakeClock#advance_to_next_timer` fires the next pending callback; `Testing::SchedulerHelpers#with_fake_scheduler` replaces the global Runtime for the duration of a block | Beta |
|
|
102
|
-
| **`Configuration#runtime_backend`** — `:thread` (default, one OS thread per task), `:immediate` (tests — tasks run synchronously, no extra threads), `:fiber` (**EXPERIMENTAL** —
|
|
101
|
+
| **`Configuration#runtime_backend`** — `:thread` (default, one OS thread per task), `:immediate` (tests — tasks run synchronously, no extra threads), `:fiber` (**EXPERIMENTAL** — validation backend only: runs tasks as Ruby Fibers on a cooperative scheduler to verify that framework components are truly non-blocking; **not for production use** and not a planned production replacement for `:thread`; no preemptive scheduling will be added) | Beta |
|
|
103
102
|
| **`Configuration#strict_runtime_guards`** — When `true`, calling `Agent#invoke` from inside a scheduler task raises `SchedulerReentrancyError`; when `false` (default) a warning is logged instead | Beta |
|
|
104
103
|
|
|
105
104
|
## Installation
|
|
@@ -150,6 +149,28 @@ See the [RubyLLM documentation](https://rubyllm.com) for all supported providers
|
|
|
150
149
|
| `InvocationContext#provider_limits` | Configure the provider client directly |
|
|
151
150
|
| `stream_queue_max_size` | No replacement; the shared EventLoop queue is unbounded by design. Monitor `Metrics.snapshot[:event_loop_queue_depth]` instead |
|
|
152
151
|
|
|
152
|
+
### 0.16 cleanup migration
|
|
153
|
+
|
|
154
|
+
The following compatibility-only APIs have been removed from the active contract:
|
|
155
|
+
|
|
156
|
+
| Removed API | Current contract |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `context_overhead` | Manifest-first assembly budgets actual mandatory + selected content |
|
|
159
|
+
| Tool `on_error :return_empty` | Use `:raise` or `:suppress` |
|
|
160
|
+
| `dispatch_parallel(..., force_kill:)` / `fan_out(..., force_kill:)` | Cooperative cancellation; no force-kill switch |
|
|
161
|
+
| `runtime_backend = :cooperative` | Use `:thread`, `:immediate`, or experimental `:fiber` explicitly |
|
|
162
|
+
| `Runtime.instance = ...` | Runtime replacement is test/internal infrastructure, not a public setter |
|
|
163
|
+
| `TaskGroup.new(runtime: nil)` | Runtime is required |
|
|
164
|
+
| `tools ToolA, ToolB` | Use `tools(ToolA => nil, ToolB => nil)` |
|
|
165
|
+
| `CancellationToken.new(deadline: Time...)` | Use `CancellationToken.timeout_after(seconds)` for token deadlines |
|
|
166
|
+
| `StaticKnowledge` / `EntityKnowledge` / `Knowledge::Base` / `Phronomy::KnowledgeSource` | Register plain persistent Knowledge with `knowledge:` or `add_knowledge` |
|
|
167
|
+
| `static_knowledge*` class APIs | Persistent Knowledge belongs to Agent instances and is Journal-backed |
|
|
168
|
+
| `clear_memory!` | Use `clear_knowledge!`; conversation history is controlled independently with `clear_transcript!` |
|
|
169
|
+
|
|
170
|
+
The legacy `build_context` / `LlmContextWindow::Assembler` extension path is no
|
|
171
|
+
longer an active API. Stateful Agent input is assembled through the canonical
|
|
172
|
+
Journal → Context Policy → LLM Input Manifest pipeline.
|
|
173
|
+
|
|
153
174
|
### Optional dependencies
|
|
154
175
|
|
|
155
176
|
Install additional gems only for the features you use:
|
|
@@ -176,9 +197,10 @@ class WebSearch < Phronomy::Agent::Context::Capability::Base
|
|
|
176
197
|
end
|
|
177
198
|
|
|
178
199
|
class ResearchAgent < Phronomy::Agent::Base
|
|
200
|
+
agent_definition id: "research-agent", version: 1
|
|
179
201
|
model "gpt-4o"
|
|
180
202
|
instructions "You are a research assistant. Use tools to answer questions."
|
|
181
|
-
tools
|
|
203
|
+
tools(WebSearch => nil)
|
|
182
204
|
max_iterations 5
|
|
183
205
|
end
|
|
184
206
|
|
|
@@ -433,7 +455,7 @@ end
|
|
|
433
455
|
class OrchestratorAgent < Phronomy::Agent::Base
|
|
434
456
|
model "gpt-4o"
|
|
435
457
|
instructions "Use the research tool first, then the write tool to produce a blog post."
|
|
436
|
-
tools
|
|
458
|
+
tools(ResearchTool => nil, WriteTool => nil)
|
|
437
459
|
end
|
|
438
460
|
|
|
439
461
|
result = OrchestratorAgent.new.invoke("Write a blog post about Ruby 3.4 features")
|
|
@@ -469,39 +491,56 @@ end
|
|
|
469
491
|
> that logic must be implemented by the application. Reference implementations for
|
|
470
492
|
> common patterns are available in `phronomy-examples` (example 06).
|
|
471
493
|
|
|
472
|
-
### Knowledge —
|
|
494
|
+
### Knowledge — Persistent Agent context
|
|
495
|
+
|
|
496
|
+
Knowledge is plain logical content retained by one Agent and considered by
|
|
497
|
+
Context Policy for future LLM calls. It is not a separate source-object type and
|
|
498
|
+
it is not automatically mandatory.
|
|
499
|
+
|
|
500
|
+
Register initial Knowledge when creating the Agent:
|
|
473
501
|
|
|
474
502
|
```ruby
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
503
|
+
policy_text = File.read("policy.md")
|
|
504
|
+
|
|
505
|
+
agent = ResearchAgent.new(
|
|
506
|
+
knowledge: [
|
|
507
|
+
policy_text,
|
|
508
|
+
"Customer tier: enterprise"
|
|
509
|
+
]
|
|
480
510
|
)
|
|
511
|
+
```
|
|
481
512
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
513
|
+
Add durable Knowledge later:
|
|
514
|
+
|
|
515
|
+
```ruby
|
|
516
|
+
agent.add_knowledge(
|
|
517
|
+
"Customer locale: ja-JP",
|
|
518
|
+
metadata: {"origin" => "customer_profile"}
|
|
519
|
+
)
|
|
487
520
|
```
|
|
488
521
|
|
|
489
|
-
|
|
490
|
-
|
|
522
|
+
Persistent Knowledge is Journal-backed, survives `Agent.load`, and is excluded
|
|
523
|
+
from the public conversation transcript. `clear_knowledge!` logically
|
|
524
|
+
invalidates earlier Knowledge while keeping the append-only Journal intact.
|
|
491
525
|
|
|
492
526
|
```ruby
|
|
493
|
-
|
|
494
|
-
# Call refresh! when the underlying content changes (e.g. after reloading policy.md).
|
|
495
|
-
MyAgent.static_knowledge_refresh!
|
|
527
|
+
agent.clear_knowledge!
|
|
496
528
|
```
|
|
497
529
|
|
|
498
|
-
|
|
530
|
+
For request-scoped retrieval results that should not be persisted, use
|
|
531
|
+
`before_llm_input` `segment_candidates` instead.
|
|
532
|
+
|
|
533
|
+
Load and split documents with built-in loaders when the application needs an
|
|
534
|
+
acquisition/RAG pipeline:
|
|
499
535
|
|
|
500
536
|
```ruby
|
|
501
537
|
chunks = Phronomy::VectorStore::Loader::MarkdownLoader.new.load("docs/guide.md")
|
|
502
538
|
.then { |docs| Phronomy::VectorStore::Splitter::RecursiveSplitter.new(chunk_size: 512).split(docs) }
|
|
503
539
|
```
|
|
504
540
|
|
|
541
|
+
The application decides whether retrieved/extracted information becomes durable
|
|
542
|
+
Agent Knowledge (`add_knowledge`) or per-call Context (`before_llm_input`).
|
|
543
|
+
|
|
505
544
|
### Multi-Agent Handoff — Hub-and-spoke routing
|
|
506
545
|
|
|
507
546
|
```ruby
|
|
@@ -519,26 +558,106 @@ puts result[:output] # final answer
|
|
|
519
558
|
puts result[:agent].class # => BillingAgent
|
|
520
559
|
```
|
|
521
560
|
|
|
522
|
-
### Before-
|
|
561
|
+
### Before-LLM-Input Hook — Per-call LLM input customization
|
|
562
|
+
|
|
563
|
+
`before_llm_input` runs before every LLM call and allows an application to
|
|
564
|
+
customize that call without mutating the Agent, RubyLLM chat, or canonical
|
|
565
|
+
Journal state directly.
|
|
566
|
+
|
|
567
|
+
Hooks can be configured at three levels:
|
|
568
|
+
|
|
569
|
+
1. global — applies to every Agent
|
|
570
|
+
2. class — applies to every instance of one Agent definition
|
|
571
|
+
3. instance — applies only to one Agent instance
|
|
572
|
+
|
|
573
|
+
They run in that order: global → class → instance.
|
|
574
|
+
|
|
575
|
+
A hook receives an immutable `Phronomy::Agent::LLMInputBuildContext` containing
|
|
576
|
+
metadata about the LLM call:
|
|
577
|
+
|
|
578
|
+
- `agent_id`
|
|
579
|
+
- `agent_definition_id`
|
|
580
|
+
- `definition_version`
|
|
581
|
+
- `config`
|
|
582
|
+
- `call_sequence`
|
|
583
|
+
|
|
584
|
+
The hook does not receive the mutable Agent instance, RubyLLM messages, or
|
|
585
|
+
`RubyLLM::Chat`.
|
|
586
|
+
|
|
587
|
+
Return either:
|
|
588
|
+
|
|
589
|
+
- `nil` to leave the call unchanged, or
|
|
590
|
+
- a `Phronomy::Agent::LLMInputPatch`
|
|
591
|
+
|
|
592
|
+
### Class-level hook
|
|
523
593
|
|
|
524
594
|
```ruby
|
|
525
|
-
# Class-level: applies to all instances
|
|
526
595
|
class MyAgent < Phronomy::Agent::Base
|
|
596
|
+
agent_definition id: "my-agent", version: 1
|
|
597
|
+
|
|
527
598
|
model "gpt-4o"
|
|
528
|
-
|
|
599
|
+
|
|
600
|
+
before_llm_input ->(ctx) {
|
|
601
|
+
Phronomy::Agent::LLMInputPatch.new(
|
|
602
|
+
model_config_patch: {
|
|
603
|
+
temperature: ctx.config[:precise] ? 0.0 : 0.7
|
|
604
|
+
}
|
|
605
|
+
)
|
|
606
|
+
}
|
|
529
607
|
end
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
### Instance-level hook
|
|
530
611
|
|
|
531
|
-
|
|
612
|
+
```ruby
|
|
532
613
|
agent = MyAgent.new
|
|
533
|
-
agent.before_completion = ->(ctx) { { max_tokens: 512 } }
|
|
534
614
|
|
|
535
|
-
|
|
536
|
-
Phronomy.
|
|
537
|
-
|
|
615
|
+
agent.before_llm_input = ->(_ctx) {
|
|
616
|
+
Phronomy::Agent::LLMInputPatch.new(
|
|
617
|
+
model_config_patch: {
|
|
618
|
+
max_output_tokens: 512
|
|
619
|
+
}
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
### Global hook
|
|
625
|
+
|
|
626
|
+
```ruby
|
|
627
|
+
Phronomy.configure do |config|
|
|
628
|
+
config.before_llm_input = ->(_ctx) {
|
|
629
|
+
Phronomy::Agent::LLMInputPatch.new(
|
|
630
|
+
model_config_patch: {
|
|
631
|
+
temperature: 0.3
|
|
632
|
+
}
|
|
633
|
+
)
|
|
634
|
+
}
|
|
538
635
|
end
|
|
539
636
|
```
|
|
540
637
|
|
|
541
|
-
|
|
638
|
+
When multiple hooks provide `model_config_patch`, patches are merged in hook
|
|
639
|
+
order and later values win on key conflicts.
|
|
640
|
+
|
|
641
|
+
`LLMInputPatch` can also supply `segment_candidates` for additional per-call
|
|
642
|
+
context. Those candidates enter the same Context Policy selection path as
|
|
643
|
+
persistent/history candidates and are not automatically mandatory. They are
|
|
644
|
+
not persisted to the Journal.
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
Phronomy::Agent::LLMInputPatch.new(
|
|
648
|
+
segment_candidates: [
|
|
649
|
+
{
|
|
650
|
+
content: "The customer is on the enterprise plan.",
|
|
651
|
+
category: :knowledge,
|
|
652
|
+
role: :user
|
|
653
|
+
}
|
|
654
|
+
]
|
|
655
|
+
)
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
The Journal remains the canonical record of observed execution history and
|
|
659
|
+
persistent Knowledge. `before_llm_input` customizes only the logical candidate
|
|
660
|
+
set for a particular LLM call.
|
|
542
661
|
|
|
543
662
|
### GeneratorVerifier — Generator-Verifier loop with custom prompt builders
|
|
544
663
|
|
|
@@ -590,8 +709,7 @@ end
|
|
|
590
709
|
|
|
591
710
|
### MultiAgent::Orchestrator — Parallel subagent dispatch
|
|
592
711
|
|
|
593
|
-
> **Note:** `
|
|
594
|
-
> `max_concurrency:` to cap the number of concurrent workers and `on_error:`
|
|
712
|
+
> **Note:** Use `max_concurrency:` to cap concurrent workers and `on_error:`
|
|
595
713
|
> to control failure handling (`:raise` re-raises the first error after all
|
|
596
714
|
> tasks complete; `:skip` fills failed slots with `nil`). For very large
|
|
597
715
|
> fan-outs consider additional rate-limiting at the application level.
|
|
@@ -617,7 +735,6 @@ class MyOrchestrator < Phronomy::MultiAgent::Orchestrator
|
|
|
617
735
|
instructions "Orchestrate."
|
|
618
736
|
|
|
619
737
|
def run(query)
|
|
620
|
-
# Heterogeneous agents in parallel (cap at 4 threads; skip failures; 30 s timeout)
|
|
621
738
|
results = dispatch_parallel(
|
|
622
739
|
{agent: SearchAgent, input: "topic A"},
|
|
623
740
|
{agent: AnalysisAgent, input: query},
|
|
@@ -626,7 +743,6 @@ class MyOrchestrator < Phronomy::MultiAgent::Orchestrator
|
|
|
626
743
|
timeout: 30
|
|
627
744
|
)
|
|
628
745
|
|
|
629
|
-
# Fan-out — same agent, multiple inputs
|
|
630
746
|
translations = fan_out(
|
|
631
747
|
agent: TranslationAgent,
|
|
632
748
|
inputs: %w[Hello World],
|
|
@@ -655,14 +771,10 @@ end
|
|
|
655
771
|
app = Phronomy::Workflow.define(EnrichContext) do
|
|
656
772
|
initial :enrich
|
|
657
773
|
state :enrich, action: ->(s) do
|
|
658
|
-
# Use Thread#value to collect results safely — avoids concurrent Hash writes
|
|
659
774
|
threads = {
|
|
660
775
|
summary: Thread.new { Summarizer.call(s) },
|
|
661
776
|
tags: Thread.new { Tagger.call(s) }
|
|
662
777
|
}
|
|
663
|
-
# For bounded waits, use Thread#join(timeout_seconds); nil means timed out — handle explicitly.
|
|
664
|
-
# Do not use Timeout.timeout or Thread#kill — both inject async exceptions that bypass cleanup.
|
|
665
|
-
# Prefer CancellationToken for cooperative cancellation of Phronomy-managed tasks.
|
|
666
778
|
threads.each_value(&:join)
|
|
667
779
|
s.merge(summary: threads[:summary].value, tags: Array(threads[:tags].value))
|
|
668
780
|
end
|
|
@@ -675,12 +787,10 @@ state = app.invoke({}, config: { thread_id: "t1" })
|
|
|
675
787
|
### Output Parser — Structured LLM responses
|
|
676
788
|
|
|
677
789
|
```ruby
|
|
678
|
-
# Extract JSON from LLM output (handles Markdown code fences automatically)
|
|
679
790
|
parser = Phronomy::OutputParser::JsonParser.new
|
|
680
791
|
data = parser.parse('```json\n{"name":"Alice","score":0.9}\n```')
|
|
681
792
|
# => { name: "Alice", score: 0.9 }
|
|
682
793
|
|
|
683
|
-
# Map JSON directly to a Struct
|
|
684
794
|
PersonSchema = Struct.new(:name, :age, keyword_init: true)
|
|
685
795
|
parser = Phronomy::OutputParser::StructuredParser.new(PersonSchema)
|
|
686
796
|
person = parser.parse('{"name":"Alice","age":30}')
|
|
@@ -703,15 +813,15 @@ runner = Phronomy::Eval::Runner.new(
|
|
|
703
813
|
results = runner.run(dataset, ->(q) { agent.invoke(q) })
|
|
704
814
|
metrics = Phronomy::Eval::Metrics.new(results)
|
|
705
815
|
|
|
706
|
-
puts "Mean score: #{metrics.mean_score}"
|
|
707
|
-
puts "Pass rate: #{metrics.pass_rate}"
|
|
816
|
+
puts "Mean score: #{metrics.mean_score}"
|
|
817
|
+
puts "Pass rate: #{metrics.pass_rate}"
|
|
708
818
|
```
|
|
709
819
|
|
|
710
820
|
### Tracing — Custom observability
|
|
711
821
|
|
|
712
822
|
```ruby
|
|
713
823
|
Phronomy.configure do |c|
|
|
714
|
-
c.tracer = MyCustomTracer.new
|
|
824
|
+
c.tracer = MyCustomTracer.new
|
|
715
825
|
end
|
|
716
826
|
```
|
|
717
827
|
|
|
@@ -735,32 +845,79 @@ child process (stdio transport) or release the HTTP connection:
|
|
|
735
845
|
search_tool.close
|
|
736
846
|
```
|
|
737
847
|
|
|
738
|
-
###
|
|
848
|
+
### Agent State and Conversation History
|
|
849
|
+
|
|
850
|
+
Phronomy Agents are stateful objects. Each Agent has a stable `agent_id`, a persistent Agent root, an append-only execution Journal, and zero or more Agent executions.
|
|
851
|
+
|
|
852
|
+
Every concrete Agent definition must declare a stable definition identity:
|
|
853
|
+
|
|
854
|
+
```ruby
|
|
855
|
+
class ResearchAgent < Phronomy::Agent::Base
|
|
856
|
+
agent_definition id: "research-agent", version: 1
|
|
857
|
+
|
|
858
|
+
model "gpt-4o"
|
|
859
|
+
instructions "You are a research assistant."
|
|
860
|
+
end
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
The definition ID identifies the application-level Agent definition. The version is checked when a previously persisted Agent is loaded so that persisted state is not silently interpreted by an incompatible Agent definition.
|
|
864
|
+
|
|
865
|
+
Create and continue using the same Agent instance normally:
|
|
866
|
+
|
|
867
|
+
```ruby
|
|
868
|
+
persistence = Phronomy::Persistence::InMemory.new
|
|
869
|
+
|
|
870
|
+
agent = ResearchAgent.create(
|
|
871
|
+
agent_id: "research-session-42",
|
|
872
|
+
knowledge: ["Customer tier: enterprise"],
|
|
873
|
+
persistence: persistence
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
agent.invoke("My name is Alice.")
|
|
877
|
+
agent.add_knowledge("Customer locale: ja-JP")
|
|
878
|
+
result = agent.invoke("What is my name?")
|
|
879
|
+
|
|
880
|
+
puts result[:output]
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
Conversation history does not need to be passed back through `messages:` on every invocation. The Agent's canonical history is retained in its Journal and selected automatically when later LLM Calls are assembled.
|
|
884
|
+
|
|
885
|
+
A persisted Agent can be loaded again when the same Persistence backend is available:
|
|
886
|
+
|
|
887
|
+
```ruby
|
|
888
|
+
agent = ResearchAgent.load(
|
|
889
|
+
"research-session-42",
|
|
890
|
+
persistence: persistence
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
result = agent.invoke("Continue our previous discussion.")
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
`result[:messages]` remains available as a materialized transcript of the Agent's current conversation history. It is a projection of canonical Agent state, not the authoritative storage mechanism and does not need to be supplied to the next `invoke`.
|
|
739
897
|
|
|
740
|
-
|
|
741
|
-
message array and passes it in via the `messages:` keyword argument:
|
|
898
|
+
Existing external conversation history can be supplied when a new Agent is created:
|
|
742
899
|
|
|
743
900
|
```ruby
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
# Second turn — pass prior messages so the agent has context
|
|
749
|
-
result2 = MyAgent.new.invoke(
|
|
750
|
-
"What is my name?",
|
|
751
|
-
messages: prior_messages,
|
|
752
|
-
thread_id: "session-1"
|
|
901
|
+
agent = ResearchAgent.create(
|
|
902
|
+
context: existing_messages,
|
|
903
|
+
knowledge: initial_knowledge,
|
|
904
|
+
persistence: persistence
|
|
753
905
|
)
|
|
754
|
-
puts result2[:output] # => "Your name is Alice."
|
|
755
906
|
```
|
|
756
907
|
|
|
757
|
-
|
|
758
|
-
|
|
908
|
+
Imported history must satisfy Phronomy's Import contract. User, assistant, and Tool messages are journaled without destroying their logical message boundaries. System instructions are Agent configuration and are not imported as ordinary conversation messages.
|
|
909
|
+
|
|
910
|
+
`thread_id` is an execution correlation identifier. It does not identify the persistent Agent and is not a substitute for `agent_id`.
|
|
759
911
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
912
|
+
The active conversation and Knowledge views can be advanced independently without deleting the canonical Journal:
|
|
913
|
+
|
|
914
|
+
```ruby
|
|
915
|
+
agent.clear_transcript! # conversation only
|
|
916
|
+
agent.clear_knowledge! # persistent Knowledge only
|
|
917
|
+
agent.reset_context! # both
|
|
918
|
+
```
|
|
763
919
|
|
|
920
|
+
`purge!` is different: it permanently removes the Agent and its persisted execution history from the configured Persistence backend.
|
|
764
921
|
|
|
765
922
|
## Configuration
|
|
766
923
|
|
|
@@ -769,11 +926,11 @@ Phronomy.configure do |c|
|
|
|
769
926
|
c.default_model = "gpt-4o-mini"
|
|
770
927
|
c.recursion_limit = 25
|
|
771
928
|
c.tracer = Phronomy::Tracing::NullTracer.new
|
|
772
|
-
c.
|
|
929
|
+
c.before_llm_input = nil # optional global before_llm_input hook
|
|
773
930
|
c.trace_pii = false # default; set to true only when trace data contains no PII
|
|
774
931
|
c.logger = nil # optional; any object responding to #warn (e.g. Rails.logger)
|
|
775
932
|
c.event_loop_stop_grace_seconds = 5 # seconds to wait for sessions to drain on shutdown
|
|
776
|
-
c.runtime_backend = :thread # :thread (default); :immediate (tests, synchronous); :fiber (experimental validation only)
|
|
933
|
+
c.runtime_backend = :thread # :thread (default); :immediate (tests, synchronous); :fiber (experimental validation only)
|
|
777
934
|
c.strict_runtime_guards = false # when true, raises SchedulerReentrancyError on invoke-inside-task
|
|
778
935
|
c.stream_callback_error_policy = :report # :report (default) preserves Agent result; :fail_task fails Task with StreamCallbackError
|
|
779
936
|
end
|
|
@@ -796,7 +953,7 @@ Understanding when to use each prevents scheduler stalls and hidden deadlocks.
|
|
|
796
953
|
| Context | Recommended API |
|
|
797
954
|
|---------|----------------|
|
|
798
955
|
| Top-level application code, Rails controller, background job | `agent.invoke(input)` — blocks the calling thread until done |
|
|
799
|
-
| Workflow action
|
|
956
|
+
| Workflow action | Start `invoke_async` and use `Workflow#signal` in the `on_event:` callback to deliver the result as a later Workflow event |
|
|
800
957
|
| Top-level code that wants explicit async | `agent.invoke_async(input).wait_result` — blocks the calling thread until the Task completes |
|
|
801
958
|
| Streaming from top-level code | `agent.stream(input) { |event| ... }` — blocks until done; callbacks run on the EventLoop thread |
|
|
802
959
|
| Streaming non-blocking | `task = agent.stream_async(input) { |event| ... }` — returns Task immediately; callbacks run on the EventLoop thread |
|
|
@@ -805,51 +962,133 @@ Understanding when to use each prevents scheduler stalls and hidden deadlocks.
|
|
|
805
962
|
|
|
806
963
|
### Why this matters
|
|
807
964
|
|
|
808
|
-
`invoke` is a synchronous wrapper
|
|
809
|
-
thread until the
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
965
|
+
`invoke` is a synchronous wrapper around asynchronous Agent execution and blocks
|
|
966
|
+
the calling thread until the Agent finishes. It is appropriate for top-level
|
|
967
|
+
application code such as CLI commands, controller actions, or background jobs.
|
|
968
|
+
|
|
969
|
+
Workflow entry and transition actions have a different contract: they are
|
|
970
|
+
synchronous Run-to-Completion callbacks and must finish promptly.
|
|
971
|
+
|
|
972
|
+
If a Workflow action needs an Agent or another asynchronous operation, start the
|
|
973
|
+
operation asynchronously, register its completion listener, return the Workflow
|
|
974
|
+
context, and use `Workflow#signal` to deliver completion as a later Workflow
|
|
975
|
+
event.
|
|
976
|
+
|
|
977
|
+
Do not call blocking `Agent#invoke` from an EventLoop callback, and do not return
|
|
978
|
+
the `Task` from `Agent#invoke_async` as the result of a Workflow entry or
|
|
979
|
+
transition action.
|
|
813
980
|
|
|
814
981
|
### Runtime guard
|
|
815
982
|
|
|
816
983
|
Phronomy detects this pattern automatically:
|
|
817
984
|
|
|
818
985
|
```ruby
|
|
819
|
-
# Default (soft mode): logs a warning and continues
|
|
820
986
|
Phronomy.configure { |c| c.strict_runtime_guards = false }
|
|
821
|
-
|
|
822
|
-
# Strict mode: raises SchedulerReentrancyError immediately
|
|
823
987
|
Phronomy.configure { |c| c.strict_runtime_guards = true }
|
|
824
988
|
```
|
|
825
989
|
|
|
826
990
|
You can also query the current context directly:
|
|
827
991
|
|
|
828
992
|
```ruby
|
|
829
|
-
Phronomy::Runtime.in_scheduler_context?
|
|
993
|
+
Phronomy::Runtime.in_scheduler_context?
|
|
830
994
|
```
|
|
831
995
|
|
|
832
996
|
### Migration: blocking wait → Task mapping
|
|
833
997
|
|
|
834
998
|
```ruby
|
|
835
|
-
# Top-level synchronous use
|
|
836
999
|
result = my_agent.invoke("Hello")
|
|
837
|
-
|
|
838
|
-
# Explicit async from top-level code
|
|
839
1000
|
result = my_agent.invoke_async("Hello").wait_result
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
### Async work inside a Workflow
|
|
1004
|
+
|
|
1005
|
+
Workflow entry and transition actions are synchronous Run-to-Completion
|
|
1006
|
+
callbacks.
|
|
1007
|
+
|
|
1008
|
+
They may start asynchronous work, but they must return the Workflow context (or
|
|
1009
|
+
`nil`). Returning a `Phronomy::Task` from an entry or transition action is an
|
|
1010
|
+
error.
|
|
1011
|
+
|
|
1012
|
+
When an asynchronous Agent finishes, deliver its result back to the live
|
|
1013
|
+
Workflow as a later event with `Workflow#signal`.
|
|
1014
|
+
|
|
1015
|
+
```ruby
|
|
1016
|
+
class AnswerContext
|
|
1017
|
+
include Phronomy::WorkflowContext
|
|
1018
|
+
|
|
1019
|
+
field :question, type: :replace, default: ""
|
|
1020
|
+
field :answer, type: :replace, default: nil
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
workflow = nil
|
|
1024
|
+
|
|
1025
|
+
workflow = Phronomy::Workflow.define(AnswerContext) do
|
|
1026
|
+
initial :asking
|
|
1027
|
+
|
|
1028
|
+
state :asking
|
|
1029
|
+
state :done
|
|
1030
|
+
|
|
1031
|
+
entry :asking, ->(ctx) {
|
|
1032
|
+
thread_id = ctx.thread_id
|
|
1033
|
+
|
|
1034
|
+
my_agent.invoke_async(
|
|
1035
|
+
ctx.question,
|
|
1036
|
+
on_event: ->(event) {
|
|
1037
|
+
next unless event.type == :done
|
|
1038
|
+
|
|
1039
|
+
workflow.signal(
|
|
1040
|
+
thread_id: thread_id,
|
|
1041
|
+
event: :answer_ready,
|
|
1042
|
+
payload: { answer: event.payload[:output] }
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
)
|
|
840
1046
|
|
|
841
|
-
|
|
842
|
-
NODE = ->(ctx) {
|
|
843
|
-
my_agent.invoke_async("Hello").map { |result|
|
|
844
|
-
ctx.merge(answer: result[:output])
|
|
1047
|
+
ctx
|
|
845
1048
|
}
|
|
846
|
-
|
|
1049
|
+
|
|
1050
|
+
transition(
|
|
1051
|
+
from: :asking,
|
|
1052
|
+
on: :answer_ready,
|
|
1053
|
+
to: :done,
|
|
1054
|
+
action: ->(ctx, event) {
|
|
1055
|
+
ctx.merge(answer: event.payload[:answer])
|
|
1056
|
+
}
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
transition from: :done, to: :__finish__
|
|
1060
|
+
end
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
The important separation is:
|
|
1064
|
+
|
|
1065
|
+
```text
|
|
1066
|
+
Workflow action
|
|
1067
|
+
│
|
|
1068
|
+
├─ starts asynchronous work
|
|
1069
|
+
│
|
|
1070
|
+
└─ returns context immediately
|
|
1071
|
+
│
|
|
1072
|
+
▼
|
|
1073
|
+
asynchronous Agent
|
|
1074
|
+
│
|
|
1075
|
+
▼
|
|
1076
|
+
on_event / callback
|
|
1077
|
+
│
|
|
1078
|
+
▼
|
|
1079
|
+
Workflow#signal
|
|
1080
|
+
│
|
|
1081
|
+
▼
|
|
1082
|
+
later Workflow event
|
|
847
1083
|
```
|
|
848
1084
|
|
|
1085
|
+
`Task#map` remains a valid Task API for transforming Task results, but a mapped
|
|
1086
|
+
Task must not be returned from a Workflow entry or transition action.
|
|
1087
|
+
|
|
849
1088
|
### :immediate backend (synchronous / test mode)
|
|
850
1089
|
|
|
851
1090
|
The `:immediate` backend runs tasks synchronously using `FakeScheduler`
|
|
852
|
-
(backed by `Task::ImmediateBackend`).
|
|
1091
|
+
(backed by `Task::ImmediateBackend`). Blocking I/O is isolated in `BlockingAdapterPool`.
|
|
853
1092
|
To switch back to the default thread-per-task backend:
|
|
854
1093
|
|
|
855
1094
|
```ruby
|
|
@@ -863,46 +1102,79 @@ end
|
|
|
863
1102
|
|
|
864
1103
|
## Context Management
|
|
865
1104
|
|
|
866
|
-
Phronomy
|
|
867
|
-
available (either from the built-in registry or via an explicit `context_window:` setting),
|
|
868
|
-
agents automatically stay within the configured token limit.
|
|
1105
|
+
Phronomy uses a Manifest-first context architecture for stateful Agents.
|
|
869
1106
|
|
|
870
|
-
|
|
1107
|
+
```text
|
|
1108
|
+
Canonical Journal
|
|
1109
|
+
↓
|
|
1110
|
+
Context candidates
|
|
1111
|
+
↓
|
|
1112
|
+
Context Policy
|
|
1113
|
+
↓
|
|
1114
|
+
LLM Call Manifest
|
|
1115
|
+
↓
|
|
1116
|
+
Runtime Projection
|
|
1117
|
+
↓
|
|
1118
|
+
RubyLLM / Provider
|
|
1119
|
+
```
|
|
871
1120
|
|
|
872
|
-
|
|
1121
|
+
The **Journal** is the canonical append-only record of logical execution facts
|
|
1122
|
+
observed by Phronomy and persistent Knowledge explicitly registered by the
|
|
1123
|
+
application.
|
|
873
1124
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
model: "claude-3-5-sonnet-20241022", # looks up context_window + max_output_tokens
|
|
877
|
-
overhead: 500 # extra reservation for tool definitions
|
|
878
|
-
)
|
|
879
|
-
budget.context_window # => 200_000
|
|
880
|
-
budget.max_output_tokens # => 8_192
|
|
881
|
-
budget.effective_input_limit # => 191_308
|
|
882
|
-
```
|
|
1125
|
+
The **Manifest** is the canonical logical input fixed for one particular LLM
|
|
1126
|
+
Call.
|
|
883
1127
|
|
|
884
|
-
|
|
1128
|
+
Context-window management therefore does not trim or rewrite the Agent's
|
|
1129
|
+
canonical history. Phronomy selects the subset of available optional Context
|
|
1130
|
+
needed for each LLM Call and records that selection in the Manifest.
|
|
885
1131
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1132
|
+
Persistent Knowledge is an ordinary `:knowledge` Context candidate. It is not
|
|
1133
|
+
concatenated into the mandatory system prompt. Conversation history and
|
|
1134
|
+
Knowledge share the same policy/budget selection path while remaining distinct
|
|
1135
|
+
in public transcript semantics.
|
|
1136
|
+
|
|
1137
|
+
Per-call `before_llm_input` segment candidates also pass through Context Policy
|
|
1138
|
+
and are not written to the Journal.
|
|
1139
|
+
|
|
1140
|
+
Tool protocol dependencies are preserved during selection. An assistant message
|
|
1141
|
+
containing Tool Calls and the corresponding Tool-role messages are selected as
|
|
1142
|
+
a protocol-safe unit rather than independently pruning messages in a way that
|
|
1143
|
+
would create an invalid LLM conversation.
|
|
1144
|
+
|
|
1145
|
+
When the available budget is insufficient, optional history or Knowledge can be
|
|
1146
|
+
omitted from the current Manifest. Required context is never silently removed
|
|
1147
|
+
merely to satisfy the budget. If required input cannot fit, Phronomy raises
|
|
1148
|
+
`ContextBudgetExceededError`.
|
|
892
1149
|
|
|
893
|
-
###
|
|
1150
|
+
### Context-window configuration
|
|
1151
|
+
|
|
1152
|
+
Phronomy derives the effective context budget from RubyLLM model metadata when available.
|
|
1153
|
+
|
|
1154
|
+
For local or otherwise unregistered models, the context window can be declared explicitly:
|
|
894
1155
|
|
|
895
1156
|
```ruby
|
|
896
|
-
class
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1157
|
+
class LocalAgent < Phronomy::Agent::Base
|
|
1158
|
+
agent_definition id: "local-agent", version: 1
|
|
1159
|
+
|
|
1160
|
+
model "local-model"
|
|
1161
|
+
context_window 32_768
|
|
1162
|
+
max_output_tokens 4_096
|
|
901
1163
|
end
|
|
902
1164
|
```
|
|
903
1165
|
|
|
904
|
-
`
|
|
905
|
-
|
|
1166
|
+
`context_window` determines the model's total context capacity.
|
|
1167
|
+
|
|
1168
|
+
`max_output_tokens` reserves capacity for the model's output.
|
|
1169
|
+
|
|
1170
|
+
Mandatory instructions, current input and Tool definitions are budgeted from
|
|
1171
|
+
their actual canonical values. `context_overhead` is not part of the current
|
|
1172
|
+
contract.
|
|
1173
|
+
|
|
1174
|
+
The current default Context Policy is framework-managed. Public custom Context
|
|
1175
|
+
Policy APIs, deterministic persistent compaction, and other advanced policy
|
|
1176
|
+
extension points are still evolving and should not yet be treated as stable
|
|
1177
|
+
application APIs.
|
|
906
1178
|
|
|
907
1179
|
> **Note on CJK languages**: The default `TokenEstimator` uses a character-ratio heuristic
|
|
908
1180
|
> calibrated for ASCII/Latin text (4 chars/token). For Chinese, Japanese, and Korean text,
|
|
@@ -916,15 +1188,14 @@ registry the budget is silently skipped.
|
|
|
916
1188
|
> Phronomy::LlmContextWindow::TokenEstimator.tokenizer = ->(text) { enc.encode(text).length }
|
|
917
1189
|
> ```
|
|
918
1190
|
|
|
919
|
-
|
|
920
1191
|
### CancellationToken — Cooperative cancellation
|
|
921
1192
|
|
|
922
1193
|
Pass a `CancellationToken` to any agent via `config: { cancellation_token: token }`.
|
|
923
1194
|
Cancellation is checked at multiple granular checkpoints: before the LLM call,
|
|
924
|
-
after each streaming chunk, before each parallel
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
blocks always execute.
|
|
1195
|
+
after each streaming chunk, before each parallel tool-call batch, and after each
|
|
1196
|
+
`before_llm_input` hook. `CancellationError` is raised immediately. Phronomy
|
|
1197
|
+
does not replay the complete Agent invocation. No threads are force-killed —
|
|
1198
|
+
`ensure` blocks always execute.
|
|
928
1199
|
|
|
929
1200
|
> **Cooperative cancellation — not preemptive**
|
|
930
1201
|
>
|
|
@@ -932,19 +1203,14 @@ blocks always execute.
|
|
|
932
1203
|
> checkpoints listed above; it is **not** injected as a signal into a running
|
|
933
1204
|
> operation. This means the following are **not** interrupted mid-execution:
|
|
934
1205
|
>
|
|
935
|
-
> -
|
|
1206
|
+
> - An application retrieval/load operation that is already blocking
|
|
936
1207
|
> - A single `chat.ask` call that is not streaming
|
|
937
1208
|
> - A single `tool.execute` call that is already running
|
|
938
1209
|
> - Any external I/O (database query, vector search, HTTP request) inside those calls
|
|
939
1210
|
>
|
|
940
|
-
> For deep in-flight safety, complement `CancellationToken` with
|
|
941
|
-
>
|
|
942
|
-
> database `statement_timeout`, or Redis client timeout
|
|
943
|
-
> to abort cleanly. Avoid `Timeout.timeout` unless you understand its async-exception
|
|
944
|
-
> risks: it injects `Timeout::Error` at an arbitrary execution point (the same
|
|
945
|
-
> mechanism as `Thread#kill`), which Phronomy avoids by default due to resource
|
|
946
|
-
> safety concerns. Ruby's GVL prevents fully preemptive cancellation without such
|
|
947
|
-
> risky interruption.
|
|
1211
|
+
> For deep in-flight safety, complement `CancellationToken` with operation-native
|
|
1212
|
+
> timeouts. Prefer library-native timeouts such as `Net::HTTP#read_timeout`,
|
|
1213
|
+
> database `statement_timeout`, or Redis client timeout.
|
|
948
1214
|
|
|
949
1215
|
> **`timeout_after` vs `CancellationScope.deadline_in`**
|
|
950
1216
|
>
|
|
@@ -970,18 +1236,16 @@ blocks always execute.
|
|
|
970
1236
|
> Phronomy does not interpret `config[:llm_timeout]`, `config[:tool_timeout]`,
|
|
971
1237
|
> Agent `retry_policy`, or Tool `retry_on`. Configure LLM transport behavior on
|
|
972
1238
|
> RubyLLM (or another adapter) and configure Tool transport behavior on the Tool's
|
|
973
|
-
> HTTP/DB/MCP client.
|
|
974
|
-
> the timeout and retry semantics.
|
|
1239
|
+
> HTTP/DB/MCP client.
|
|
975
1240
|
>
|
|
976
1241
|
> `InvocationContext#deadline` and `cancellation_token` remain available for a
|
|
977
1242
|
> caller-defined root-operation boundary. They provide cooperative cancellation
|
|
978
1243
|
> across the Phronomy execution tree; they do not replace provider-native socket,
|
|
979
1244
|
> request, statement, or session timeouts.
|
|
980
|
-
|
|
1245
|
+
|
|
981
1246
|
```ruby
|
|
982
1247
|
token = Phronomy::Concurrency::CancellationToken.new
|
|
983
1248
|
|
|
984
|
-
# Cancel from another thread after 5 s
|
|
985
1249
|
Thread.new { sleep 5; token.cancel! }
|
|
986
1250
|
|
|
987
1251
|
begin
|
|
@@ -990,15 +1254,9 @@ rescue Phronomy::CancellationError
|
|
|
990
1254
|
puts "cancelled"
|
|
991
1255
|
end
|
|
992
1256
|
|
|
993
|
-
# Hard deadline via monotonic clock (recommended — immune to NTP/DST changes)
|
|
994
1257
|
token = Phronomy::Concurrency::CancellationToken.timeout_after(30)
|
|
995
1258
|
result = MyAgent.new.invoke("...", config: { cancellation_token: token })
|
|
996
1259
|
|
|
997
|
-
# Hard deadline via wall-clock (legacy — still supported)
|
|
998
|
-
token = Phronomy::Concurrency::CancellationToken.new(deadline: Time.now + 30)
|
|
999
|
-
result = MyAgent.new.invoke("...", config: { cancellation_token: token })
|
|
1000
|
-
|
|
1001
|
-
# Propagate to all parallel workers via dispatch_parallel / fan_out
|
|
1002
1260
|
token = Phronomy::Concurrency::CancellationToken.new
|
|
1003
1261
|
Thread.new { sleep 10; token.cancel! }
|
|
1004
1262
|
|
|
@@ -1035,7 +1293,6 @@ bundle exec ruby NN_example_name/run.rb
|
|
|
1035
1293
|
| 12 | `12_prompt_template/` | Advanced prompt templates |
|
|
1036
1294
|
| 13 | `13_mcp_http_tool/` | HTTP-based MCP tool server |
|
|
1037
1295
|
| 14 | `14_code_review/` | Automated code review agent |
|
|
1038
|
-
| 16 | `16_before_completion_hook/` | Global/class/instance before_completion hooks |
|
|
1039
1296
|
| 17 | `17_multi_agent_handoff/` | Hub-and-spoke agent routing via Runner |
|
|
1040
1297
|
|
|
1041
1298
|
The following examples are **app-level demos** (Rails apps or advanced pipelines)
|