phronomy 0.9.0 → 0.10.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/CHANGELOG.md +96 -4
- data/README.md +12 -12
- data/lib/phronomy/agent/base.rb +106 -96
- data/lib/phronomy/agent/checkpoint.rb +30 -1
- data/lib/phronomy/agent/checkpoint_store.rb +97 -0
- data/lib/phronomy/agent/concerns/retryable.rb +1 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +57 -2
- data/lib/phronomy/configuration.rb +13 -0
- data/lib/phronomy/event_loop.rb +1 -18
- data/lib/phronomy/task/mapped_backend.rb +78 -0
- data/lib/phronomy/task.rb +53 -0
- data/lib/phronomy/tools/agent.rb +2 -3
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/fsm_session.rb +249 -0
- data/lib/phronomy/workflow/phase_machine_builder.rb +247 -0
- data/lib/phronomy/workflow_runner.rb +2 -2
- data/lib/phronomy.rb +10 -3
- data/scripts/api_snapshot.rb +0 -1
- metadata +6 -7
- data/lib/phronomy/agent/fsm.rb +0 -157
- data/lib/phronomy/agent/invocation_pipeline.rb +0 -108
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +0 -251
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +0 -249
- data/lib/phronomy/agent/react_agent.rb +0 -205
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8bcaf54fe256791053b04c22df3b58595d267b2c0ee16b67369fe2ecb36f19d
|
|
4
|
+
data.tar.gz: 9be01ec03a79ec5d557a3c51fc442992cd012a07e682a43cfa4c9e0358110fc8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 44de75b5cc59ddf380aeb0be3abcf2a2c566cb8a1db6c35540525b86c74e7e0c06e75a3581e30276a25eea7f5f0334226e05b54d7d9694ed8f2b881d04a54e60
|
|
7
|
+
data.tar.gz: 76821f0cad1a918b762bb1d5737f902cc67b726f8dafb0dcf646b5cfc8dd4173527733a8a04e92e71ebea9a152fdb6ebaea20d9ccde3037f32d7fc32f5b05497
|
data/CHANGELOG.md
CHANGED
|
@@ -9,8 +9,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## [0.10.0] - 2026-06-08
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`Task#map` — transform a Task's completed value** (#384):
|
|
19
|
+
`task.map { |result| ctx.merge(answer: result[:output]) }` returns a new `Task`
|
|
20
|
+
whose completed value is the block's return value. Primary use-case: wire
|
|
21
|
+
`Agent::Base#invoke_async` into a Workflow entry action so the agent result
|
|
22
|
+
reaches `WorkflowContext` through the standard `:action_completed` path without
|
|
23
|
+
requiring any changes to `FSMSession`. If the source task fails or is cancelled,
|
|
24
|
+
the mapped task propagates the error without calling the block.
|
|
25
|
+
|
|
26
|
+
### Removed
|
|
27
|
+
|
|
28
|
+
- **`Agent::Base#run_as_child` removed** (#384):
|
|
29
|
+
Introduced when agents had their own FSM (`Agent::FSM`). After `Agent::FSM` was
|
|
30
|
+
removed in v0.9.0, the `:child_completed` event payload was silently discarded by
|
|
31
|
+
`FSMSession`, so `ctx.answer` was never populated. Migrate to
|
|
32
|
+
`invoke_async + Task#map`:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# Before (removed)
|
|
36
|
+
entry :translate, ->(ctx) { TranslationAgent.new.run_as_child(ctx.query, ctx: ctx) }
|
|
37
|
+
transition from: :translate, on: :child_completed, to: :done
|
|
38
|
+
|
|
39
|
+
# After (recommended)
|
|
40
|
+
entry :translate, ->(ctx) {
|
|
41
|
+
TranslationAgent.new.invoke_async(ctx.query).map { |r| ctx.merge(answer: r[:output]) }
|
|
42
|
+
}
|
|
43
|
+
transition from: :translate, to: :done
|
|
44
|
+
```
|
|
45
|
+
|
|
12
46
|
### Added
|
|
13
47
|
|
|
48
|
+
- **`Task#map` — transform a Task's completed value** (post-v0.9.0):
|
|
49
|
+
`task.map { |result| ctx.merge(answer: result[:output]) }` returns a new `Task`
|
|
50
|
+
whose completed value is the block's return value. The primary use-case is
|
|
51
|
+
connecting `Agent::Base#invoke_async` to a Workflow entry action so the agent
|
|
52
|
+
result populates the `WorkflowContext` through the standard `:action_completed`
|
|
53
|
+
path. If the source task fails or is cancelled, the mapped task propagates the
|
|
54
|
+
error without calling the block.
|
|
55
|
+
|
|
14
56
|
- **`Phronomy::Diagnostics` and `SchedulerReentrancyError`** (#278, #279):
|
|
15
57
|
`Phronomy::Diagnostics` exposes a snapshot of current scheduler state
|
|
16
58
|
(`pending_count`, `active_tasks`, `pool_utilization`, etc.) for debugging and
|
|
@@ -174,10 +216,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
174
216
|
tasks are treated the same as errors and follow the existing `on_error:` policy (`:raise`
|
|
175
217
|
or `:skip`).
|
|
176
218
|
|
|
177
|
-
- **MCP `HttpTransport` custom authentication headers** (#144): `
|
|
178
|
-
accepts `headers: {}
|
|
179
|
-
|
|
180
|
-
|
|
219
|
+
- **MCP `HttpTransport` custom authentication headers** (#144): `Phronomy::Tools::Mcp::HttpTransport#initialize`
|
|
220
|
+
now accepts `headers: {}`. Arbitrary headers (e.g. `Authorization: Bearer …`) are injected
|
|
221
|
+
into every JSON-RPC request, enabling use of MCP servers that require bearer tokens or
|
|
222
|
+
API keys. Threading `headers:` through `Mcp.from_server` is tracked in issue #144 and
|
|
223
|
+
pending in PR #151.
|
|
181
224
|
|
|
182
225
|
- **`StdioTransport` — `env:`, `cwd:`, and `startup_timeout:` options** (#145):
|
|
183
226
|
Three new keyword arguments are now accepted when constructing a `StdioTransport` (and
|
|
@@ -226,8 +269,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
226
269
|
`dispatch_parallel` and `fan_out` accept `cancellation_token:` and automatically
|
|
227
270
|
inject it into every worker task's config unless the task already supplies its own.
|
|
228
271
|
|
|
272
|
+
### Added (post-v0.9.0)
|
|
273
|
+
|
|
274
|
+
- **`Agent::Base#run_as_child` removed** (post-v0.9.0):
|
|
275
|
+
`run_as_child` was introduced when agents had their own FSM. After `Agent::FSM`
|
|
276
|
+
was removed, the `:child_completed` event payload was silently discarded by
|
|
277
|
+
`FSMSession`, meaning `ctx.answer` was never populated. The method has been
|
|
278
|
+
removed. Use `invoke_async + Task#map` instead:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
# Before (deprecated)
|
|
282
|
+
entry :translate, ->(ctx) { TranslationAgent.new.run_as_child(ctx.query, ctx: ctx) }
|
|
283
|
+
transition from: :translate, on: :child_completed, to: :done
|
|
284
|
+
|
|
285
|
+
# After (recommended)
|
|
286
|
+
entry :translate, ->(ctx) {
|
|
287
|
+
TranslationAgent.new.invoke_async(ctx.query).map { |r| ctx.merge(answer: r[:output]) }
|
|
288
|
+
}
|
|
289
|
+
transition from: :translate, to: :done
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
- **`Phronomy::Agent::CheckpointStore` — idempotency store for HITL resume** (post-v0.9.0):
|
|
293
|
+
New in-memory store tracks consumed checkpoint IDs. Calling `Agent::Base#resume` twice
|
|
294
|
+
with the same checkpoint raises `Phronomy::CheckpointAlreadyResumedError` instead of
|
|
295
|
+
silently re-executing the approved tool. Custom stores can be injected via
|
|
296
|
+
`agent.checkpoint_store = MyRedis::CheckpointStore.new`. Duck-type contract:
|
|
297
|
+
`consumed?(id)`, `consume!(id)`, and optionally `cleanup!(id)` / `clear!`.
|
|
298
|
+
|
|
299
|
+
- **`checkpoint_id`, `agent_class`, `requested_at` on `Checkpoint`; `Agent::Base.resume` class method** (post-v0.9.0):
|
|
300
|
+
`Checkpoint` now carries a UUID `checkpoint_id` (idempotency key), `agent_class`
|
|
301
|
+
(fully-qualified class name), and `requested_at` (UTC timestamp). The new class-level
|
|
302
|
+
`Agent::Base.resume(checkpoint, approved:)` method instantiates the correct agent class
|
|
303
|
+
automatically and delegates to `#resume`, simplifying job-queue resume flows.
|
|
304
|
+
|
|
305
|
+
- **`CheckpointStore#cleanup!` and `#clear!`** (post-v0.9.0):
|
|
306
|
+
Optional methods on the `CheckpointStore` duck-type contract. `cleanup!(checkpoint_id)`
|
|
307
|
+
removes a single checkpoint entry; `clear!` wipes all tracking state.
|
|
308
|
+
|
|
229
309
|
### Removed
|
|
230
310
|
|
|
311
|
+
- **`Phronomy::ReactAgent` class removed** (post-v0.9.0):
|
|
312
|
+
Use `Phronomy::Agent::Base` directly. `ReactAgent` had no distinct public API beyond
|
|
313
|
+
`Agent::Base` and was not listed in the stability table.
|
|
314
|
+
|
|
315
|
+
- **`Phronomy::Agent::FSM` class removed** (post-v0.9.0, internal):
|
|
316
|
+
The agent invocation path is now unified through `Agent::Base#invoke` with inline logic.
|
|
317
|
+
No public API impact.
|
|
318
|
+
|
|
319
|
+
- **`Phronomy::Agent::Lifecycle::FSMSession` and `::PhaseMachineBuilder` moved to `Workflow` namespace** (post-v0.9.0, internal):
|
|
320
|
+
These internal classes now live at `Phronomy::Workflow::FSMSession` and
|
|
321
|
+
`Phronomy::Workflow::PhaseMachineBuilder`. No public API impact.
|
|
322
|
+
|
|
231
323
|
- **BREAKING: `Agent::Base#run_as_child` drops `&result_writer` block parameter** (#265):
|
|
232
324
|
The optional block form `run_as_child(input, ctx: ctx) { |r| ctx.answer = r[:output] }`
|
|
233
325
|
is no longer supported. The result is now delivered **exclusively** as the
|
data/README.md
CHANGED
|
@@ -54,8 +54,9 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
|
|
|
54
54
|
| Feature | Stability |
|
|
55
55
|
|---|---|
|
|
56
56
|
| **Workflow EventLoop Mode** — Opt-in event-driven execution: `Phronomy.configure { \|c\| c.event_loop = true }` | Experimental |
|
|
57
|
-
| **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#
|
|
57
|
+
| **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#invoke_async` + `Task#map` (child-agent pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
|
|
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 | Experimental |
|
|
59
|
+
| **`Task#map`** — transforms a `Task`'s completed value via a block; returns a new `Task` whose value is the block's return value; if the source task fails or is cancelled the mapped task propagates the error without calling the block; primary use-case: `invoke_async.map { \|r\| ctx.merge(answer: r[:output]) }` to wire agent results into a `WorkflowContext` | Experimental |
|
|
59
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 | Experimental |
|
|
60
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 |
|
|
61
62
|
| **`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` | Experimental |
|
|
@@ -76,6 +77,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
|
|
|
76
77
|
| **Agent::TeamCoordinator** — Agent teams pattern: LLM coordinator + stateful workers with sequential task assignment (worker-local message history persisted across tasks) | Beta |
|
|
77
78
|
| **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
|
|
78
79
|
| **`ScopePolicy`** — Configurable policy callable that maps (tool, scope, agent) to `:allow`/`:approve`/`:reject`; default policy auto-routes high-risk scopes through the approval gate | Experimental |
|
|
80
|
+
| **HITL Checkpoint/Resume** — `Agent::Base#invoke` returns `{ suspended: true, checkpoint: Checkpoint }` when an approval-required tool is encountered without a synchronous handler; `Agent::Base#resume(checkpoint, approved:)` resumes execution; `Agent::Base.resume(checkpoint, approved:)` (class-level) resolves the agent class automatically; `Checkpoint#to_h` / `Checkpoint.from_h` for serialization; `Agent::Base#checkpoint_store=` for custom idempotency backends; `CheckpointAlreadyResumedError` raised on duplicate resume | Experimental |
|
|
79
81
|
|
|
80
82
|
> **Public API boundary**: The tables above are the complete list of classes, modules, and features
|
|
81
83
|
> intended for gem consumers. Every entry has an associated stability label.
|
|
@@ -198,20 +200,18 @@ final = app.send_event(state: state, event: :approve)
|
|
|
198
200
|
puts "Approved: #{final.approved}" # => true
|
|
199
201
|
```
|
|
200
202
|
|
|
201
|
-
In EventLoop mode (`c.event_loop = true`), `
|
|
202
|
-
asynchronously
|
|
203
|
-
|
|
204
|
-
dispatched. Always declare both transitions to avoid a stuck workflow:
|
|
203
|
+
In EventLoop mode (`c.event_loop = true`), use `invoke_async + Task#map` to run an agent
|
|
204
|
+
asynchronously inside a Workflow entry action. The mapped Task returns a `WorkflowContext`,
|
|
205
|
+
which `FSMSession` picks up via the standard `:action_completed` path:
|
|
205
206
|
|
|
206
207
|
```ruby
|
|
207
|
-
# EventLoop mode: workflow that runs an agent
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
208
|
+
# EventLoop mode: workflow that runs an agent and captures the result.
|
|
209
|
+
entry :translate, ->(ctx) {
|
|
210
|
+
TranslationAgent.new.invoke_async(ctx.query).map do |result|
|
|
211
|
+
ctx.merge(answer: result[:output]) # returns WorkflowContext
|
|
212
|
+
end
|
|
212
213
|
}
|
|
213
|
-
transition from: :
|
|
214
|
-
transition from: :run_agent, on: :child_failed, to: :handle_error
|
|
214
|
+
transition from: :translate, to: :done # no on: needed
|
|
215
215
|
```
|
|
216
216
|
|
|
217
217
|
### Multi-Agent — Agent-as-Tool pattern
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "checkpoint_store"
|
|
4
5
|
require_relative "concerns/retryable"
|
|
5
6
|
require_relative "concerns/guardrailable"
|
|
6
7
|
require_relative "concerns/before_completion"
|
|
@@ -374,6 +375,27 @@ module Phronomy
|
|
|
374
375
|
@context_overhead = val.to_i
|
|
375
376
|
end
|
|
376
377
|
end
|
|
378
|
+
|
|
379
|
+
# Resumes a suspended invocation identified by +checkpoint+ without
|
|
380
|
+
# requiring the original agent instance to be kept in memory.
|
|
381
|
+
#
|
|
382
|
+
# Validates that the checkpoint was created by this agent class, then
|
|
383
|
+
# instantiates a fresh agent and delegates to {Suspendable#resume}.
|
|
384
|
+
#
|
|
385
|
+
# @param checkpoint [Phronomy::Agent::Checkpoint]
|
|
386
|
+
# @param approved [Boolean] +true+ to execute the pending tool; +false+ to deny
|
|
387
|
+
# @param config [Hash] same runtime options as {#invoke}
|
|
388
|
+
# @return [Hash] same shape as {#invoke} — may contain +suspended: true+ if
|
|
389
|
+
# another approval-required tool is encountered during continuation
|
|
390
|
+
# @raise [ArgumentError] when +checkpoint.agent_class+ does not match this class
|
|
391
|
+
# @api public
|
|
392
|
+
def resume(checkpoint, approved:, config: {})
|
|
393
|
+
if checkpoint.agent_class && checkpoint.agent_class != name
|
|
394
|
+
raise ArgumentError,
|
|
395
|
+
"checkpoint belongs to #{checkpoint.agent_class}, cannot resume with #{name}"
|
|
396
|
+
end
|
|
397
|
+
new.resume(checkpoint, approved: approved, config: config)
|
|
398
|
+
end
|
|
377
399
|
end
|
|
378
400
|
|
|
379
401
|
# Registers an anonymous handoff tool class on this agent instance.
|
|
@@ -442,12 +464,35 @@ module Phronomy
|
|
|
442
464
|
if invocation_context
|
|
443
465
|
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
444
466
|
end
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
invoke_async(input, messages: messages, thread_id: thread_id, config: config).await
|
|
467
|
+
_check_scheduler_reentrancy
|
|
468
|
+
|
|
469
|
+
timeout_sec = self.class.invoke_timeout
|
|
470
|
+
unless timeout_sec
|
|
471
|
+
return invoke_async(input, messages: messages, thread_id: thread_id, config: config).await
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# invoke_timeout: create a CancellationScope with deadline, pass its token
|
|
475
|
+
# to the async invocation, and use scope.pop_queue so the calling thread
|
|
476
|
+
# unblocks as soon as either the result arrives or the deadline fires.
|
|
477
|
+
scope = Phronomy::Concurrency::CancellationScope.new(parent_token: config[:cancellation_token])
|
|
478
|
+
scope.deadline_in(timeout_sec)
|
|
479
|
+
effective_config = config.merge(cancellation_token: scope.token)
|
|
480
|
+
task = invoke_async(input, messages: messages, thread_id: thread_id, config: effective_config)
|
|
481
|
+
|
|
482
|
+
# Bridge the task result to an AsyncQueue so scope.pop_queue can observe the deadline.
|
|
483
|
+
completion_queue = Phronomy::Concurrency::AsyncQueue.new
|
|
484
|
+
Phronomy::Runtime.instance.spawn(name: "invoke-timeout-bridge:#{(self.class.name || "agent").downcase}") do
|
|
485
|
+
completion_queue.push(task.await)
|
|
486
|
+
rescue => e
|
|
487
|
+
completion_queue.push(e)
|
|
450
488
|
end
|
|
489
|
+
|
|
490
|
+
result = scope.pop_queue(completion_queue) do
|
|
491
|
+
raise Phronomy::TimeoutError,
|
|
492
|
+
"Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
|
|
493
|
+
end
|
|
494
|
+
raise result if result.is_a?(Exception)
|
|
495
|
+
result
|
|
451
496
|
end
|
|
452
497
|
|
|
453
498
|
# Invokes this agent asynchronously and returns a {Phronomy::Task}.
|
|
@@ -487,60 +532,13 @@ module Phronomy
|
|
|
487
532
|
end
|
|
488
533
|
end
|
|
489
534
|
|
|
490
|
-
# Registers this agent as a child {AgentFSM} inside the given Workflow context.
|
|
491
|
-
#
|
|
492
|
-
# Use this method from a Workflow entry action (running on the EventLoop thread)
|
|
493
|
-
# instead of {#invoke}, which would raise a deadlock error because +invoke+ blocks
|
|
494
|
-
# on a +Thread::Queue+ when EventLoop mode is active.
|
|
495
|
-
#
|
|
496
|
-
# The agent runs asynchronously in a background IO thread. When it finishes, the
|
|
497
|
-
# parent {FSMSession} receives a +:child_completed+ event whose payload is the
|
|
498
|
-
# result hash +{ output:, messages:, usage: }+. Declare an +on: :child_completed+
|
|
499
|
-
# transition in your Workflow to advance to the next state.
|
|
500
|
-
#
|
|
501
|
-
# The result is delivered exclusively as the +:child_completed+ event payload.
|
|
502
|
-
# The parent Workflow task is the sole owner of the parent +WorkflowContext+ and
|
|
503
|
-
# applies the result after receiving the event — no background thread writes to
|
|
504
|
-
# the parent context directly.
|
|
505
|
-
#
|
|
506
|
-
# @example
|
|
507
|
-
# entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
|
|
508
|
-
# transition from: :run_agent, on: :child_completed, to: :process_result
|
|
509
|
-
#
|
|
510
|
-
# @param input [String, Hash] user input passed to the agent
|
|
511
|
-
# @param ctx [Object] a WorkflowContext that responds to +#thread_id+
|
|
512
|
-
# @param messages [Array] prior conversation history
|
|
513
|
-
# @param config [Hash] invocation config (forwarded to +_invoke_impl+)
|
|
514
|
-
# @return [nil] the caller must not wait on any return value;
|
|
515
|
-
# the result arrives as a +:child_completed+ event
|
|
516
|
-
# @raise [Phronomy::Error] when EventLoop mode is not enabled
|
|
517
|
-
# @api public
|
|
518
|
-
def run_as_child(input, ctx:, messages: [], config: {})
|
|
519
|
-
unless Phronomy.configuration.event_loop
|
|
520
|
-
raise Phronomy::Error,
|
|
521
|
-
"run_as_child requires EventLoop mode. " \
|
|
522
|
-
"Enable with: Phronomy.configure { |c| c.event_loop = true }"
|
|
523
|
-
end
|
|
524
|
-
|
|
525
|
-
fsm = Agent::FSM.new(
|
|
526
|
-
agent: self,
|
|
527
|
-
input: input,
|
|
528
|
-
messages: messages,
|
|
529
|
-
thread_id: "#{ctx.thread_id}_agent_#{SecureRandom.uuid}",
|
|
530
|
-
config: config,
|
|
531
|
-
parent_id: ctx.thread_id
|
|
532
|
-
)
|
|
533
|
-
Phronomy::EventLoop.instance.enqueue_child(fsm)
|
|
534
|
-
nil
|
|
535
|
-
end
|
|
536
|
-
|
|
537
535
|
# Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
|
|
538
536
|
# as they are produced by the underlying LLM.
|
|
539
537
|
#
|
|
540
538
|
# Events emitted (in order):
|
|
541
539
|
# :token — each content delta from the LLM
|
|
542
|
-
# :tool_call — when the LLM requests a tool
|
|
543
|
-
# :tool_result — after a tool completes
|
|
540
|
+
# :tool_call — when the LLM requests a tool
|
|
541
|
+
# :tool_result — after a tool completes
|
|
544
542
|
# :done — final event carrying output, messages, and usage
|
|
545
543
|
# :error — if an unrecoverable error occurs
|
|
546
544
|
#
|
|
@@ -587,42 +585,6 @@ module Phronomy
|
|
|
587
585
|
[effective_thread_id, effective_config]
|
|
588
586
|
end
|
|
589
587
|
|
|
590
|
-
def _invoke_via_event_loop(input, messages:, thread_id:, config:)
|
|
591
|
-
if Phronomy::EventLoop.current?
|
|
592
|
-
raise Phronomy::Error,
|
|
593
|
-
"Cannot call Agent#invoke (EventLoop mode) from within an EventLoop " \
|
|
594
|
-
"entry action. Use agent.run_as_child(input, ctx: ctx) instead."
|
|
595
|
-
end
|
|
596
|
-
|
|
597
|
-
timeout_sec = self.class.invoke_timeout
|
|
598
|
-
effective_config, scope = if timeout_sec
|
|
599
|
-
s = Phronomy::Concurrency::CancellationScope.new(parent_token: config[:cancellation_token])
|
|
600
|
-
s.deadline_in(timeout_sec)
|
|
601
|
-
[config.merge(cancellation_token: s.token), s]
|
|
602
|
-
else
|
|
603
|
-
[config, nil]
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
fsm = Agent::FSM.new(
|
|
607
|
-
agent: self,
|
|
608
|
-
input: input,
|
|
609
|
-
messages: messages,
|
|
610
|
-
thread_id: thread_id || SecureRandom.uuid,
|
|
611
|
-
config: effective_config
|
|
612
|
-
)
|
|
613
|
-
completion_queue = Phronomy::EventLoop.instance.register(fsm)
|
|
614
|
-
result = if scope
|
|
615
|
-
scope.pop_queue(completion_queue) do
|
|
616
|
-
raise Phronomy::TimeoutError,
|
|
617
|
-
"Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
|
|
618
|
-
end
|
|
619
|
-
else
|
|
620
|
-
completion_queue.pop
|
|
621
|
-
end
|
|
622
|
-
raise result if result.is_a?(Exception)
|
|
623
|
-
result
|
|
624
|
-
end
|
|
625
|
-
|
|
626
588
|
def _check_scheduler_reentrancy
|
|
627
589
|
return unless Phronomy::Task.current
|
|
628
590
|
|
|
@@ -851,12 +813,30 @@ module Phronomy
|
|
|
851
813
|
# wrap it in a retry loop without duplicating the LLM interaction logic.
|
|
852
814
|
def invoke_once(input, messages: [], thread_id: nil, config: {})
|
|
853
815
|
trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
|
|
854
|
-
|
|
816
|
+
run_input_guardrails!(input)
|
|
817
|
+
|
|
818
|
+
user_message = extract_message(input)
|
|
819
|
+
chat = build_chat
|
|
820
|
+
context = build_context(
|
|
855
821
|
input,
|
|
856
|
-
messages: messages,
|
|
857
|
-
|
|
858
|
-
|
|
822
|
+
messages: messages, thread_id: thread_id, config: config,
|
|
823
|
+
budget: build_token_budget, instruction: build_instructions(input),
|
|
824
|
+
tools: self.class.tools + _handoff_tools
|
|
825
|
+
)
|
|
826
|
+
_apply_context_to_chat(chat, context)
|
|
827
|
+
|
|
828
|
+
run_before_completion_hooks!(chat, config)
|
|
829
|
+
_register_suspension_hook!(chat)
|
|
830
|
+
check_cancellation!(config, "invocation cancelled before LLM call")
|
|
831
|
+
|
|
832
|
+
result, usage = _complete_with_suspension_guard(
|
|
833
|
+
chat, user_message, config,
|
|
834
|
+
thread_id: thread_id, original_input: input
|
|
859
835
|
)
|
|
836
|
+
next [result, usage] if result[:suspended]
|
|
837
|
+
|
|
838
|
+
run_output_guardrails!(result[:output])
|
|
839
|
+
[result, usage]
|
|
860
840
|
end
|
|
861
841
|
end
|
|
862
842
|
|
|
@@ -877,6 +857,36 @@ module Phronomy
|
|
|
877
857
|
context[:messages].each { |msg| chat.messages << msg }
|
|
878
858
|
end
|
|
879
859
|
|
|
860
|
+
# Submits the LLM call via LLMAdapter and handles SuspendSignal.
|
|
861
|
+
# Sets/clears the chat cancellation token around the call so that
|
|
862
|
+
# ParallelToolChat can observe cancellation without Thread.current.
|
|
863
|
+
# Returns [result_hash, usage_or_nil].
|
|
864
|
+
def _complete_with_suspension_guard(chat, user_message, config, thread_id:, original_input:)
|
|
865
|
+
chat.cancellation_token = config[:cancellation_token] if chat.respond_to?(:cancellation_token=)
|
|
866
|
+
begin
|
|
867
|
+
adapter = Phronomy.configuration.llm_adapter
|
|
868
|
+
response = adapter.complete_async(chat, user_message, config: config).await
|
|
869
|
+
rescue SuspendSignal => signal
|
|
870
|
+
checkpoint = Checkpoint.new(
|
|
871
|
+
checkpoint_id: SecureRandom.uuid,
|
|
872
|
+
agent_class: self.class.name,
|
|
873
|
+
requested_at: Time.now.utc,
|
|
874
|
+
thread_id: thread_id,
|
|
875
|
+
original_input: original_input,
|
|
876
|
+
messages: chat.messages.dup,
|
|
877
|
+
pending_tool_name: signal.tool_name,
|
|
878
|
+
pending_tool_args: signal.args,
|
|
879
|
+
pending_tool_call_id: signal.tool_call_id
|
|
880
|
+
)
|
|
881
|
+
return [{output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}, nil]
|
|
882
|
+
ensure
|
|
883
|
+
chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
|
|
884
|
+
end
|
|
885
|
+
output = response.content
|
|
886
|
+
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
887
|
+
[{output: output, messages: chat.messages, usage: usage}, usage]
|
|
888
|
+
end
|
|
889
|
+
|
|
880
890
|
def _drain_stream(chat, user_message, config, &block)
|
|
881
891
|
adapter = Phronomy.configuration.llm_adapter
|
|
882
892
|
chunk_queue = Phronomy::Concurrency::AsyncQueue.new(max_size: Phronomy.configuration.stream_queue_max_size)
|
|
@@ -920,12 +930,12 @@ module Phronomy
|
|
|
920
930
|
end
|
|
921
931
|
|
|
922
932
|
# Returns the chat class to instantiate for this invocation.
|
|
923
|
-
# When
|
|
933
|
+
# When {Phronomy.configuration.parallel_tool_execution} is true,
|
|
924
934
|
# returns {ParallelToolChat} so that concurrent tool dispatch is enabled.
|
|
925
935
|
# Falls back to +nil+ otherwise, signalling {#build_chat} to use the
|
|
926
936
|
# standard +RubyLLM.chat+ factory.
|
|
927
937
|
def build_chat_class
|
|
928
|
-
Phronomy.configuration.
|
|
938
|
+
Phronomy.configuration.parallel_tool_execution ? Phronomy::MultiAgent::ParallelToolChat : nil
|
|
929
939
|
end
|
|
930
940
|
|
|
931
941
|
def build_chat
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Phronomy
|
|
4
6
|
module Agent
|
|
5
7
|
# Encapsulates the suspended state of an agent invocation.
|
|
@@ -19,6 +21,18 @@ module Phronomy
|
|
|
19
21
|
# end
|
|
20
22
|
# puts result[:output]
|
|
21
23
|
class Checkpoint
|
|
24
|
+
# @return [String] a globally unique identifier for this checkpoint;
|
|
25
|
+
# used as an idempotency key when guarding against duplicate resumes
|
|
26
|
+
attr_reader :checkpoint_id
|
|
27
|
+
|
|
28
|
+
# @return [String, nil] the fully-qualified name of the agent class that
|
|
29
|
+
# created this checkpoint (e.g. +"MyApp::ReviewAgent"+); used by the
|
|
30
|
+
# class-level +resume+ method to validate the correct agent is used
|
|
31
|
+
attr_reader :agent_class
|
|
32
|
+
|
|
33
|
+
# @return [Time] the UTC timestamp when this checkpoint was created
|
|
34
|
+
attr_reader :requested_at
|
|
35
|
+
|
|
22
36
|
# @return [String, nil] the thread_id from the invocation config
|
|
23
37
|
attr_reader :thread_id
|
|
24
38
|
|
|
@@ -41,6 +55,9 @@ module Phronomy
|
|
|
41
55
|
# inject the tool result message on resume)
|
|
42
56
|
attr_reader :pending_tool_call_id
|
|
43
57
|
|
|
58
|
+
# @param checkpoint_id [String] unique identifier; defaults to a new UUID
|
|
59
|
+
# @param agent_class [String, nil] fully-qualified agent class name
|
|
60
|
+
# @param requested_at [Time] when the checkpoint was created; defaults to +Time.now.utc+
|
|
44
61
|
# @param thread_id [String, nil]
|
|
45
62
|
# @param original_input [String, Hash] the input passed to the original #invoke call
|
|
46
63
|
# @param messages [Array<RubyLLM::Message>]
|
|
@@ -48,7 +65,11 @@ module Phronomy
|
|
|
48
65
|
# @param pending_tool_args [Hash]
|
|
49
66
|
# @param pending_tool_call_id [String]
|
|
50
67
|
# @api public
|
|
51
|
-
def initialize(thread_id:, original_input:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id
|
|
68
|
+
def initialize(thread_id:, original_input:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:,
|
|
69
|
+
checkpoint_id: SecureRandom.uuid, agent_class: nil, requested_at: Time.now.utc)
|
|
70
|
+
@checkpoint_id = checkpoint_id
|
|
71
|
+
@agent_class = agent_class
|
|
72
|
+
@requested_at = requested_at
|
|
52
73
|
@thread_id = thread_id
|
|
53
74
|
@original_input = original_input
|
|
54
75
|
@messages = messages.dup.freeze
|
|
@@ -71,6 +92,9 @@ module Phronomy
|
|
|
71
92
|
# @api public
|
|
72
93
|
def to_h
|
|
73
94
|
{
|
|
95
|
+
checkpoint_id: @checkpoint_id,
|
|
96
|
+
agent_class: @agent_class,
|
|
97
|
+
requested_at: @requested_at&.iso8601,
|
|
74
98
|
thread_id: @thread_id,
|
|
75
99
|
original_input: @original_input,
|
|
76
100
|
messages: @messages.map { |m| serialize_message(m) },
|
|
@@ -99,7 +123,12 @@ module Phronomy
|
|
|
99
123
|
end
|
|
100
124
|
}
|
|
101
125
|
messages = Array(h[:messages]).map { |m| deserialize_message(m) }
|
|
126
|
+
requested_at_raw = h[:requested_at]
|
|
127
|
+
requested_at = requested_at_raw ? Time.parse(requested_at_raw.to_s).utc : nil
|
|
102
128
|
new(
|
|
129
|
+
checkpoint_id: h[:checkpoint_id]&.to_s || SecureRandom.uuid,
|
|
130
|
+
agent_class: h[:agent_class]&.to_s,
|
|
131
|
+
requested_at: requested_at || Time.now.utc,
|
|
103
132
|
thread_id: h[:thread_id],
|
|
104
133
|
original_input: h[:original_input],
|
|
105
134
|
messages: messages,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Default in-memory idempotency store for {Checkpoint} resume operations.
|
|
6
|
+
#
|
|
7
|
+
# Tracks consumed checkpoint IDs so that calling {Agent::Base#resume} twice
|
|
8
|
+
# with the same checkpoint raises {Phronomy::CheckpointAlreadyResumedError}
|
|
9
|
+
# instead of silently executing the approved tool a second time.
|
|
10
|
+
#
|
|
11
|
+
# This implementation is *not thread-safe*. It assumes a single agent instance
|
|
12
|
+
# is accessed from only one thread at a time, which is the expected usage pattern.
|
|
13
|
+
# Agent instances themselves are not thread-safe (state like +@messages+, +@config+
|
|
14
|
+
# is not protected), so concurrent calls to the same agent instance are unsupported.
|
|
15
|
+
#
|
|
16
|
+
# Each agent instance gets its own store by default, so no sharing occurs unless
|
|
17
|
+
# the caller explicitly assigns the same store object to multiple agents.
|
|
18
|
+
#
|
|
19
|
+
# For distributed environments (multiple processes or background jobs), swap this
|
|
20
|
+
# for a custom implementation backed by Redis, ActiveRecord, or another shared store.
|
|
21
|
+
# *Your custom store implementation is responsible for ensuring thread-safety* if
|
|
22
|
+
# your application shares the same store instance across multiple threads.
|
|
23
|
+
#
|
|
24
|
+
# @example Plugging in a custom store
|
|
25
|
+
# agent = MyAgent.new
|
|
26
|
+
# agent.checkpoint_store = MyRedis::CheckpointStore.new
|
|
27
|
+
#
|
|
28
|
+
# @example Duck-type contract required by any replacement
|
|
29
|
+
# # consumed?(checkpoint_id) => Boolean
|
|
30
|
+
# # consume!(checkpoint_id) => void; raises CheckpointAlreadyResumedError if duplicate
|
|
31
|
+
# # cleanup!(checkpoint_id) => void (optional); removes tracking for the checkpoint
|
|
32
|
+
# # clear! => void (optional); removes all tracked checkpoints
|
|
33
|
+
#
|
|
34
|
+
# @api public
|
|
35
|
+
class CheckpointStore
|
|
36
|
+
def initialize
|
|
37
|
+
@consumed = Set.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns +true+ if the given checkpoint ID has already been consumed.
|
|
41
|
+
#
|
|
42
|
+
# @param checkpoint_id [String]
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
# @api public
|
|
45
|
+
def consumed?(checkpoint_id)
|
|
46
|
+
@consumed.include?(checkpoint_id)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Marks +checkpoint_id+ as consumed, or raises if it was already consumed.
|
|
50
|
+
#
|
|
51
|
+
# @param checkpoint_id [String]
|
|
52
|
+
# @raise [Phronomy::CheckpointAlreadyResumedError]
|
|
53
|
+
# @return [void]
|
|
54
|
+
# @api public
|
|
55
|
+
def consume!(checkpoint_id)
|
|
56
|
+
if @consumed.include?(checkpoint_id)
|
|
57
|
+
raise Phronomy::CheckpointAlreadyResumedError,
|
|
58
|
+
"checkpoint #{checkpoint_id} has already been resumed"
|
|
59
|
+
end
|
|
60
|
+
@consumed.add(checkpoint_id)
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Removes tracking for a specific checkpoint ID.
|
|
65
|
+
#
|
|
66
|
+
# Use this to explicitly discard a checkpoint when the application
|
|
67
|
+
# determines it is no longer needed (e.g., user abandons an approval
|
|
68
|
+
# workflow).
|
|
69
|
+
#
|
|
70
|
+
# This method is optional in the duck-type contract. Custom store
|
|
71
|
+
# implementations may choose not to implement it.
|
|
72
|
+
#
|
|
73
|
+
# @param checkpoint_id [String]
|
|
74
|
+
# @return [void]
|
|
75
|
+
# @api public
|
|
76
|
+
def cleanup!(checkpoint_id)
|
|
77
|
+
@consumed.delete(checkpoint_id)
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Removes all tracked checkpoint IDs.
|
|
82
|
+
#
|
|
83
|
+
# Use this for test cleanup, periodic maintenance, or application
|
|
84
|
+
# shutdown.
|
|
85
|
+
#
|
|
86
|
+
# This method is optional in the duck-type contract. Custom store
|
|
87
|
+
# implementations may choose not to implement it.
|
|
88
|
+
#
|
|
89
|
+
# @return [void]
|
|
90
|
+
# @api public
|
|
91
|
+
def clear!
|
|
92
|
+
@consumed.clear
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -49,7 +49,7 @@ module Phronomy
|
|
|
49
49
|
|
|
50
50
|
private
|
|
51
51
|
|
|
52
|
-
# Retry loop for #invoke.
|
|
52
|
+
# Retry loop for #invoke.
|
|
53
53
|
def _invoke_impl(input, messages: [], thread_id: nil, config: {})
|
|
54
54
|
# Fail fast when the token is already cancelled before any LLM call.
|
|
55
55
|
if (token = config[:cancellation_token]) && token.cancelled?
|