phronomy 0.13.0 → 0.15.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -2,74 +2,21 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Accepted
5
+ Superseded by [ADR-011](011-delegate-transport-policy-to-adapters.md).
6
6
 
7
- ## Context
7
+ ## Historical decision
8
8
 
9
- `Agent::Base` exposes `invoke_timeout N` as a class-level DSL. When an invocation
10
- exceeds the timeout, `Phronomy::TimeoutError` is raised to the caller.
9
+ Phronomy previously exposed `Agent::Base.invoke_timeout` as a class-level wait
10
+ boundary. The initial implementation did not stop background work; a later
11
+ implementation attached a cancellation scope to the complete Agent invocation.
11
12
 
12
- The question is: should the timeout also stop the agent's background work?
13
-
14
- Ruby's `Timeout.timeout` / `Thread#kill` can interrupt a running thread, but
15
- doing so is unsafe: it can leave mutexes locked, database connections in a broken
16
- state, and external API calls mid-flight without cleanup. `Thread#raise` has the
17
- same hazards because it can interrupt anywhere inside a `rescue`/`ensure` block.
18
-
19
- Cooperative cancellation (checking a shared flag periodically) is safe but
20
- requires every tool, every LLM call, and every framework-internal loop to
21
- participate — a significant API surface change.
22
-
23
- ## Decision
24
-
25
- `invoke_timeout` is a **wait timeout only**. When the deadline is reached:
26
-
27
- - `TimeoutError` is raised in the calling thread.
28
- - The agent's background thread continues running until it either completes
29
- normally or is garbage-collected when the process ends.
30
- - No cancellation signal is sent to the agent.
31
-
32
- This is explicitly documented in the README and in the DSL source.
33
-
34
- A proper cooperative cancellation mechanism is tracked in Issue #216
35
- (`CancellationToken`), which is a separate feature requiring agent, tool, and
36
- transport layer participation.
37
-
38
- ## Consequences
39
-
40
- **Positive:**
41
- - No risk of leaving shared resources (DB connections, mutexes, sockets) in a
42
- broken state due to forced thread interruption.
43
- - Implementation is simple: `Timeout.timeout` on the calling side only.
44
- - The contract is explicit and predictable.
45
-
46
- **Negative / Tradeoffs:**
47
- - Background threads may continue consuming resources (LLM API quota, etc.)
48
- after the caller has given up.
49
- - Users who expect "cancel" semantics from a timeout will be surprised.
50
- - Proper cancellation requires the `CancellationToken` feature (#216), which
51
- has not yet been implemented.
52
-
53
- ## Extension: PendingOperation#await cooperative cancellation semantics
54
-
55
- `BlockingAdapterPool::PendingOperation#await` also supports both `timeout:` and
56
- `cancellation_token:` parameters. The same non-preemptive rule applies here,
57
- consistent with ADR-010 (cooperative-first, non-preemptive concurrency model):
58
-
59
- 1. **No forcible thread termination.** When a `cancellation_token` is cancelled,
60
- `CancellationError` is raised to the `await` caller; when the timeout fires,
61
- `TimeoutError` is raised instead. In both cases, the underlying worker thread
62
- is **not** killed. The worker runs its block to natural completion.
63
- 2. **Cooperative, not preemptive.** Cancellation takes effect only at `await`
64
- call sites or at explicit `token.check!` checkpoints inside the submitted
65
- block. Code that ignores the token will not be interrupted.
66
- 3. **Timeout scope.** `timeout:` at `await` time is measured from the moment
67
- `await` is called. If both submit-time and await-time timeouts are provided,
68
- the earlier deadline wins.
69
- 4. **Error propagation.** `CancellationError` (or `TimeoutError`) is raised to
70
- the `await` caller; the submitter is responsible for handling it.
71
-
72
- These semantics are identical in spirit to the `invoke_timeout` decision above:
73
- the framework exposes a *wait* boundary, not a hard-kill boundary. Safe resource
74
- cleanup is the caller's responsibility.
13
+ The API has been removed. An Agent class no longer owns a default invocation
14
+ timeout. Applications that need a deadline for a particular root operation pass
15
+ an `InvocationContext` with `deadline:` or `cancellation_token:`. Those values are
16
+ coordination context supplied by the caller, not an implicit Agent execution
17
+ policy.
75
18
 
19
+ LLM transport timeout belongs to RubyLLM or another configured LLM adapter.
20
+ Tool transport timeout belongs to the Tool implementation or its underlying
21
+ client. Phronomy retains generic deadline and cancellation primitives for the
22
+ execution tree it coordinates.
@@ -0,0 +1,82 @@
1
+ # ADR-011: Delegate Transport Timeout and Retry to Adapters
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ Phronomy accumulated execution-policy settings at several layers:
10
+
11
+ - `Agent::Base.retry_policy`, which replayed the complete Agent invocation;
12
+ - `Agent::Base.invoke_timeout`, which imposed an Agent-class deadline;
13
+ - `config[:llm_timeout]`, applied outside RubyLLM;
14
+ - Tool `retry_on` and `config[:tool_timeout]`;
15
+ - `max_parallel_tools` and the unused `InvocationContext#provider_limits`.
16
+
17
+ The policies did not share a reliable resource model. In particular, replaying
18
+ an Agent or Tool can repeat external side effects, and a pool-level timeout does
19
+ not terminate a blocking provider call that is already running. RubyLLM already
20
+ owns LLM request timeout, transient-error retry, backoff, and jitter. Tool
21
+ implementations commonly use clients that own equivalent transport behavior.
22
+
23
+ ## Decision
24
+
25
+ 1. RubyLLM, or another configured LLM adapter, owns LLM transport timeout,
26
+ retry, backoff, jitter, and provider rate-limit handling.
27
+ 2. Phronomy translates final provider errors but does not replay the complete
28
+ Agent invocation.
29
+ 3. Tool implementations or their underlying clients own Tool-specific timeout
30
+ and retry. Phronomy does not provide a generic Tool replay policy.
31
+ 4. Agent classes do not own a default invocation timeout. Callers may pass an
32
+ `InvocationContext` containing a `deadline` or `cancellation_token` when a
33
+ specific root operation needs a boundary.
34
+ 5. Phronomy retains the timeout mechanisms for boundaries it owns: Workflow
35
+ actions, authorization evaluation, Orchestrator aggregate waits, Runtime
36
+ shutdown/drain, and generic concurrency primitives.
37
+ 6. Parallel Tool execution is a boolean mode. When enabled, all authorized
38
+ calls in the intercepted batch are dispatched. Runtime's bounded workers and
39
+ queues remain the coarse process-protection mechanism.
40
+ 7. No resource manager, provider limiter, priority scheduler, or compatibility
41
+ shim is introduced by this change.
42
+
43
+ ## Consequences
44
+
45
+ ### Positive
46
+
47
+ - LLM transport behavior has one configuration authority.
48
+ - Agent and Tool side effects are not implicitly replayed by the framework.
49
+ - Timeout behavior is controlled at the layer capable of interrupting or
50
+ cancelling the underlying I/O safely.
51
+ - Invocation context remains small and contains only values consumed by the
52
+ execution path.
53
+ - The Agent FSM has one invocation attempt and one error propagation path.
54
+
55
+ ### Tradeoffs
56
+
57
+ - Applications must configure RubyLLM explicitly when its defaults are not
58
+ appropriate for production.
59
+ - Custom LLM adapters must document and implement their own transport policy.
60
+ - Tool authors are responsible for idempotency when they choose to retry in a
61
+ Tool or client.
62
+ - Enabling parallel Tool execution dispatches the complete authorized batch;
63
+ applications should leave it disabled when unbounded batch fan-out is not
64
+ acceptable.
65
+
66
+ ## Example
67
+
68
+ ```ruby
69
+ RubyLLM.configure do |config|
70
+ config.request_timeout = 120
71
+ config.max_retries = 3
72
+ config.retry_interval = 0.1
73
+ config.retry_backoff_factor = 2
74
+ config.retry_interval_randomness = 0.5
75
+ end
76
+
77
+ context = Phronomy::InvocationContext.new(
78
+ deadline: Phronomy::Concurrency::Deadline.in(30)
79
+ )
80
+
81
+ result = MyAgent.new.invoke("Hello", invocation_context: context)
82
+ ```
@@ -0,0 +1,75 @@
1
+ # MCP client support
2
+
3
+ Phronomy supports the official `mcp` Ruby SDK **1.x**. MCP SDK 0.x is not supported.
4
+
5
+ ## Transports
6
+
7
+ `Phronomy::Tools::Mcp.from_server` supports:
8
+
9
+ - `stdio://...`
10
+ - `http://...`
11
+ - `https://...`
12
+
13
+ HTTP and SSE support are standard dependencies of Phronomy through `faraday` and
14
+ `event_stream_parser`.
15
+
16
+ ## Input-schema subset
17
+
18
+ Phronomy intentionally supports a strict subset of JSON Schema 2020-12 for MCP
19
+ Tool inputs.
20
+
21
+ Supported root keywords:
22
+
23
+ - `$schema`
24
+ - `type` (`object` only)
25
+ - `properties`
26
+ - `required`
27
+ - `title`
28
+ - `description`
29
+ - `additionalProperties` when omitted or `false`
30
+
31
+ Supported property types:
32
+
33
+ - `string`
34
+ - `integer`
35
+ - `number`
36
+ - `boolean`
37
+
38
+ Supported property keywords:
39
+
40
+ - `type`
41
+ - `description`
42
+ - `enum`
43
+ - `title`
44
+
45
+ The following validation keywords are accepted but ignored with a warning:
46
+
47
+ - `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`
48
+ - `minLength`, `maxLength`, `pattern`, `format`, `default`, `examples`
49
+
50
+ Unknown keywords, structural composition (`oneOf`, `anyOf`, `allOf`, `$ref`,
51
+ conditionals), arrays, nested objects, and nullable type arrays fail fast with
52
+ `Phronomy::ToolError` rather than silently changing the Tool contract.
53
+
54
+ When `additionalProperties` is omitted, the remote schema is accepted, but
55
+ Phronomy still exposes and accepts only names declared in `properties`.
56
+
57
+ `outputSchema` is detected and reported with a warning, but is not validated in
58
+ this phase.
59
+
60
+ ## Errors and cancellation
61
+
62
+ - JSON-RPC errors raised by the SDK are converted to `Phronomy::ToolError`.
63
+ - MCP Tool results with `isError: true` are returned to the model as Tool error
64
+ text so the model can correct its arguments.
65
+ - An expired HTTP session is reconnected for future calls. The failed Tool call
66
+ is not replayed because Tool side effects are not assumed to be idempotent.
67
+ - Cancellation invalidates the current client and transport. The next call creates
68
+ a new connection, preventing an abandoned SDK worker from sharing the old stdio
69
+ stream with a new request.
70
+
71
+ Calls, reconnects, and explicit `close` operations on a Tool instance are
72
+ serialized. `close` synchronously closes the current client and permits a later
73
+ call to reconnect. A transport detached by cancellation is owned by the bounded
74
+ MCP cleanup pool and is drained during Runtime shutdown; `close` does not wait
75
+ for an older detached transport.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phronomy"
4
+ require "securerandom"
5
+
6
+ class GenerationContext
7
+ include Phronomy::WorkflowContext
8
+
9
+ field :prompt
10
+ field :generation_request_id
11
+ field :answer
12
+ field :error_message
13
+
14
+ # This is application logic. Phronomy transports the event and payload but
15
+ # does not decide which Agent invocation is current or where the result lives.
16
+ def handle_fsm_event(event)
17
+ request_id = event.payload[:generation_request_id]
18
+ return :consume unless request_id == generation_request_id
19
+
20
+ case event.type
21
+ when :generation_completed
22
+ self.answer = event.payload[:agent_result][:output]
23
+ when :generation_failed
24
+ self.error_message = event.payload[:error].message
25
+ end
26
+ false
27
+ end
28
+ end
29
+
30
+ class AnswerAgent < Phronomy::Agent::Base
31
+ model "gpt-4o-mini"
32
+ instructions "Answer clearly and briefly."
33
+ end
34
+
35
+ agent = AnswerAgent.new
36
+ workflow = nil
37
+
38
+ workflow = Phronomy::Workflow.define(GenerationContext) do
39
+ initial :generating
40
+
41
+ state :generating, action: ->(context) {
42
+ request_id = context.generation_request_id
43
+
44
+ # The Agent Task is intentionally not returned from the entry action.
45
+ # on_event is the application-level integration channel.
46
+ agent.invoke_async(
47
+ context.prompt,
48
+ on_event: ->(agent_event) {
49
+ workflow_event =
50
+ case agent_event.type
51
+ when :done
52
+ :generation_completed
53
+ when :error, :timeout, :cancelled, :approval_required
54
+ :generation_failed
55
+ end
56
+ next unless workflow_event
57
+
58
+ workflow.signal(
59
+ thread_id: context.thread_id,
60
+ event: workflow_event,
61
+ payload: {
62
+ generation_request_id: request_id,
63
+ agent_result: (
64
+ agent_event.payload if agent_event.type == :done
65
+ ),
66
+ error:
67
+ agent_event.payload[:error] ||
68
+ Phronomy::Error.new(
69
+ "Agent requested Tool approval"
70
+ )
71
+ }
72
+ )
73
+ }
74
+ )
75
+
76
+ context
77
+ }
78
+
79
+ state :succeeded
80
+ state :failed
81
+
82
+ transition(
83
+ from: :generating,
84
+ on: :generation_completed,
85
+ to: :succeeded
86
+ )
87
+ transition(
88
+ from: :generating,
89
+ on: :generation_failed,
90
+ to: :failed
91
+ )
92
+ transition from: :succeeded, to: :__finish__
93
+ transition from: :failed, to: :__finish__
94
+ end
95
+
96
+ result = workflow.invoke(
97
+ {
98
+ prompt: "What is Run-to-Completion?",
99
+ generation_request_id: SecureRandom.uuid
100
+ },
101
+ config: {thread_id: SecureRandom.uuid}
102
+ )
103
+
104
+ puts(result.answer || result.error_message)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phronomy"
4
+
5
+ class ImportContext
6
+ include Phronomy::WorkflowContext
7
+
8
+ field :record_count, default: 0
9
+ field :error_message
10
+
11
+ def handle_fsm_event(event)
12
+ case event.type
13
+ when :import_completed
14
+ self.record_count = event.payload[:record_count]
15
+ when :import_failed
16
+ self.error_message = event.payload[:error].message
17
+ end
18
+ false
19
+ end
20
+ end
21
+
22
+ workflow = nil
23
+
24
+ workflow = Phronomy::Workflow.define(ImportContext) do
25
+ initial :importing
26
+
27
+ state :importing, action: ->(context) {
28
+ task = Phronomy::Runtime.instance.spawn do
29
+ # Replace with application-owned asynchronous work.
30
+ 100
31
+ end
32
+
33
+ task.on_complete do |record_count, error|
34
+ workflow.signal(
35
+ thread_id: context.thread_id,
36
+ event: error ? :import_failed : :import_completed,
37
+ payload: {
38
+ record_count: record_count,
39
+ error: error
40
+ }
41
+ )
42
+ end
43
+
44
+ # Do not return task. The state is active after this synchronous entry ends.
45
+ context
46
+ }
47
+
48
+ state :completed
49
+ state :failed
50
+
51
+ transition from: :importing, on: :import_completed, to: :completed
52
+ transition from: :importing, on: :import_failed, to: :failed
53
+ transition from: :completed, to: :__finish__
54
+ transition from: :failed, to: :__finish__
55
+ end
56
+
57
+ result = workflow.invoke({})
58
+ puts "Imported #{result.record_count} records"
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec path: ".."
6
+
7
+ gem "mcp", "= 1.0.0"
8
+ gem "rspec", "~> 3.0"
9
+ gem "webrick", "~> 1.8"