phronomy 0.14.0 → 0.15.1
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 +77 -0
- data/README.md +236 -57
- data/benchmark/bench_agent_invoke.rb +2 -3
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
- data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
- data/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -0
- data/lib/phronomy/agent/agent_invocation.rb +385 -0
- data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
- data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
- data/lib/phronomy/agent/async_event_api.rb +553 -0
- data/lib/phronomy/agent/base.rb +242 -509
- data/lib/phronomy/agent/context/capability/base.rb +51 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -137
- data/lib/phronomy/agent/tool_approval_request.rb +121 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
- data/lib/phronomy/agent/tool_executor.rb +47 -69
- data/lib/phronomy/agent/tool_invocation.rb +634 -0
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
- data/lib/phronomy/agent.rb +21 -9
- data/lib/phronomy/configuration.rb +42 -6
- data/lib/phronomy/engine/event_loop.rb +269 -112
- data/lib/phronomy/engine/fsm_session.rb +180 -142
- data/lib/phronomy/engine/task.rb +5 -10
- data/lib/phronomy/event.rb +8 -8
- data/lib/phronomy/generator_verifier.rb +253 -142
- data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
- data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
- data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
- data/lib/phronomy/invocation_context.rb +5 -19
- data/lib/phronomy/llm_adapter/base.rb +25 -34
- data/lib/phronomy/metrics.rb +2 -0
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/tools/mcp.rb +25 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -186
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +54 -102
- data/lib/phronomy/workflow_runner.rb +238 -300
- data/lib/phronomy.rb +6 -4
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +18 -47
- data/lib/phronomy/agent/concerns/retryable.rb +0 -103
- data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
- data/lib/phronomy/agent/invocation_context.rb +0 -171
- data/lib/phronomy/agent/invocation_session.rb +0 -352
- data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
|
@@ -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,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,385 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
# Mutable domain state for one Agent invocation.
|
|
8
|
+
#
|
|
9
|
+
# AgentInvocation interprets Agent-internal events. FSMSession owns the
|
|
10
|
+
# transition mechanics, while Agent::Base projects the terminal outcome to
|
|
11
|
+
# both the Application listener and the returned Task.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
class AgentInvocation
|
|
15
|
+
TOOL_EVENT_TYPES = %i[
|
|
16
|
+
tool_authorized
|
|
17
|
+
tool_approval_required
|
|
18
|
+
tool_completed
|
|
19
|
+
tool_failed
|
|
20
|
+
tool_rejected
|
|
21
|
+
tool_cancelled
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
LLM_EVENT_TYPES = %i[
|
|
25
|
+
llm_completed
|
|
26
|
+
llm_failed
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
attr_accessor :input,
|
|
30
|
+
:messages,
|
|
31
|
+
:chat,
|
|
32
|
+
:output,
|
|
33
|
+
:usage,
|
|
34
|
+
:input_blocked,
|
|
35
|
+
:output_blocked,
|
|
36
|
+
:block_error,
|
|
37
|
+
:user_message_sent,
|
|
38
|
+
:event_listener,
|
|
39
|
+
:approval_request,
|
|
40
|
+
:rejected,
|
|
41
|
+
:error
|
|
42
|
+
|
|
43
|
+
attr_reader :id,
|
|
44
|
+
:agent,
|
|
45
|
+
:config,
|
|
46
|
+
:thread_id,
|
|
47
|
+
:approval_policy,
|
|
48
|
+
:approval_listener,
|
|
49
|
+
:pending_tool_calls,
|
|
50
|
+
:tool_invocations,
|
|
51
|
+
:session_id,
|
|
52
|
+
:phase,
|
|
53
|
+
:mode
|
|
54
|
+
|
|
55
|
+
def initialize(
|
|
56
|
+
agent:,
|
|
57
|
+
input:,
|
|
58
|
+
messages:,
|
|
59
|
+
config:,
|
|
60
|
+
approval_policy: nil,
|
|
61
|
+
approval_listener: nil,
|
|
62
|
+
event_listener: nil,
|
|
63
|
+
stream_listener: nil,
|
|
64
|
+
mode: nil,
|
|
65
|
+
id: nil
|
|
66
|
+
)
|
|
67
|
+
@agent = agent
|
|
68
|
+
@input = input
|
|
69
|
+
@messages = Array(messages)
|
|
70
|
+
@config = config
|
|
71
|
+
@thread_id = config[:thread_id]
|
|
72
|
+
@id = (id || config[:agent_invocation_id] || SecureRandom.uuid).to_s
|
|
73
|
+
invocation_context = config[:invocation_context]
|
|
74
|
+
invocation_policy = if invocation_context&.respond_to?(:approval_policy)
|
|
75
|
+
invocation_context.approval_policy
|
|
76
|
+
end
|
|
77
|
+
@approval_policy = invocation_policy || approval_policy
|
|
78
|
+
@approval_listener = approval_listener
|
|
79
|
+
@event_listener = event_listener || stream_listener
|
|
80
|
+
@mode = (mode || (stream_listener ? :stream : :invoke)).to_sym
|
|
81
|
+
|
|
82
|
+
@chat = nil
|
|
83
|
+
@output = nil
|
|
84
|
+
@usage = nil
|
|
85
|
+
@input_blocked = false
|
|
86
|
+
@output_blocked = false
|
|
87
|
+
@block_error = nil
|
|
88
|
+
@user_message_sent = false
|
|
89
|
+
@pending_tool_calls = []
|
|
90
|
+
@tool_invocations = []
|
|
91
|
+
@approval_request = nil
|
|
92
|
+
@rejected = false
|
|
93
|
+
@human_rejection = false
|
|
94
|
+
@approval_resume_in_progress = false
|
|
95
|
+
@pending_approval_resolution_ids = []
|
|
96
|
+
@error = nil
|
|
97
|
+
@session_id = nil
|
|
98
|
+
@phase = nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Compatibility aliases for existing internal callers.
|
|
102
|
+
def stream_listener
|
|
103
|
+
@event_listener
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def stream_listener=(listener)
|
|
107
|
+
@event_listener = listener
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def streaming?
|
|
111
|
+
@mode == :stream
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def set_graph_metadata(thread_id: nil, phase: nil)
|
|
115
|
+
@session_id = thread_id if thread_id
|
|
116
|
+
@phase = phase
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def pending_tool_calls=(calls)
|
|
120
|
+
@pending_tool_calls = Array(calls)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def tool_invocations=(invocations)
|
|
124
|
+
@tool_invocations = Array(invocations)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def clear_tool_batch!
|
|
128
|
+
@pending_tool_calls = []
|
|
129
|
+
@tool_invocations = []
|
|
130
|
+
@approval_request = nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle_fsm_event(event)
|
|
134
|
+
if event.type == :llm_stream_chunk
|
|
135
|
+
deliver_event(
|
|
136
|
+
StreamEvent.new(
|
|
137
|
+
type: :token,
|
|
138
|
+
payload: {content: event.payload.fetch(:content)}
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
return true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
if LLM_EVENT_TYPES.include?(event.type)
|
|
145
|
+
apply_llm_event(event)
|
|
146
|
+
return true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
return false unless TOOL_EVENT_TYPES.include?(event.type)
|
|
150
|
+
|
|
151
|
+
invocation = tool_invocation(
|
|
152
|
+
event.payload&.fetch(:tool_invocation_id, nil)
|
|
153
|
+
)
|
|
154
|
+
return true unless invocation
|
|
155
|
+
|
|
156
|
+
@error ||= invocation.error if invocation.failed? || invocation.cancelled?
|
|
157
|
+
@rejected = true if invocation.rejected?
|
|
158
|
+
if @approval_resume_in_progress &&
|
|
159
|
+
@pending_approval_resolution_ids.delete(invocation.id)
|
|
160
|
+
if @pending_approval_resolution_ids.empty?
|
|
161
|
+
@approval_resume_in_progress = false
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
true
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Deprecated internal compatibility hook. FSMSession no longer calls
|
|
168
|
+
# this method; asynchronous results enter through explicit events.
|
|
169
|
+
def apply_fsm_action_result(result)
|
|
170
|
+
event_type =
|
|
171
|
+
if result.respond_to?(:error) &&
|
|
172
|
+
result.error &&
|
|
173
|
+
!result.error.is_a?(ToolCallIntercepted)
|
|
174
|
+
:llm_failed
|
|
175
|
+
else
|
|
176
|
+
:llm_completed
|
|
177
|
+
end
|
|
178
|
+
handle_fsm_event(
|
|
179
|
+
Phronomy::Event.new(
|
|
180
|
+
type: event_type,
|
|
181
|
+
target_id: @id,
|
|
182
|
+
payload: result
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
self
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def accept_tool_calls!(tool_calls)
|
|
189
|
+
@user_message_sent = true
|
|
190
|
+
@pending_tool_calls = Array(tool_calls)
|
|
191
|
+
@messages = @chat.messages
|
|
192
|
+
@pending_tool_calls.each do |tool_call|
|
|
193
|
+
deliver_event(
|
|
194
|
+
StreamEvent.new(
|
|
195
|
+
type: :tool_call,
|
|
196
|
+
payload: {tool_call: tool_call}
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
self
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def apply_llm_response!(response)
|
|
204
|
+
unless response
|
|
205
|
+
raise Phronomy::Error, "LLM operation completed without a response"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
@user_message_sent = true
|
|
209
|
+
@output = response.content
|
|
210
|
+
@usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
211
|
+
@messages = @chat.messages
|
|
212
|
+
@pending_tool_calls = []
|
|
213
|
+
self
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def tool_invocation(id)
|
|
217
|
+
@tool_invocations.find { |invocation| invocation.id == id.to_s }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def merge_config!(values)
|
|
221
|
+
@config.merge!(values) unless values.empty?
|
|
222
|
+
self
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def approval_context
|
|
226
|
+
return @config[:approval_context] if @config[:approval_context]
|
|
227
|
+
|
|
228
|
+
context = @config[:invocation_context]
|
|
229
|
+
return {} unless context
|
|
230
|
+
|
|
231
|
+
%i[
|
|
232
|
+
thread_id session_id user_id token_budget
|
|
233
|
+
task_id parent_task_id
|
|
234
|
+
].each_with_object({}) do |name, result|
|
|
235
|
+
if context.respond_to?(name)
|
|
236
|
+
result[name] = context.public_send(name)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def begin_approval_resume!(approved:)
|
|
242
|
+
@human_rejection = !approved
|
|
243
|
+
@pending_approval_resolution_ids = @tool_invocations
|
|
244
|
+
.select(&:awaiting_approval?)
|
|
245
|
+
.map(&:id)
|
|
246
|
+
@approval_resume_in_progress =
|
|
247
|
+
!@pending_approval_resolution_ids.empty?
|
|
248
|
+
self
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def prepare_approval_request!
|
|
252
|
+
@approval_request = ToolApprovalRequest.build(self)
|
|
253
|
+
self
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def record_tool_results!
|
|
257
|
+
@tool_invocations.each do |invocation|
|
|
258
|
+
@chat.add_message(
|
|
259
|
+
role: :tool,
|
|
260
|
+
content: invocation.result.to_s,
|
|
261
|
+
tool_call_id: invocation.tool_call_id
|
|
262
|
+
)
|
|
263
|
+
deliver_event(
|
|
264
|
+
StreamEvent.new(
|
|
265
|
+
type: :tool_result,
|
|
266
|
+
payload: {
|
|
267
|
+
tool_call_id: invocation.tool_call_id,
|
|
268
|
+
tool_name: invocation.tool_name,
|
|
269
|
+
tool_result: invocation.result
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
@messages = @chat.messages
|
|
275
|
+
clear_tool_batch!
|
|
276
|
+
self
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def input_passed?
|
|
280
|
+
!@input_blocked
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def input_blocked?
|
|
284
|
+
@input_blocked
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def output_passed?
|
|
288
|
+
!@output_blocked
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def output_blocked?
|
|
292
|
+
@output_blocked
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def tool_call_pending?
|
|
296
|
+
!@pending_tool_calls.empty?
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def preflight_complete?
|
|
300
|
+
!@tool_invocations.empty? &&
|
|
301
|
+
@tool_invocations.all?(&:preflight_settled?)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def approval_required?
|
|
305
|
+
!@human_rejection &&
|
|
306
|
+
!@approval_resume_in_progress &&
|
|
307
|
+
preflight_complete? &&
|
|
308
|
+
@tool_invocations.any?(&:awaiting_approval?)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def ready_to_dispatch?
|
|
312
|
+
!@approval_resume_in_progress &&
|
|
313
|
+
preflight_complete? &&
|
|
314
|
+
@tool_invocations.none?(&:awaiting_approval?) &&
|
|
315
|
+
@tool_invocations.none?(&:rejected?) &&
|
|
316
|
+
@tool_invocations.none?(&:failed?) &&
|
|
317
|
+
@tool_invocations.none?(&:cancelled?) &&
|
|
318
|
+
@tool_invocations.any?(&:authorized?)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def tool_batch_terminal?
|
|
322
|
+
!@tool_invocations.empty? &&
|
|
323
|
+
@tool_invocations.all?(&:terminal?)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def tool_batch_failed?
|
|
327
|
+
return false if @human_rejection || @approval_resume_in_progress
|
|
328
|
+
|
|
329
|
+
no_rejection = @tool_invocations.none?(&:rejected?)
|
|
330
|
+
preflight_failure =
|
|
331
|
+
preflight_complete? &&
|
|
332
|
+
@tool_invocations.any? do |invocation|
|
|
333
|
+
invocation.failed? || invocation.cancelled?
|
|
334
|
+
end
|
|
335
|
+
terminal_failure =
|
|
336
|
+
tool_batch_terminal? &&
|
|
337
|
+
@tool_invocations.any? do |invocation|
|
|
338
|
+
invocation.failed? || invocation.cancelled?
|
|
339
|
+
end
|
|
340
|
+
no_rejection && (preflight_failure || terminal_failure)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def tool_batch_rejected?
|
|
344
|
+
return false unless @tool_invocations.any?(&:rejected?)
|
|
345
|
+
|
|
346
|
+
@human_rejection ? tool_batch_terminal? : preflight_complete?
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def tool_batch_completed?
|
|
350
|
+
tool_batch_terminal? &&
|
|
351
|
+
@tool_invocations.all?(&:execution_completed?)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
private
|
|
355
|
+
|
|
356
|
+
def apply_llm_event(event)
|
|
357
|
+
result = event.payload
|
|
358
|
+
unless result.is_a?(LLMOperationResult)
|
|
359
|
+
raise Phronomy::Error,
|
|
360
|
+
"Expected LLMOperationResult, got #{result.class}"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
if event.type == :llm_failed
|
|
364
|
+
@error = result.error ||
|
|
365
|
+
Phronomy::Error.new("LLM operation failed without an error")
|
|
366
|
+
return
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
if result.error
|
|
370
|
+
if result.error.is_a?(ToolCallIntercepted)
|
|
371
|
+
accept_tool_calls!(result.error.tool_calls)
|
|
372
|
+
else
|
|
373
|
+
@error = result.error
|
|
374
|
+
end
|
|
375
|
+
else
|
|
376
|
+
apply_llm_response!(result.response)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def deliver_event(event)
|
|
381
|
+
@event_listener&.call(event)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# In-process registry for suspended AgentInvocation aggregates.
|
|
6
|
+
#
|
|
7
|
+
# The registry is the single in-process source for pending Human approval.
|
|
8
|
+
# Cross-process persistence remains outside the scope of this implementation.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module AgentInvocationRegistry
|
|
12
|
+
Entry = Struct.new(:invocation, :approval_request)
|
|
13
|
+
|
|
14
|
+
@entries = {}
|
|
15
|
+
@approval_index = {}
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
|
|
18
|
+
def self.store_suspended(invocation, approval_request)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
invocation_id = invocation.id
|
|
21
|
+
request_id = approval_request.id
|
|
22
|
+
if @entries.key?(invocation_id) || @approval_index.key?(request_id)
|
|
23
|
+
raise Phronomy::Error,
|
|
24
|
+
"Suspended AgentInvocation is already registered: #{invocation_id}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@entries[invocation_id] = Entry.new(
|
|
28
|
+
invocation: invocation,
|
|
29
|
+
approval_request: approval_request
|
|
30
|
+
)
|
|
31
|
+
@approval_index[request_id] = invocation_id
|
|
32
|
+
end
|
|
33
|
+
approval_request
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Atomically removes and returns one pending approval aggregate.
|
|
37
|
+
# Duplicate approval commands therefore cannot execute the Tool twice.
|
|
38
|
+
def self.consume_approval(agent_invocation_id, approval_request_id)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
indexed_invocation_id = @approval_index[approval_request_id.to_s]
|
|
41
|
+
return nil unless indexed_invocation_id == agent_invocation_id.to_s
|
|
42
|
+
|
|
43
|
+
entry = @entries.delete(indexed_invocation_id)
|
|
44
|
+
return nil unless entry
|
|
45
|
+
|
|
46
|
+
@approval_index.delete(entry.approval_request.id)
|
|
47
|
+
entry
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.lookup(agent_invocation_id)
|
|
52
|
+
@mutex.synchronize { @entries[agent_invocation_id.to_s] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.exists?(agent_invocation_id)
|
|
56
|
+
@mutex.synchronize { @entries.key?(agent_invocation_id.to_s) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.remove_terminal(agent_invocation_id)
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
entry = @entries.delete(agent_invocation_id.to_s)
|
|
62
|
+
@approval_index.delete(entry.approval_request.id) if entry
|
|
63
|
+
entry
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.clear!
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
@entries.clear
|
|
70
|
+
@approval_index.clear
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|