phronomy 0.9.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e84ccabf84c48e16cdb968c1f7b69f2348b24a70e477aa39bbbe1244d34edfc
4
- data.tar.gz: f31dc2d1c4ed4bb7717e88278f1ced3debd0177f1f7a8042b170421a5d8e7493
3
+ metadata.gz: f8bcaf54fe256791053b04c22df3b58595d267b2c0ee16b67369fe2ecb36f19d
4
+ data.tar.gz: 9be01ec03a79ec5d557a3c51fc442992cd012a07e682a43cfa4c9e0358110fc8
5
5
  SHA512:
6
- metadata.gz: 1c1ab4d05c27930b84abbad09f5c59027f9bfcddf9a89aa485608afdcd22ba50fcf971c2185a815206edfc37b29abb0fa99b7f80a8fa3f436c1d6a97b5ad38e4
7
- data.tar.gz: 04016a561705ff24c4a6b9f8bb3d6918c303071f7bf97d94d70313b95f796ae561fee29fad9e7e620928655bf7e2007751cfa217bd973d83d2ad4d26d9754e3e
6
+ metadata.gz: 44de75b5cc59ddf380aeb0be3abcf2a2c566cb8a1db6c35540525b86c74e7e0c06e75a3581e30276a25eea7f5f0334226e05b54d7d9694ed8f2b881d04a54e60
7
+ data.tar.gz: 76821f0cad1a918b762bb1d5737f902cc67b726f8dafb0dcf646b5cfc8dd4173527733a8a04e92e71ebea9a152fdb6ebaea20d9ccde3037f32d7fc32f5b05497
data/CHANGELOG.md CHANGED
@@ -11,10 +11,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  ---
13
13
 
14
- ## [0.9.1] - 2026-06-06
14
+ ## [0.10.0] - 2026-06-08
15
15
 
16
16
  ### Added
17
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
+
46
+ ### Added
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
+
18
56
  - **`Phronomy::Diagnostics` and `SchedulerReentrancyError`** (#278, #279):
19
57
  `Phronomy::Diagnostics` exposes a snapshot of current scheduler state
20
58
  (`pending_count`, `active_tasks`, `pool_utilization`, etc.) for debugging and
@@ -233,6 +271,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
233
271
 
234
272
  ### Added (post-v0.9.0)
235
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
+
236
292
  - **`Phronomy::Agent::CheckpointStore` — idempotency store for HITL resume** (post-v0.9.0):
237
293
  New in-memory store tracks consumed checkpoint IDs. Calling `Agent::Base#resume` twice
238
294
  with the same checkpoint raises `Phronomy::CheckpointAlreadyResumedError` instead of
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#run_as_child` (child-FSM pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
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 |
@@ -199,20 +200,18 @@ final = app.send_event(state: state, event: :approve)
199
200
  puts "Approved: #{final.approved}" # => true
200
201
  ```
201
202
 
202
- In EventLoop mode (`c.event_loop = true`), `Agent#run_as_child` spawns a child agent
203
- asynchronously. When the child succeeds, `:child_completed` is dispatched with the result
204
- `{ output:, messages:, usage: }` as its payload; when it fails, `:child_failed` is
205
- 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:
206
206
 
207
207
  ```ruby
208
- # EventLoop mode: workflow that runs an agent as a child FSM.
209
- # The result { output:, messages:, usage: } arrives as the :child_completed event
210
- # payload — write it back to the context in the target state's entry action.
211
- entry :run_agent, ->(ctx) {
212
- MyAgent.new.run_as_child(ctx.query, ctx: ctx)
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
213
213
  }
214
- transition from: :run_agent, on: :child_completed, to: :done
215
- transition from: :run_agent, on: :child_failed, to: :handle_error
214
+ transition from: :translate, to: :done # no on: needed
216
215
  ```
217
216
 
218
217
  ### Multi-Agent — Agent-as-Tool pattern
@@ -532,56 +532,6 @@ module Phronomy
532
532
  end
533
533
  end
534
534
 
535
- # Registers this agent as a child {AgentFSM} inside the given Workflow context.
536
- #
537
- # Use this method from a Workflow entry action (running on the EventLoop thread)
538
- # instead of {#invoke}, which would raise a deadlock error because +invoke+ blocks
539
- # on a +Thread::Queue+ when EventLoop mode is active.
540
- #
541
- # The agent runs asynchronously in a background IO thread. When it finishes, the
542
- # parent {FSMSession} receives a +:child_completed+ event whose payload is the
543
- # result hash +{ output:, messages:, usage: }+. Declare an +on: :child_completed+
544
- # transition in your Workflow to advance to the next state.
545
- #
546
- # The result is delivered exclusively as the +:child_completed+ event payload.
547
- # The parent Workflow task is the sole owner of the parent +WorkflowContext+ and
548
- # applies the result after receiving the event — no background thread writes to
549
- # the parent context directly.
550
- #
551
- # @example
552
- # entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
553
- # transition from: :run_agent, on: :child_completed, to: :process_result
554
- #
555
- # @param input [String, Hash] user input passed to the agent
556
- # @param ctx [Object] a WorkflowContext that responds to +#thread_id+
557
- # @param messages [Array] prior conversation history
558
- # @param config [Hash] invocation config (forwarded to +_invoke_impl+)
559
- # @return [nil] the caller must not wait on any return value;
560
- # the result arrives as a +:child_completed+ event
561
- # @raise [Phronomy::Error] when EventLoop mode is not enabled
562
- # @api public
563
- def run_as_child(input, ctx:, messages: [], config: {})
564
- unless Phronomy.configuration.event_loop
565
- raise Phronomy::Error,
566
- "run_as_child requires EventLoop mode. " \
567
- "Enable with: Phronomy.configure { |c| c.event_loop = true }"
568
- end
569
-
570
- parent_id = ctx.thread_id
571
- thread_id = "#{parent_id}_agent_#{SecureRandom.uuid}"
572
- Phronomy::Runtime.instance.spawn(name: "agent-child:#{thread_id}") do
573
- result = _invoke_impl(input, messages: messages, thread_id: thread_id, config: config)
574
- Phronomy::EventLoop.instance.post(
575
- Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
576
- )
577
- rescue => e
578
- Phronomy::EventLoop.instance.post(
579
- Phronomy::Event.new(type: :child_failed, target_id: parent_id, payload: e)
580
- )
581
- end
582
- nil
583
- end
584
-
585
535
  # Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
586
536
  # as they are produced by the underlying LLM.
587
537
  #
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Phronomy
6
+ class Task
7
+ # Backend for Tasks created by {Task#map}.
8
+ #
9
+ # A mapped task's lifecycle is driven entirely by the +on_complete+
10
+ # callback of its source task — it never spawns a thread of its own.
11
+ # +MappedBackend+ transitions the owning task to +:running+ immediately
12
+ # on initialization so that +FSMSession+ treats it as an in-progress
13
+ # async action. Completion (or failure) is triggered externally via
14
+ # {Task#transition!} from the +on_complete+ callback registered by
15
+ # {Task#map}.
16
+ #
17
+ # +await+ and +join+ block until {#unblock} is called, which {Task#map}
18
+ # arranges by registering a second +on_complete+ callback on the *mapped*
19
+ # task itself after the transform callback has been registered.
20
+ #
21
+ # @api private
22
+ class MappedBackend < Backend
23
+ def initialize(task:, &)
24
+ super
25
+ @done_queue = Queue.new
26
+ task.transition!(:running)
27
+ end
28
+
29
+ # Unblocks +await+ / +join+. Called by {Task#map} after the mapped task
30
+ # reaches a terminal state.
31
+ # @api private
32
+ def unblock(value, error)
33
+ @done_queue.push([value, error])
34
+ end
35
+
36
+ # Blocks until the mapped task reaches a terminal state.
37
+ # @return [Object] the mapped value
38
+ # @raise [Exception] if the source task or the map block raised an error
39
+ # @api private
40
+ def await
41
+ value, error = @done_queue.pop
42
+ raise error if error
43
+
44
+ value
45
+ end
46
+
47
+ # Returns +false+ — a mapped task has no independent thread to kill.
48
+ # @return [Boolean]
49
+ # @api private
50
+ def alive?
51
+ false
52
+ end
53
+
54
+ # No-op — mapped tasks carry no independent thread to cancel.
55
+ # @return [self]
56
+ # @api private
57
+ def cancel!
58
+ self
59
+ end
60
+
61
+ # Blocks until the mapped task completes, with an optional timeout.
62
+ # @param limit [Numeric, nil]
63
+ # @return [Object, nil] +nil+ on timeout
64
+ # @api private
65
+ def join(limit = nil)
66
+ if limit.nil?
67
+ await
68
+ else
69
+ begin
70
+ Timeout.timeout(limit) { await }
71
+ rescue Timeout::Error
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
data/lib/phronomy/task.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "task/backend"
4
4
  require_relative "task/thread_backend"
5
5
  require_relative "task/immediate_backend"
6
6
  require_relative "task/fiber_backend"
7
+ require_relative "task/mapped_backend"
7
8
 
8
9
  module Phronomy
9
10
  # A single unit of concurrent work.
@@ -195,6 +196,58 @@ module Phronomy
195
196
  self
196
197
  end
197
198
 
199
+ # Returns a new {Task} whose completed value is the result of applying
200
+ # +block+ to this task's completed value.
201
+ #
202
+ # If this task fails or is cancelled, the mapped task also fails/is
203
+ # cancelled with the same error. The block is never called in error cases.
204
+ #
205
+ # The primary use-case is transforming an agent result into a
206
+ # {WorkflowContext} so that a Workflow entry action can return a Task
207
+ # whose value is picked up by {Workflow::FSMSession} via the existing
208
+ # +:action_completed+ path:
209
+ #
210
+ # @example Returning agent output into a Workflow state field
211
+ # entry :translate, ->(ctx) {
212
+ # TranslationAgent.new.invoke_async(ctx.query).map do |result|
213
+ # ctx.merge(answer: result[:output]) # returns WorkflowContext
214
+ # end
215
+ # }
216
+ #
217
+ # @yield [value] the completed value of this task
218
+ # @yieldreturn [Object] the value for the mapped task
219
+ # @return [Task] a new task that completes when this task does
220
+ # @api public
221
+ def map(&block)
222
+ # MappedBackend drives the task lifecycle entirely via on_complete;
223
+ # it never spawns a thread of its own.
224
+ mapped = self.class.spawn(
225
+ name: "#{@name}-mapped",
226
+ parent: @parent,
227
+ backend_class: MappedBackend
228
+ ) {}
229
+
230
+ on_complete do |value, error|
231
+ mapped_value = nil
232
+ mapped_error = error
233
+ unless error
234
+ begin
235
+ mapped_value = block.call(value)
236
+ rescue => e
237
+ mapped_error = e
238
+ end
239
+ end
240
+ if mapped_error
241
+ mapped.transition!(:failed, error: mapped_error)
242
+ else
243
+ mapped.transition!(:completed, value: mapped_value)
244
+ end
245
+ # Unblock mapped.await / mapped.join after the terminal transition.
246
+ mapped.backend.unblock(mapped_value, mapped_error)
247
+ end
248
+ mapped
249
+ end
250
+
198
251
  # Returns +true+ once the task has finished (success, error, or cancellation).
199
252
  # @return [Boolean]
200
253
  # @api private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.9.1"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/phronomy.rb CHANGED
@@ -123,7 +123,8 @@ module Phronomy
123
123
  # Raised when a {Phronomy::WorkflowContext} field is mutated from a thread
124
124
  # that does not own the context (i.e. not the EventLoop dispatch thread).
125
125
  # Only raised in EventLoop mode. Use +context.merge(...)+ to produce a new
126
- # context, or deliver updates as +:child_completed+ event payloads.
126
+ # context, or deliver updates as +:action_completed+ event payloads
127
+ # via {Agent::Base#invoke_async} + {Task#map}.
127
128
  class WorkflowContextOwnershipError < Error; end
128
129
 
129
130
  class << self
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-06-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -189,6 +189,7 @@ files:
189
189
  - lib/phronomy/task/backend.rb
190
190
  - lib/phronomy/task/fiber_backend.rb
191
191
  - lib/phronomy/task/immediate_backend.rb
192
+ - lib/phronomy/task/mapped_backend.rb
192
193
  - lib/phronomy/task/thread_backend.rb
193
194
  - lib/phronomy/task_group.rb
194
195
  - lib/phronomy/testing.rb