phronomy 0.20.0 → 0.22.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.
@@ -0,0 +1,184 @@
1
+ # ADR-015: Public Tool Façade, Extension SPI, and RBS Contract Boundary
2
+
3
+ ## Status
4
+
5
+ Accepted.
6
+
7
+ ## Context
8
+
9
+ Phronomy has accumulated two related API-maintenance problems:
10
+
11
+ 1. Application-defined Tools are implemented by subclassing the internal-taxonomy
12
+ name `Phronomy::Agent::Context::Capability::Base`, even though the product
13
+ concept exposed to users is simply a Tool.
14
+ 2. The gem ships almost no RBS signatures, so the distinction between
15
+ application APIs, extension SPIs, and private implementation objects is not
16
+ machine-readable.
17
+
18
+ At the same time, Phronomy already has compatibility mechanisms that must remain
19
+ canonical: runtime behavior, focused contract tests, YARD `@api`
20
+ classification, feature stability documentation, and API snapshots. RBS must not
21
+ become a second specification system that silently promotes internal behavior to
22
+ a compatibility promise.
23
+
24
+ The LLM call boundary also needs an explicit extension contract. Configuration
25
+ already allows replacing `llm_adapter`, and `LLMAdapter::Base` requires
26
+ implementations of `complete` and `stream`, but those methods were previously
27
+ classified as private. The current Agent pipeline still materializes a
28
+ RubyLLM-shaped chat/runtime object before invoking the adapter; formalizing this
29
+ SPI therefore must not be described as a provider-neutral replacement of the
30
+ whole materialization pipeline.
31
+
32
+ ## Decision
33
+
34
+ ### Public Tool authoring façade
35
+
36
+ The application-facing Tool authoring name is:
37
+
38
+ ```ruby
39
+ Phronomy::Tool::Base
40
+ ```
41
+
42
+ The existing implementation class remains:
43
+
44
+ ```ruby
45
+ Phronomy::Agent::Context::Capability::Base
46
+ ```
47
+
48
+ `Phronomy::Tool::Base` is an exact constant alias to that same class object. It
49
+ is not a subclass and the implementation source is not moved.
50
+
51
+ This preserves:
52
+
53
+ - class identity;
54
+ - Tool DSL class-instance state;
55
+ - built-in Tool inheritance;
56
+ - existing user code using the longer namespace;
57
+ - existing serialization/debugging behavior based on the implementation class
58
+ name.
59
+
60
+ Consequently, `Phronomy::Tool::Base.name` intentionally remains
61
+ `"Phronomy::Agent::Context::Capability::Base"`. If a future public contract
62
+ requires the runtime class name itself to become `Phronomy::Tool::Base`, that is
63
+ a separate migration decision.
64
+
65
+ `Phronomy::Tool` means the authoring API. `Phronomy::Tools` continues to mean
66
+ Phronomy-provided built-in Tool classes.
67
+
68
+ ### Extension dependency direction
69
+
70
+ Phronomy owns the interfaces that external implementations depend on:
71
+
72
+ ```text
73
+ Phronomy Core ──────→ Phronomy-owned public contract
74
+ External extension ─→ Phronomy-owned public contract
75
+ External extension ─→ extension-specific dependency
76
+ ```
77
+
78
+ External adapters/backends must not need private Runtime execution machinery
79
+ such as EventLoop, FSMSession, AgentInvocation, ExecutionCoordinator, or private
80
+ OffloadPool operation state merely to implement their domain contract.
81
+
82
+ Synchronous work requiring an operating-system worker Thread is submitted by
83
+ Phronomy to OffloadPool. The caller-facing completion handle is
84
+ `Phronomy::Task`; OffloadPool-specific queue/worker/abandonment state remains
85
+ private execution machinery as defined by ADR-010.
86
+
87
+ ### LLMAdapter SPI
88
+
89
+ `Phronomy::LLMAdapter::Base` is a Beta extension SPI.
90
+
91
+ The external implementer contract is centered on:
92
+
93
+ ```ruby
94
+ def complete(chat, message, config: {})
95
+ def stream(chat, message, config: {}, &block)
96
+ ```
97
+
98
+ The adapter or provider client owns provider transport timeout, retry, backoff,
99
+ and rate-limit behavior.
100
+
101
+ Phronomy owns the asynchronous bridge:
102
+
103
+ ```text
104
+ complete / stream
105
+
106
+ complete_async / stream_async
107
+
108
+ OffloadPool
109
+
110
+ Task
111
+ ```
112
+
113
+ External adapter implementations do not implement or depend on OffloadPool,
114
+ EventLoop, FSMSession, or other Runtime internals.
115
+
116
+ This SPI is the call boundary around the currently materialized chat/runtime
117
+ object. It does **not** make the entire pipeline provider-neutral. In particular,
118
+ this ADR does not remove `RubyLLMMaterializer`, introduce provider-neutral
119
+ request/response objects, or claim that arbitrary LLM client libraries can
120
+ replace RubyLLM without additional architecture work.
121
+
122
+ ### RBS ownership and scope
123
+
124
+ RBS is a typed representation of contracts already established by Phronomy. The
125
+ source of API meaning remains the combination of:
126
+
127
+ ```text
128
+ runtime behavior
129
+ + YARD @api classification
130
+ + feature/API documentation
131
+ + focused compatibility/contract tests
132
+ ```
133
+
134
+ RBS describes the resulting type shape. It must not be used in the opposite
135
+ direction to justify making an internal method public.
136
+
137
+ Initial signatures cover:
138
+
139
+ - primary application APIs;
140
+ - explicit extension SPIs;
141
+ - stable/common completion and configuration types needed to connect them.
142
+
143
+ Private implementation classes are not exhaustively signed.
144
+
145
+ Dynamic/external boundaries may use `untyped` where the framework does not own a
146
+ stable type shape. Phronomy-owned contracts such as Task result flow,
147
+ Persistence SPI methods, IDs/revisions, and documented fixed method arguments
148
+ should be typed where practical.
149
+
150
+ ### Third-party RBS ownership
151
+
152
+ Phronomy does not vendor placeholder signatures for RubyLLM or other third-party
153
+ gems merely to make its own signatures validate. If a third-party type is not
154
+ available or is too unstable, the Phronomy-owned boundary uses an appropriate
155
+ `untyped` type until an authoritative signature is available.
156
+
157
+ ### Validation and source dependency checks
158
+
159
+ RBS validation runs in a dedicated CI job so RBS tooling does not change the
160
+ Ruby 3.2 runtime support contract.
161
+
162
+ RBS verifies type/contract dependency shape. It does not prove Ruby source
163
+ `require` or constant dependency direction. Existing lightweight architecture
164
+ regression specs remain the appropriate place for source/runtime dependency
165
+ rules when such checks are needed.
166
+
167
+ Steep/full implementation type-checking is not introduced by this ADR.
168
+
169
+ ## Consequences
170
+
171
+ - Tool authoring becomes shorter without moving the implementation class.
172
+ - Existing Tool definitions remain source-compatible.
173
+ - The alias has a deliberately different user-facing name and runtime
174
+ `Class#name`; that distinction is documented rather than hidden in API
175
+ snapshot machinery.
176
+ - `LLMAdapter::Base#complete` and `#stream` become explicit Beta extension
177
+ contracts while async execution stays framework-owned.
178
+ - RBS can describe external extension boundaries without exposing private
179
+ Runtime machinery.
180
+ - Public API review gains one additional artifact (`sig/**/*.rbs`) but does not
181
+ gain a second semantic source of truth.
182
+ - A future provider-neutral LLM architecture, genuine native-async backend SPI,
183
+ or implementation-class rename requires a separate ADR rather than being
184
+ smuggled into signature work.
data/docs/features.md CHANGED
@@ -19,7 +19,9 @@ for production deployments.
19
19
  |---|---|
20
20
  | **Workflow** — Stateful, branching workflows with `wait_state` and explicit events | Stable |
21
21
  | **Agent** — Stateful ReAct-style agents with stable `agent_id`, persistence-backed execution state, canonical history, guardrails, and conversation context | Stable |
22
+ | **Tool authoring façade** — `Phronomy::Tool::Base` is the public authoring name for the existing Capability base class; the legacy namespace remains compatible | Beta |
22
23
  | **Unified Persistence** — One durable backend abstraction for Agent state and Workflow `workflow_states`; live Agent/Workflow state remains owned by the active instance/session between durable commits; custom backends implement the documented Backend SPI and repository/transaction semantics | Beta |
24
+ | **LLMAdapter SPI** — `Phronomy::LLMAdapter::Base#complete` / `#stream` define the Beta call-adapter extension boundary; Phronomy owns async/offload wrapping | Beta |
23
25
  | **Before-Large-Language-Model (LLM) Input Hook** — Three-tier per-call LLM input customization via `before_llm_input` and `LLMInputPatch` | Stable |
24
26
  | **Context Management** — Journal + Context Policy + per-LLM-call Manifest with token-budget-aware selection and protocol-safe Tool Call / Tool message dependencies | Stable |
25
27
  | **Filters** — Input/output transformation and blocking via `Filter::Base` | Beta |
@@ -35,7 +37,8 @@ for production deployments.
35
37
  |---|---|
36
38
  | **Knowledge** — Journal-backed persistent Agent context registered with `knowledge:` / `add_knowledge`, selected per LLM call by Context Policy | Beta |
37
39
  | **`VectorStore#size`** — Document count for InMemory, RedisSearch, and Pgvector backends | Beta |
38
- | **`VectorStore::AsyncBackend`**Pluggable async VectorStore interface with pool-backed defaults and native-async override points | Beta |
40
+ | **VectorStore async convenience** `add_async` / `search_async` / `remove_async` / `clear_async` offload the synchronous Backend SPI through Phronomy and return `Task`; native async override is not part of the current Backend SPI | Beta |
41
+ | **Embedding async convenience** — `embed_async` offloads synchronous `embed` through Phronomy and returns `Task` | Beta |
39
42
  | **Model Context Protocol (MCP) Tool** — `Phronomy::Tools::Mcp` integration through the official `mcp` gem | Beta |
40
43
  | **Agent Tool** — `Phronomy::Tools::Agent.from_agent` exposes a child Agent as a Tool without occupying a worker while waiting | Beta |
41
44
  | **Vector Search Tool** — `Phronomy::Tools::VectorSearch` wraps VectorStore and Embeddings adapters | Beta |
@@ -50,6 +53,7 @@ for production deployments.
50
53
  | **Agent async events** — `invoke_async(..., on_event:)` and `stream_async(..., on_event:)`; streaming additionally emits `:token` | Beta |
51
54
  | **`stream` / `stream_async`** — Event callbacks execute on EventLoop and must return quickly | Beta |
52
55
  | **`stream_callback_error_policy`** — Terminal event callback error policy (`:report` / `:fail_task`) | Beta |
56
+ | **Task completion contract** — `Task` is the common caller-facing completion handle for EventLoop/FSMSession lifecycles and OffloadPool work | Beta |
53
57
  | **`Task#map`** — Application-level Task result transformation and error propagation | Stable |
54
58
  | **CancellationToken** — Cooperative cancellation with explicit `cancel!`, lazy monotonic deadlines, and callback registration | Experimental |
55
59
  | **Tool `execution_mode`** — `:cooperative` for short EventLoop-safe work; `:offloaded` for synchronous work that must stay off EventLoop | Experimental |
@@ -80,18 +84,23 @@ compatibility guarantees.
80
84
 
81
85
  The YARD `@api` classification is independent from Ruby language visibility in
82
86
  both directions. `@api public` marks a compatibility contract, but the Ruby
83
- visibility still follows the intended calling model: ordinary APIs may be
84
- public, subclass extension helpers may be protected, and constructors use
85
- Ruby-private `initialize` behind `.new`. `@api private` means "internal/no
86
- compatibility promise" and does not require a Ruby `private` declaration; some
87
- internal methods remain Ruby-public because Phronomy components call them
88
- through explicit receivers.
89
-
90
- Persistence Backend SPI methods are a deliberate exception to the ordinary
91
- application-facing interpretation of `@api public`: they are public extension
92
- contracts for backend implementers, but application business logic should usually
93
- interact with Agents/Workflows instead of calling low-level repository operations.
94
- See [Persistence backend contract](persistence-backends.md).
87
+ visibility still follows the intended calling model. `@api private` means
88
+ "internal/no compatibility promise" and does not require a Ruby `private`
89
+ declaration; some internal methods remain Ruby-public because Phronomy components
90
+ call them through explicit receivers.
91
+
92
+ `Task` is the caller-facing completion abstraction. Framework components own
93
+ settlement (`complete` / `fail` / `cancel!`); application code observes Tasks via
94
+ `wait_result`, `on_complete`, `map`, and state readers. Operation-wide cancellation
95
+ is requested through the `CancellationToken` accepted by the API that created the
96
+ Task.
97
+
98
+ Persistence Backend SPI methods, LLMAdapter methods, and other documented
99
+ extension contracts are deliberate exceptions to the ordinary
100
+ application-facing interpretation of `@api public`: they are compatibility
101
+ contracts for implementers. Extension implementations must not depend on Runtime
102
+ private execution objects such as EventLoop/FSMSession/OffloadPool operation
103
+ records.
95
104
 
96
105
  `Phronomy::StateStore` is no longer a public backend abstraction. Workflow
97
106
  durability is provided through `Phronomy::Persistence#workflow_states`; see the
@@ -49,8 +49,12 @@ Install only the backend gems required by your application:
49
49
 
50
50
  ## Define a Tool and Agent
51
51
 
52
+ Use `Phronomy::Tool::Base` as the application-facing authoring API. It is an
53
+ exact alias of the existing `Phronomy::Agent::Context::Capability::Base`, so
54
+ existing Tool definitions using the longer namespace remain compatible.
55
+
52
56
  ```ruby
53
- class WebSearch < Phronomy::Agent::Context::Capability::Base
57
+ class WebSearch < Phronomy::Tool::Base
54
58
  description "Search the web"
55
59
  param :query, type: :string, desc: "Search query"
56
60
 
@@ -138,6 +142,24 @@ task = agent.invoke_async("Hello")
138
142
  result = task.wait_result
139
143
  ```
140
144
 
145
+ Both `invoke` and `invoke_async` can receive public Agent events through either
146
+ an `on_event:` listener or a block. A block is convenient when the listener is
147
+ local to the call:
148
+
149
+ ```ruby
150
+ task = agent.invoke_async("Hello") do |event|
151
+ puts event.payload[:output] if event.type == :done
152
+ end
153
+ ```
154
+
155
+ Use `on_event:` when the listener already exists as a callable. Do not provide
156
+ both `on_event:` and a block to the same invocation.
157
+
158
+ `Phronomy::Task` is the common caller-facing completion handle for asynchronous
159
+ Phronomy work. Logical lifecycle progress is driven by EventLoop/FSMSession;
160
+ synchronous work that must execute away from EventLoop is submitted to
161
+ OffloadPool. Both paths expose completion as a `Task`.
162
+
141
163
  `Task#wait_result` is for an external caller. Do not block EventLoop waiting for
142
164
  a Task that can only complete through that same EventLoop.
143
165
 
@@ -150,13 +172,12 @@ end
150
172
  ```
151
173
 
152
174
  ```ruby
153
- task = agent.stream_async(
154
- "Explain the design",
155
- on_event: ->(event) { puts event.payload if event.type == :token }
156
- )
175
+ task = agent.stream_async("Explain the design") do |event|
176
+ puts event.payload if event.type == :token
177
+ end
157
178
  ```
158
179
 
159
- Streaming callbacks execute on EventLoop and therefore should return quickly.
180
+ Agent event callbacks execute on EventLoop and therefore should return quickly.
160
181
 
161
182
  ## Human-in-the-loop approval
162
183
 
@@ -274,18 +295,15 @@ workflow = Phronomy::Workflow.define(AnswerContext) do
274
295
  entry :asking, ->(ctx) {
275
296
  thread_id = ctx.thread_id
276
297
 
277
- my_agent.invoke_async(
278
- ctx.question,
279
- on_event: ->(event) {
280
- next unless event.type == :done
281
-
282
- workflow.signal(
283
- thread_id: thread_id,
284
- event: :answer_ready,
285
- payload: {answer: event.payload[:output]}
286
- )
287
- }
288
- )
298
+ my_agent.invoke_async(ctx.question) do |event|
299
+ next unless event.type == :done
300
+
301
+ workflow.signal(
302
+ thread_id: thread_id,
303
+ event: :answer_ready,
304
+ payload: {answer: event.payload[:output]}
305
+ )
306
+ end
289
307
 
290
308
  ctx
291
309
  }