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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +159 -28
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +400 -143
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +7 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +3 -19
  10. data/benchmark/bench_tool_schema.rb +2 -34
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  14. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  15. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  16. data/lib/phronomy/agent/activation_registry.rb +28 -0
  17. data/lib/phronomy/agent/agent_execution.rb +97 -0
  18. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  19. data/lib/phronomy/agent/agent_invocation.rb +44 -46
  20. data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
  21. data/lib/phronomy/agent/agent_root.rb +66 -0
  22. data/lib/phronomy/agent/async_event_api.rb +55 -475
  23. data/lib/phronomy/agent/base.rb +351 -514
  24. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  25. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  26. data/lib/phronomy/agent/context_assembler.rb +357 -0
  27. data/lib/phronomy/agent/context_candidate.rb +47 -0
  28. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  29. data/lib/phronomy/agent/context_importer.rb +217 -0
  30. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  31. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  32. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  33. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
  34. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  35. data/lib/phronomy/agent/context_plan.rb +25 -0
  36. data/lib/phronomy/agent/context_plan_validator.rb +134 -0
  37. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  38. data/lib/phronomy/agent/context_policy.rb +15 -0
  39. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  40. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  41. data/lib/phronomy/agent/context_request.rb +35 -0
  42. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  43. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  44. data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
  45. data/lib/phronomy/agent/immutable.rb +31 -0
  46. data/lib/phronomy/agent/journal_projection.rb +60 -0
  47. data/lib/phronomy/agent/journal_record.rb +67 -0
  48. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  49. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  50. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  51. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  52. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  53. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  54. data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
  55. data/lib/phronomy/agent/shared_state.rb +46 -138
  56. data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
  57. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  58. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  59. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  60. data/lib/phronomy/agent.rb +10 -16
  61. data/lib/phronomy/agent_busy_error.rb +5 -0
  62. data/lib/phronomy/canonical_json.rb +136 -0
  63. data/lib/phronomy/configuration.rb +17 -155
  64. data/lib/phronomy/content_store/base.rb +51 -0
  65. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  66. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  67. data/lib/phronomy/engine/event_loop.rb +3 -0
  68. data/lib/phronomy/engine/runtime.rb +15 -230
  69. data/lib/phronomy/engine/task_group.rb +30 -102
  70. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  71. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  72. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  73. data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
  74. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  75. data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
  76. data/lib/phronomy/persistence/in_memory.rb +247 -0
  77. data/lib/phronomy/persistence.rb +39 -0
  78. data/lib/phronomy/tools/agent.rb +14 -36
  79. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  80. data/lib/phronomy/version.rb +1 -1
  81. data/lib/phronomy.rb +9 -115
  82. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  83. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  84. data/scripts/api_snapshot.rb +1 -12
  85. data/scripts/migrate_spec_agent_definition.rb +108 -0
  86. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  87. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  88. metadata +54 -13
  89. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  90. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  91. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
  92. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  93. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  94. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  95. data/lib/phronomy/knowledge_source.rb +0 -12
  96. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  97. 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 history | Stable |
31
- | **Before-Completion Hook** — Three-tier LLM parameter injection | Stable |
32
- | **Context Management** — Token budget calculation, estimation, and pruning; `Agent::Base` protected hooks: `build_context` (overridable), `trim_messages`, `trim_to_budget`, `compact_messages`, `budget_exceeded?`, `drop_messages_over` | Stable |
30
+ | **Agent** — Stateful ReAct-style tool-calling agents with stable `agent_id`, persistence-backed execution state, canonical execution history, guardrails, and conversation context | Stable |
31
+ | **Before-LLM-Input Hook** — Three-tier per-call LLM input customization via `before_llm_input` and `LLMInputPatch` | Stable |
32
+ | **Context Management** — Canonical Journal + per-LLM-call Manifest architecture with token-budget-aware selection and protocol-safe Tool Call / Tool message dependencies. Context selection never deletes canonical execution history | Stable |
33
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** — Static context injection with pluggable loaders, splitters, and vector stores; `static_knowledge_refresh!` for runtime cache invalidation | Beta |
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; Workflow entry and transition actions do not await mapped Tasks | Stable |
60
- | **CancellationToken** — Cooperative cancellation via `cancel!`/`cancelled?`/`raise_if_cancelled!`; `timeout_after(seconds)` for monotonic-clock deadlines; optional `deadline:` (wall-clock) for backward compatibility; passed as `config: { cancellation_token: token }` to agents and `dispatch_parallel`; injected into `tool.execute` when the method declares a `cancellation_token:` keyword; bridged to `MCP::Cancellation` in `Phronomy::Tools::Mcp#execute` | Experimental |
61
- | **`dispatch_parallel` / `fan_out` `force_kill:` option** — `force_kill: false` (default) leaves timed-out workers running and raises `TimeoutError` immediately; `force_kill: true` restores the old `Thread#kill` behaviour with a `logger.warn` | Beta |
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, agent_invocation_id: String, approval_request: Phronomy::Agent::ToolApprovalRequest }` when a tool requiring approval is encountered; `Agent::Base#approve(id, approval_request_id:, approved:)` (synchronous) or `Agent::Base#approve_async(id, approval_request_id:, approved:)` (returns `Task`) resumes execution; approval state is in-process only not persisted across process restarts or shared across pods | Beta |
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** — 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 |
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 WebSearch
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 ResearchTool, WriteTool
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 — Static context injection
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
- # Static knowledge (policy files, reference docs)
476
- policy = Phronomy::Agent::Context::Knowledge::StaticKnowledge.new(
477
- File.read("policy.md"),
478
- type: :policy,
479
- source: "policy.md" # exposed to LLM for citation
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
- # Inject at invocation time via the agent DSL
483
- class MyAgent < Phronomy::Agent::Base
484
- model "gpt-4o-mini"
485
- knowledge policy
486
- end
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
- `static_knowledge_refresh!` invalidates the class-level cache of static knowledge sources.
490
- Call it when the underlying file or content has changed:
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
- # Static knowledge sources are cached at the class level after the first fetch.
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
- Load and split documents with built-in loaders:
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-Completion Hook — Dynamic LLM parameter injection
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
- before_completion ->(ctx) { { temperature: ctx.config[:precise] ? 0.0 : 0.7 } }
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
- # Instance-level: overrides class hook for this agent only
612
+ ```ruby
532
613
  agent = MyAgent.new
533
- agent.before_completion = ->(ctx) { { max_tokens: 512 } }
534
614
 
535
- # Global: applies to every agent across the app
536
- Phronomy.configure do |c|
537
- c.before_completion = ->(ctx) { { temperature: 0.3 } }
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
- Hooks are called in order global → class → instance — and shallow-merged (`Hash#merge`; last hook wins on key conflicts).
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:** `dispatch_parallel` and `fan_out` use plain Ruby threads. Use
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}" # Float 0.0–1.0
707
- puts "Pass rate: #{metrics.pass_rate}" # fraction with score >= threshold
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 # any Phronomy::Tracing::Base subclass
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
- ### Conversation History passing prior messages
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
- Phronomy does not manage conversation history internally. The application owns the
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
- # First turn
745
- result1 = MyAgent.new.invoke("Hello! I'm Alice.", thread_id: "session-1")
746
- prior_messages = result1[:messages] # Array<RubyLLM::Message>
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
- `result[:messages]` contains the complete message history after each invocation.
758
- Persist it however suits your application (in-memory hash, Redis, ActiveRecord, etc.).
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
- > **Note on `thread_id`**: `thread_id` is a correlation identifier used internally for
761
- > EventLoop session routing and compaction context. It does **not** automatically persist or
762
- > restore conversation history — you must pass `messages:` explicitly on each turn as shown above.
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.before_completion = nil # optional; global hook lambda
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); :cooperative (deprecated alias for :immediate)
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 / EventLoop callback | `agent.invoke_async(input).map { |r| ctx.merge(output: r[:output]) }` returns a Task and resumes by state transition |
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 that calls `invoke_async` and then _blocks_ the calling
809
- thread until the task completes. It is intended for top-level application threads such as
810
- Rails controller actions, CLI scripts, or background jobs. Inside EventLoop-driven
811
- workflow actions, return a Task and let `Task#map` / `Task#on_complete` drive the next
812
- state transition instead of waiting inside the EventLoop thread.
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? # => true if called from inside a task
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
- # Workflow action / EventLoop-safe use
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`). Blocking I/O is isolated in `BlockingAdapterPool`.
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 includes a context window management layer. When model metadata is
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
- ### TokenBudget
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
- Derives the effective token budget from RubyLLM's model registry:
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
- ```ruby
875
- budget = Phronomy::LlmContextWindow::TokenBudget.new(
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
- Or supply explicit values (useful for local / unregistered models):
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
- ```ruby
887
- budget = Phronomy::LlmContextWindow::TokenBudget.new(
888
- context_window: 32_768,
889
- max_output_tokens: 4_096
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
- ### Agent DSL extensions
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 MyAgent < Phronomy::Agent::Base
897
- model "gpt-4o"
898
- max_output_tokens 4096 # override max_output_tokens from registry
899
- context_overhead 600 # extra reservation for system prompt + tools
900
- # LLM timeout/retry is configured on RubyLLM, not on the Agent class.
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
- `Agent::Base#invoke` builds a `TokenBudget` automatically. When the model is not in the
905
- registry the budget is silently skipped.
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
- tool-call batch, and after each `before_completion` hook. `CancellationError` is
926
- raised immediately. Phronomy does not replay the complete Agent invocation. No threads are force-killed — `ensure`
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
- > - A single `KnowledgeSource#fetch` that is already blocking (e.g. HTTP call)
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 per-source or
941
- > per-tool timeouts. Prefer library-native timeouts such as `Net::HTTP#read_timeout`,
942
- > database `statement_timeout`, or Redis client timeout — these signal the I/O layer
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. This ensures the layer capable of safely aborting the I/O owns
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)