phronomy 0.15.0 → 0.16.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 +107 -18
- data/README.md +300 -75
- data/benchmark/bench_agent_invoke.rb +3 -0
- data/benchmark/bench_regression.rb +2 -18
- data/benchmark/bench_tool_schema.rb +1 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +42 -10
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
- data/lib/phronomy/agent/agent_root.rb +67 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -393
- data/lib/phronomy/agent/base.rb +301 -641
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context_assembler.rb +321 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +167 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +34 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
- data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent.rb +14 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +9 -4
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/assembler.rb +8 -8
- data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +11 -0
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -47
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
|
@@ -132,6 +132,17 @@ module Phronomy
|
|
|
132
132
|
],
|
|
133
133
|
resume: [
|
|
134
134
|
{from: :suspended, to: :waiting_for_tools, guard: nil}
|
|
135
|
+
],
|
|
136
|
+
application_callback_failed: [
|
|
137
|
+
{from: :filtering_input, to: :failed, guard: nil},
|
|
138
|
+
{from: :building_context, to: :failed, guard: nil},
|
|
139
|
+
{from: :calling_llm, to: :failed, guard: nil},
|
|
140
|
+
{from: :starting_tools, to: :failed, guard: nil},
|
|
141
|
+
{from: :evaluating_tools, to: :failed, guard: nil},
|
|
142
|
+
{from: :waiting_for_tools, to: :failed, guard: nil},
|
|
143
|
+
{from: :dispatching_tools, to: :failed, guard: nil},
|
|
144
|
+
{from: :recording_tool_results, to: :failed, guard: nil},
|
|
145
|
+
{from: :output_filtering, to: :failed, guard: nil}
|
|
135
146
|
]
|
|
136
147
|
)
|
|
137
148
|
end
|
|
@@ -206,30 +217,58 @@ module Phronomy
|
|
|
206
217
|
invocation.chat,
|
|
207
218
|
invocation.config
|
|
208
219
|
)
|
|
209
|
-
install_tool_interceptors(invocation.chat)
|
|
220
|
+
install_tool_interceptors(invocation.chat, invocation)
|
|
210
221
|
invocation
|
|
211
222
|
end
|
|
212
223
|
private_class_method :building_context_action
|
|
213
224
|
|
|
214
|
-
|
|
225
|
+
# RubyLLM >= 1.15 adds the complete assistant Message to chat.messages
|
|
226
|
+
# before before_tool_call is fired. Capture that Message at the control
|
|
227
|
+
# boundary instead of trying to recover it after the exception unwinds.
|
|
228
|
+
def self.install_tool_interceptors(chat, invocation = nil)
|
|
229
|
+
unless chat.respond_to?(:before_tool_call)
|
|
230
|
+
raise Phronomy::ConfigurationError,
|
|
231
|
+
"Agent-owned Tool execution requires RubyLLM >= 1.15 (before_tool_call callback)"
|
|
232
|
+
end
|
|
233
|
+
|
|
215
234
|
if chat.respond_to?(:on_tool_call_batch)
|
|
216
235
|
chat.on_tool_call_batch do |tool_calls|
|
|
217
|
-
raise
|
|
236
|
+
raise build_tool_interception(chat, tool_calls, invocation)
|
|
218
237
|
end
|
|
219
238
|
end
|
|
220
239
|
|
|
221
|
-
|
|
222
|
-
chat
|
|
223
|
-
raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
|
|
224
|
-
end
|
|
225
|
-
else
|
|
226
|
-
chat.on_tool_call do |tool_call|
|
|
227
|
-
raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
|
|
228
|
-
end
|
|
240
|
+
chat.before_tool_call do |tool_call|
|
|
241
|
+
raise build_tool_interception(chat, [tool_call], invocation)
|
|
229
242
|
end
|
|
230
243
|
end
|
|
231
244
|
private_class_method :install_tool_interceptors
|
|
232
245
|
|
|
246
|
+
def self.build_tool_interception(chat, fallback_tool_calls, invocation)
|
|
247
|
+
assistant_message = chat.messages.last
|
|
248
|
+
unless assistant_message&.respond_to?(:role) &&
|
|
249
|
+
assistant_message.role.to_sym == :assistant &&
|
|
250
|
+
assistant_message.respond_to?(:tool_calls)
|
|
251
|
+
raise Phronomy::Error,
|
|
252
|
+
"RubyLLM Tool callback fired before the complete assistant message was observable"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
message_tool_calls = assistant_message.tool_calls
|
|
256
|
+
tool_calls = if message_tool_calls.respond_to?(:values)
|
|
257
|
+
message_tool_calls.values
|
|
258
|
+
else
|
|
259
|
+
Array(message_tool_calls)
|
|
260
|
+
end
|
|
261
|
+
tool_calls = Array(fallback_tool_calls) if tool_calls.empty?
|
|
262
|
+
|
|
263
|
+
ToolCallIntercepted.new(
|
|
264
|
+
tool_calls,
|
|
265
|
+
assistant_message: assistant_message,
|
|
266
|
+
assistant_outcome: ProviderCallOutcome.capture(assistant_message),
|
|
267
|
+
llm_call_id: invocation&.current_llm_call_id
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
private_class_method :build_tool_interception
|
|
271
|
+
|
|
233
272
|
def self.calling_llm_action(agent, runtime, invocation)
|
|
234
273
|
user_message = invocation.user_message_sent ?
|
|
235
274
|
nil :
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
class AgentRoot
|
|
8
|
+
LIFECYCLE_STATUSES = %i[idle active suspended closed invalidated].freeze
|
|
9
|
+
|
|
10
|
+
ATTRIBUTES = %i[
|
|
11
|
+
agent_id agent_definition_id definition_version agent_revision
|
|
12
|
+
context_revision journal_position lifecycle_status transcript_generation memory_generation
|
|
13
|
+
created_at updated_at metadata
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader(*ATTRIBUTES)
|
|
17
|
+
|
|
18
|
+
def self.create(agent_id:, agent_definition_id:, definition_version:, metadata: {})
|
|
19
|
+
now = Time.now.utc.iso8601(6)
|
|
20
|
+
new(
|
|
21
|
+
agent_id: agent_id,
|
|
22
|
+
agent_definition_id: agent_definition_id,
|
|
23
|
+
definition_version: definition_version,
|
|
24
|
+
agent_revision: 0,
|
|
25
|
+
context_revision: 0,
|
|
26
|
+
journal_position: 0,
|
|
27
|
+
lifecycle_status: :idle,
|
|
28
|
+
transcript_generation: 0,
|
|
29
|
+
memory_generation: 0,
|
|
30
|
+
created_at: now,
|
|
31
|
+
updated_at: now,
|
|
32
|
+
metadata: metadata
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(**attributes)
|
|
37
|
+
ATTRIBUTES.each do |name|
|
|
38
|
+
value = attributes.fetch(name)
|
|
39
|
+
value = value.to_sym if name == :lifecycle_status
|
|
40
|
+
instance_variable_set("@#{name}", Immutable.copy(value))
|
|
41
|
+
end
|
|
42
|
+
unless LIFECYCLE_STATUSES.include?(lifecycle_status)
|
|
43
|
+
raise ArgumentError, "unknown Agent lifecycle status: #{lifecycle_status.inspect}"
|
|
44
|
+
end
|
|
45
|
+
raise ArgumentError, "agent_revision must be non-negative" if agent_revision.negative?
|
|
46
|
+
raise ArgumentError, "context_revision must be non-negative" if context_revision.negative?
|
|
47
|
+
raise ArgumentError, "journal_position must be non-negative" if journal_position.negative?
|
|
48
|
+
Immutable.validate_canonical_json!(metadata, label: "Agent metadata")
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def with(**changes)
|
|
53
|
+
values = to_h.transform_keys(&:to_sym).merge(changes)
|
|
54
|
+
values[:updated_at] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
|
|
55
|
+
self.class.new(**values)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_h
|
|
59
|
+
ATTRIBUTES.to_h { |name| [name.to_s, public_send(name)] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.from_h(hash)
|
|
63
|
+
new(**ATTRIBUTES.to_h { |name| [name, hash.fetch(name.to_s)] })
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -2,39 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Agent
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# invoke_async and stream_async share lifecycle/tool events. stream_async
|
|
8
|
-
# additionally emits :token events. The returned Task remains a normal Task
|
|
9
|
-
# and is settled after the terminal event listener returns.
|
|
10
|
-
#
|
|
11
|
-
# @api private
|
|
5
|
+
# Public Agent execution API. Agent instances are always stateful and all
|
|
6
|
+
# operations are coordinated through Phronomy::Persistence.
|
|
12
7
|
module AsyncEventApi
|
|
13
|
-
def invoke(
|
|
14
|
-
input,
|
|
15
|
-
messages: [],
|
|
16
|
-
thread_id: nil,
|
|
17
|
-
config: {},
|
|
18
|
-
invocation_context: nil,
|
|
19
|
-
on_event: nil
|
|
20
|
-
)
|
|
8
|
+
def invoke(input, thread_id: nil, config: {}, invocation_context: nil, on_event: nil)
|
|
21
9
|
if invocation_context
|
|
22
|
-
thread_id, config = _apply_invocation_context(
|
|
23
|
-
thread_id,
|
|
24
|
-
config,
|
|
25
|
-
invocation_context
|
|
26
|
-
)
|
|
10
|
+
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
27
11
|
end
|
|
28
12
|
_check_scheduler_reentrancy(:invoke, :invoke_async)
|
|
29
|
-
|
|
30
|
-
trace(
|
|
31
|
-
"agent.invoke",
|
|
32
|
-
input: input,
|
|
33
|
-
**_build_caller_meta(config)
|
|
34
|
-
) do |_span|
|
|
13
|
+
trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
|
|
35
14
|
result = invoke_async(
|
|
36
15
|
input,
|
|
37
|
-
messages: messages,
|
|
38
16
|
thread_id: thread_id,
|
|
39
17
|
config: config,
|
|
40
18
|
on_event: on_event
|
|
@@ -45,7 +23,6 @@ module Phronomy
|
|
|
45
23
|
|
|
46
24
|
def invoke_async(
|
|
47
25
|
input,
|
|
48
|
-
messages: [],
|
|
49
26
|
thread_id: nil,
|
|
50
27
|
config: {},
|
|
51
28
|
invocation_context: nil,
|
|
@@ -53,81 +30,22 @@ module Phronomy
|
|
|
53
30
|
on_event: nil
|
|
54
31
|
)
|
|
55
32
|
if invocation_context
|
|
56
|
-
thread_id, config = _apply_invocation_context(
|
|
57
|
-
thread_id,
|
|
58
|
-
config,
|
|
59
|
-
invocation_context
|
|
60
|
-
)
|
|
33
|
+
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
61
34
|
end
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
name:
|
|
65
|
-
"agent-#{(self.class.name || "anonymous").downcase}-async"
|
|
66
|
-
)
|
|
67
|
-
approval_snapshot = _approval_configuration_snapshot(
|
|
68
|
-
on_tool_approval_required
|
|
69
|
-
)
|
|
70
|
-
_start_invocation(
|
|
71
|
-
result_task,
|
|
35
|
+
approval = _approval_configuration_snapshot(on_tool_approval_required)
|
|
36
|
+
execution_coordinator.start(
|
|
72
37
|
input,
|
|
73
|
-
messages: messages,
|
|
74
38
|
thread_id: thread_id,
|
|
75
39
|
config: config,
|
|
76
|
-
approval_snapshot: approval_snapshot,
|
|
77
40
|
mode: :invoke,
|
|
41
|
+
approval_policy: approval[:policy],
|
|
42
|
+
approval_listener: approval[:listener],
|
|
78
43
|
on_event: on_event
|
|
79
44
|
)
|
|
80
|
-
result_task
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def stream_async(
|
|
84
|
-
input,
|
|
85
|
-
messages: [],
|
|
86
|
-
thread_id: nil,
|
|
87
|
-
config: {},
|
|
88
|
-
invocation_context: nil,
|
|
89
|
-
on_tool_approval_required: nil,
|
|
90
|
-
on_event: nil,
|
|
91
|
-
&block
|
|
92
|
-
)
|
|
93
|
-
listener = resolve_event_listener(on_event, block)
|
|
94
|
-
unless listener
|
|
95
|
-
raise ArgumentError,
|
|
96
|
-
"stream_async requires on_event: or a block"
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
if invocation_context
|
|
100
|
-
thread_id, config = _apply_invocation_context(
|
|
101
|
-
thread_id,
|
|
102
|
-
config,
|
|
103
|
-
invocation_context
|
|
104
|
-
)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
result_task = Phronomy::Task.deferred(
|
|
108
|
-
name:
|
|
109
|
-
"agent-#{(self.class.name || "anonymous").downcase}" \
|
|
110
|
-
"-stream-async"
|
|
111
|
-
)
|
|
112
|
-
approval_snapshot = _approval_configuration_snapshot(
|
|
113
|
-
on_tool_approval_required
|
|
114
|
-
)
|
|
115
|
-
_start_invocation(
|
|
116
|
-
result_task,
|
|
117
|
-
input,
|
|
118
|
-
messages: messages,
|
|
119
|
-
thread_id: thread_id,
|
|
120
|
-
config: config,
|
|
121
|
-
approval_snapshot: approval_snapshot,
|
|
122
|
-
mode: :stream,
|
|
123
|
-
on_event: listener
|
|
124
|
-
)
|
|
125
|
-
result_task
|
|
126
45
|
end
|
|
127
46
|
|
|
128
47
|
def stream(
|
|
129
48
|
input,
|
|
130
|
-
messages: [],
|
|
131
49
|
thread_id: nil,
|
|
132
50
|
config: {},
|
|
133
51
|
invocation_context: nil,
|
|
@@ -136,335 +54,79 @@ module Phronomy
|
|
|
136
54
|
&block
|
|
137
55
|
)
|
|
138
56
|
listener = resolve_event_listener(on_event, block)
|
|
139
|
-
unless listener
|
|
140
|
-
raise ArgumentError,
|
|
141
|
-
"stream requires on_event: or a block"
|
|
142
|
-
end
|
|
143
|
-
|
|
57
|
+
raise ArgumentError, "stream requires on_event: or a block" unless listener
|
|
144
58
|
if invocation_context
|
|
145
|
-
thread_id, config = _apply_invocation_context(
|
|
146
|
-
thread_id,
|
|
147
|
-
config,
|
|
148
|
-
invocation_context
|
|
149
|
-
)
|
|
59
|
+
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
150
60
|
end
|
|
151
61
|
_check_scheduler_reentrancy(:stream, :stream_async)
|
|
152
|
-
|
|
153
|
-
trace(
|
|
154
|
-
"agent.stream",
|
|
155
|
-
input: input,
|
|
156
|
-
**_build_caller_meta(config)
|
|
157
|
-
) do |_span|
|
|
62
|
+
trace("agent.stream", input: input, **_build_caller_meta(config)) do |_span|
|
|
158
63
|
result = stream_async(
|
|
159
64
|
input,
|
|
160
|
-
messages: messages,
|
|
161
65
|
thread_id: thread_id,
|
|
162
66
|
config: config,
|
|
163
|
-
on_tool_approval_required:
|
|
164
|
-
on_tool_approval_required,
|
|
67
|
+
on_tool_approval_required: on_tool_approval_required,
|
|
165
68
|
on_event: listener
|
|
166
69
|
).wait_result
|
|
167
70
|
[result, result[:usage]]
|
|
168
71
|
end
|
|
169
72
|
end
|
|
170
73
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def resolve_event_listener(keyword_listener, block_listener)
|
|
174
|
-
if keyword_listener && block_listener
|
|
175
|
-
raise ArgumentError,
|
|
176
|
-
"Provide either on_event: or a block, not both"
|
|
177
|
-
end
|
|
178
|
-
keyword_listener || block_listener
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
# Installs the Application listener before EventLoop admission.
|
|
182
|
-
# Cancellation is checked by the first Agent entry action on the EventLoop
|
|
183
|
-
# thread so even pre-cancelled invocations produce a terminal event before
|
|
184
|
-
# the returned Task settles.
|
|
185
|
-
def _start_invocation(
|
|
186
|
-
result_task,
|
|
74
|
+
def stream_async(
|
|
187
75
|
input,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
)
|
|
195
|
-
effective_config =
|
|
196
|
-
thread_id ? config.merge(thread_id: thread_id) : config
|
|
197
|
-
runtime = Phronomy::Runtime.instance
|
|
198
|
-
event_loop = runtime.event_loop
|
|
199
|
-
session = Agent::AgentInvocationSessionBuilder.build(
|
|
200
|
-
agent: self,
|
|
201
|
-
input: input,
|
|
202
|
-
messages: messages,
|
|
203
|
-
config: effective_config,
|
|
204
|
-
approval_policy: approval_snapshot[:policy],
|
|
205
|
-
approval_listener: approval_snapshot[:listener],
|
|
206
|
-
mode: mode,
|
|
207
|
-
on_event: on_event,
|
|
208
|
-
runtime: runtime
|
|
209
|
-
)
|
|
210
|
-
callback_error_policy =
|
|
211
|
-
Phronomy.configuration.stream_callback_error_policy
|
|
212
|
-
source_task = Phronomy::Task.deferred(
|
|
213
|
-
name: "#{result_task.name}-source"
|
|
214
|
-
)
|
|
215
|
-
source_task.on_complete do |invocation, error|
|
|
216
|
-
completed_invocation = invocation || session.context
|
|
217
|
-
_handle_agent_completion(
|
|
218
|
-
result_task: result_task,
|
|
219
|
-
invocation: completed_invocation,
|
|
220
|
-
error: error,
|
|
221
|
-
mode: mode,
|
|
222
|
-
listener: on_event,
|
|
223
|
-
event_loop: event_loop,
|
|
224
|
-
callback_error_policy: callback_error_policy
|
|
225
|
-
)
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
event_loop.register(session, completion: source_task)
|
|
229
|
-
rescue => error
|
|
230
|
-
_fail_result_task(result_task, error)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def _handle_agent_completion(
|
|
234
|
-
result_task:,
|
|
235
|
-
invocation:,
|
|
236
|
-
error:,
|
|
237
|
-
mode:,
|
|
238
|
-
listener:,
|
|
239
|
-
event_loop:,
|
|
240
|
-
callback_error_policy:
|
|
76
|
+
thread_id: nil,
|
|
77
|
+
config: {},
|
|
78
|
+
invocation_context: nil,
|
|
79
|
+
on_tool_approval_required: nil,
|
|
80
|
+
on_event: nil,
|
|
81
|
+
&block
|
|
241
82
|
)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
)
|
|
246
|
-
_fail_result_task(
|
|
247
|
-
result_task,
|
|
248
|
-
_translated_error(completion_error)
|
|
249
|
-
)
|
|
250
|
-
return
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
result = nil
|
|
254
|
-
execution_error = nil
|
|
255
|
-
begin
|
|
256
|
-
raise error if error
|
|
257
|
-
|
|
258
|
-
result = _extract_invoke_result(invocation)
|
|
259
|
-
rescue => caught
|
|
260
|
-
execution_error = _translated_error(caught)
|
|
261
|
-
end
|
|
262
|
-
if execution_error
|
|
263
|
-
execution_error = normalize_terminal_error(
|
|
264
|
-
execution_error,
|
|
265
|
-
invocation
|
|
266
|
-
)
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
if execution_error
|
|
270
|
-
terminal_event = StreamEvent.new(
|
|
271
|
-
type: terminal_error_event_type(execution_error),
|
|
272
|
-
payload: {error: execution_error}
|
|
273
|
-
)
|
|
274
|
-
callback_error = _deliver_stream_event(
|
|
275
|
-
listener,
|
|
276
|
-
terminal_event
|
|
277
|
-
)
|
|
278
|
-
if callback_error
|
|
279
|
-
report_agent_event_callback_error(
|
|
280
|
-
callback_error,
|
|
281
|
-
event: terminal_event,
|
|
282
|
-
invocation_id: invocation&.id,
|
|
283
|
-
callback_error_policy: callback_error_policy
|
|
284
|
-
)
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
# Listener failure never replaces an Agent execution failure.
|
|
288
|
-
_fail_result_task(result_task, execution_error)
|
|
289
|
-
return
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
terminal_event = _build_stream_terminal_event(result)
|
|
293
|
-
callback_error = _deliver_stream_event(
|
|
294
|
-
listener,
|
|
295
|
-
terminal_event
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
unless callback_error
|
|
299
|
-
_complete_result_task(result_task, result)
|
|
300
|
-
return
|
|
83
|
+
listener = resolve_event_listener(on_event, block)
|
|
84
|
+
raise ArgumentError, "stream_async requires on_event: or a block" unless listener
|
|
85
|
+
if invocation_context
|
|
86
|
+
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
301
87
|
end
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
88
|
+
approval = _approval_configuration_snapshot(on_tool_approval_required)
|
|
89
|
+
execution_coordinator.start(
|
|
90
|
+
input,
|
|
91
|
+
thread_id: thread_id,
|
|
92
|
+
config: config,
|
|
93
|
+
mode: :stream,
|
|
94
|
+
approval_policy: approval[:policy],
|
|
95
|
+
approval_listener: approval[:listener],
|
|
96
|
+
on_event: listener
|
|
308
97
|
)
|
|
309
|
-
|
|
310
|
-
if callback_error_policy == :fail_task
|
|
311
|
-
wrapped = _build_stream_callback_error(
|
|
312
|
-
event_type: terminal_event.type,
|
|
313
|
-
callback_error: callback_error,
|
|
314
|
-
result: result
|
|
315
|
-
)
|
|
316
|
-
_fail_result_task(result_task, wrapped)
|
|
317
|
-
else
|
|
318
|
-
_complete_result_task(result_task, result)
|
|
319
|
-
end
|
|
320
98
|
end
|
|
321
99
|
|
|
322
|
-
def
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
end
|
|
100
|
+
def approve(execution_id, approval_request_id:, approved: true, config: {})
|
|
101
|
+
_check_scheduler_reentrancy(:approve, :approve_async)
|
|
102
|
+
approve_async(
|
|
103
|
+
execution_id,
|
|
104
|
+
approval_request_id: approval_request_id,
|
|
105
|
+
approved: approved,
|
|
106
|
+
config: config
|
|
107
|
+
).wait_result
|
|
331
108
|
end
|
|
332
109
|
|
|
333
|
-
def
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
timeout_error
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
def invocation_timeout_expired?(invocation)
|
|
343
|
-
config = invocation&.config || {}
|
|
344
|
-
invocation_context = config[:invocation_context]
|
|
345
|
-
deadline = invocation_context&.deadline
|
|
346
|
-
return true if deadline&.expired?
|
|
347
|
-
|
|
348
|
-
token = config[:cancellation_token]
|
|
349
|
-
return false unless token
|
|
350
|
-
|
|
351
|
-
remaining =
|
|
352
|
-
if token.respond_to?(:remaining_monotonic_seconds)
|
|
353
|
-
token.remaining_monotonic_seconds
|
|
354
|
-
end
|
|
355
|
-
return true if remaining == 0.0
|
|
356
|
-
|
|
357
|
-
wall_deadline = token.deadline if token.respond_to?(:deadline)
|
|
358
|
-
wall_deadline && Time.now >= wall_deadline
|
|
359
|
-
end
|
|
360
|
-
|
|
361
|
-
def report_agent_event_callback_error(
|
|
362
|
-
callback_error,
|
|
363
|
-
event:,
|
|
364
|
-
invocation_id:,
|
|
365
|
-
callback_error_policy:
|
|
366
|
-
)
|
|
367
|
-
_report_stream_callback_error(
|
|
368
|
-
callback_error,
|
|
369
|
-
event: event,
|
|
370
|
-
invocation_id: invocation_id,
|
|
371
|
-
callback_error_policy: callback_error_policy
|
|
110
|
+
def approve_async(execution_id, approval_request_id:, approved: true, config: {})
|
|
111
|
+
execution_coordinator.resume(
|
|
112
|
+
execution_id,
|
|
113
|
+
approval_request_id: approval_request_id,
|
|
114
|
+
approved: approved,
|
|
115
|
+
config: config
|
|
372
116
|
)
|
|
373
117
|
end
|
|
374
118
|
|
|
375
|
-
|
|
376
|
-
event_loop,
|
|
377
|
-
runtime,
|
|
378
|
-
child,
|
|
379
|
-
session
|
|
380
|
-
)
|
|
381
|
-
completion = Phronomy::Task.deferred(
|
|
382
|
-
name: "tool-session:#{child.id}"
|
|
383
|
-
)
|
|
384
|
-
completion.on_complete do |_result, error|
|
|
385
|
-
next unless error
|
|
119
|
+
private
|
|
386
120
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
Phronomy::Event.new(
|
|
390
|
-
type: :tool_failed,
|
|
391
|
-
target_id: child.parent_agent_invocation_id,
|
|
392
|
-
payload: {tool_invocation_id: child.id}
|
|
393
|
-
)
|
|
394
|
-
)
|
|
395
|
-
end
|
|
396
|
-
event_loop.register(session, completion: completion)
|
|
121
|
+
def execution_coordinator
|
|
122
|
+
@execution_coordinator ||= Agent::ExecutionCoordinator.new(self)
|
|
397
123
|
end
|
|
398
124
|
|
|
399
|
-
def
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
approved:,
|
|
403
|
-
config:
|
|
404
|
-
)
|
|
405
|
-
invocation.merge_config!(config)
|
|
406
|
-
invocation.begin_approval_resume!(approved: approved)
|
|
407
|
-
runtime = Phronomy::Runtime.instance
|
|
408
|
-
event_loop = runtime.event_loop
|
|
409
|
-
source_task = Phronomy::Task.deferred(
|
|
410
|
-
name: "#{result_task.name}-source"
|
|
411
|
-
)
|
|
412
|
-
parent_session =
|
|
413
|
-
Agent::AgentInvocationSessionBuilder.build_for_resume(
|
|
414
|
-
agent_invocation: invocation,
|
|
415
|
-
resume_event: :resume,
|
|
416
|
-
resume_phase: :suspended,
|
|
417
|
-
runtime: runtime
|
|
418
|
-
)
|
|
419
|
-
listener = invocation.event_listener
|
|
420
|
-
mode = invocation.mode
|
|
421
|
-
callback_error_policy =
|
|
422
|
-
Phronomy.configuration.stream_callback_error_policy
|
|
423
|
-
|
|
424
|
-
source_task.on_complete do |completed_invocation, error|
|
|
425
|
-
invocation_result = completed_invocation || parent_session.context
|
|
426
|
-
_handle_agent_completion(
|
|
427
|
-
result_task: result_task,
|
|
428
|
-
invocation: invocation_result,
|
|
429
|
-
error: error,
|
|
430
|
-
mode: mode,
|
|
431
|
-
listener: listener,
|
|
432
|
-
event_loop: event_loop,
|
|
433
|
-
callback_error_policy: callback_error_policy
|
|
434
|
-
)
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
event_loop.register(
|
|
438
|
-
parent_session,
|
|
439
|
-
completion: source_task
|
|
440
|
-
)
|
|
441
|
-
|
|
442
|
-
invocation.tool_invocations.each do |child|
|
|
443
|
-
child_session =
|
|
444
|
-
if child.awaiting_approval?
|
|
445
|
-
Agent::ToolInvocationSessionBuilder.build_for_resume(
|
|
446
|
-
tool_invocation: child,
|
|
447
|
-
resume_event: approved ? :approve : :reject,
|
|
448
|
-
resume_phase: :awaiting_approval,
|
|
449
|
-
runtime: runtime
|
|
450
|
-
)
|
|
451
|
-
elsif !approved && child.authorized?
|
|
452
|
-
Agent::ToolInvocationSessionBuilder.build_for_resume(
|
|
453
|
-
tool_invocation: child,
|
|
454
|
-
resume_event: :cancel,
|
|
455
|
-
resume_phase: :authorized,
|
|
456
|
-
runtime: runtime
|
|
457
|
-
)
|
|
458
|
-
end
|
|
459
|
-
if child_session
|
|
460
|
-
_register_tool_invocation_session(
|
|
461
|
-
event_loop,
|
|
462
|
-
runtime,
|
|
463
|
-
child,
|
|
464
|
-
child_session
|
|
465
|
-
)
|
|
466
|
-
end
|
|
125
|
+
def resolve_event_listener(keyword_listener, block_listener)
|
|
126
|
+
if keyword_listener && block_listener
|
|
127
|
+
raise ArgumentError, "Provide either on_event: or a block, not both"
|
|
467
128
|
end
|
|
129
|
+
keyword_listener || block_listener
|
|
468
130
|
end
|
|
469
131
|
end
|
|
470
132
|
end
|