ask-agent 0.40.19 → 0.40.21
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 +64 -0
- data/README.md +29 -0
- data/lib/ask/agent/chat.rb +0 -1
- data/lib/ask/agent/loop.rb +1 -1
- data/lib/ask/agent/session.rb +8 -5
- data/lib/ask/agent/session_adapter.rb +250 -0
- data/lib/ask/agent/tool_executor.rb +509 -60
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +16 -0
- data/lib/ask-agent.rb +1 -0
- metadata +30 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8119f78526cac8f96b7f6506c1d40e046127dccad46015252e9ea0805e15c677
|
|
4
|
+
data.tar.gz: be381f8d8fbb4b104ba79ab0a1cece93918c8ef028bfdb4fc24dd6ae49399726
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aee841408cd187f95b2d54f6bbb0820fac09865bf58f1af38680b1847088a8aca7c1a187008e93a98a6cf39941fd65e0690dd78a16b6d07491c6d0ec0573bcf2
|
|
7
|
+
data.tar.gz: c6b6c736800ae14baa95355f692294da8f428af180c404852a5aa9141905d5bfb776994f59aab21e772f3263e40c3c542b32009cee779f1263ade62c8d095cb5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## [0.40.21] — 2026-09-22
|
|
5
|
+
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- **`Ask::Agent::SessionAdapter` — bridges an agent session to
|
|
9
|
+
`Ask::Session::Host`.** Records user input and per-run snapshots as
|
|
10
|
+
event-sourced ask-session events, maps agent events (turn, streaming,
|
|
11
|
+
tool, todo, plan, error) onto session event types with `trace_id` /
|
|
12
|
+
`causation_id` propagation, and resumes from the latest snapshot
|
|
13
|
+
(`SessionAdapter.resume`). Resume survives process restarts when the host
|
|
14
|
+
store is durable (e.g. `Ask::Session::ProviderStore` over
|
|
15
|
+
`ask-state-providers`). Requires the new runtime dependency
|
|
16
|
+
`ask-session >= 0.1.0` (the single session store; no second store added).
|
|
17
|
+
Resume validates the snapshot before restoring, so a malformed snapshot
|
|
18
|
+
raises `SessionAdapter::Error` instead of leaving the agent half-restored.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`Session#turn_count` is now maintained during runs.** The counter was
|
|
23
|
+
only ever reset at run start (or restored from a checkpoint), never
|
|
24
|
+
incremented — so `SessionEnd`, audit-log entries, persisted
|
|
25
|
+
`metadata.turn_count`, and `SessionAdapter` snapshots all recorded `0` for
|
|
26
|
+
live runs. Turns are now counted on `TurnStart`, matching the loop's own
|
|
27
|
+
count (`reset: true` scopes it to the run; `reset: false` accumulates).
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
## [0.40.20] — 2026-09-21
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **Agent tool execution unified with ask-runtime.** `ToolExecutor` runs
|
|
35
|
+
tool calls through the runtime's execution contract, exposing the current
|
|
36
|
+
runtime call and execution context (`Ask.current_runtime_call` /
|
|
37
|
+
`Ask.current_runtime_context`) while a tool executes, and conforming to
|
|
38
|
+
the runtime executor contract.
|
|
39
|
+
- Requires `ask-runtime >= 0.1.0`.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
## [0.40.19] — 2026-09-20
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **Bundled `agent.build_agents` skill** — teaches building agents with
|
|
47
|
+
ask-rb (definitions, sessions, tools, configuration, common patterns).
|
|
48
|
+
Auto-discovered via `Gem.find_files` when ask-skills is present.
|
|
49
|
+
- **`askr skills install` / `askr skills uninstall`** with
|
|
50
|
+
`--global|--local|--dir`, plus auto-sync of managed skill copies on
|
|
51
|
+
every CLI invocation.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
## [0.40.18] — 2026-09-19
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- **`Agent.new` and `Session.new` unified.** The shared implementation
|
|
59
|
+
lives in `Session.build_from_definition`; `Agent.new` delegates to it,
|
|
60
|
+
`Session.new` supports definition lookup, and `Ask.chat` accepts `name:`
|
|
61
|
+
for definition lookup. The duplicate `build_session_from_definition` and
|
|
62
|
+
`resolve_definition_tools` helpers were removed from the `Agent` module.
|
|
63
|
+
|
|
64
|
+
|
|
1
65
|
## [0.40.17] — 2026-09-18
|
|
2
66
|
|
|
3
67
|
### Added
|
data/README.md
CHANGED
|
@@ -102,6 +102,35 @@ under multiple OpenAI-compatible providers). A `provider:` passed to
|
|
|
102
102
|
`Session.new` or declared in an agent `Definition` always wins over the global
|
|
103
103
|
default.
|
|
104
104
|
|
|
105
|
+
## Durable sessions (ask-session)
|
|
106
|
+
|
|
107
|
+
`Ask::Agent::SessionAdapter` bridges a session to an event-sourced
|
|
108
|
+
`Ask::Session::Host`. Every run records the user input, the mapped agent
|
|
109
|
+
events, and an `agent.snapshot`; `SessionAdapter.resume` restores the latest
|
|
110
|
+
snapshot — including after a process restart, as long as the host store is
|
|
111
|
+
durable (for example `Ask::Session::ProviderStore` over
|
|
112
|
+
`ask-state-providers`' SQLite adapter).
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
require "ask-agent"
|
|
116
|
+
require "ask/session"
|
|
117
|
+
|
|
118
|
+
host = Ask::Session::Host.new(store: Ask::Session::Store.new)
|
|
119
|
+
|
|
120
|
+
session = Ask::Agent::Session.new(model: "gpt-4o", id: "chat-1")
|
|
121
|
+
adapter = Ask::Agent::SessionAdapter.create(agent: session, host: host)
|
|
122
|
+
adapter.run("Hello")
|
|
123
|
+
|
|
124
|
+
# Later (even in a new process, with the same durable store):
|
|
125
|
+
resumed = Ask::Agent::Session.new(model: "gpt-4o", id: "chat-1")
|
|
126
|
+
adapter = Ask::Agent::SessionAdapter.resume(agent: resumed, host: host, session_id: "chat-1")
|
|
127
|
+
adapter.run("Continue where we left off")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`SessionAdapter::Error` is raised when the session record or its latest
|
|
131
|
+
snapshot cannot be found. This requires the `ask-session` gem (a runtime
|
|
132
|
+
dependency of ask-agent).
|
|
133
|
+
|
|
105
134
|
## Full documentation
|
|
106
135
|
|
|
107
136
|
The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs.
|
data/lib/ask/agent/chat.rb
CHANGED
|
@@ -64,7 +64,6 @@ module Ask
|
|
|
64
64
|
@messages << Ask::Message.new(role: :user, content: merge_attachments(message, attachments)) if has_message || attachments
|
|
65
65
|
|
|
66
66
|
stream = block_given?
|
|
67
|
-
tool_defs = @tools.map { |t| Ask::ToolDef.from_tool(t) }
|
|
68
67
|
|
|
69
68
|
calls_acc = {}
|
|
70
69
|
response_msg = chat_with_retry(stream, calls_acc, &block)
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -103,7 +103,7 @@ module Ask
|
|
|
103
103
|
# when the background work completes.
|
|
104
104
|
user_results = tool_executor.execute(
|
|
105
105
|
user_tool_calls, tools, hooks: hooks, event_emitter: event_emitter,
|
|
106
|
-
session_id: session_id,
|
|
106
|
+
session_id: session_id, turn: @turn_count,
|
|
107
107
|
result_callback: lambda do |tool_call_id, result|
|
|
108
108
|
tc = user_tool_calls[tool_call_id]
|
|
109
109
|
next unless tc
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -107,7 +107,10 @@ module Ask
|
|
|
107
107
|
@turn_id = 0
|
|
108
108
|
@queued_steers = []
|
|
109
109
|
@steer_mutex = Mutex.new
|
|
110
|
-
on(Events::TurnStart)
|
|
110
|
+
on(Events::TurnStart) do
|
|
111
|
+
@turn_id += 1
|
|
112
|
+
@turn_count += 1
|
|
113
|
+
end
|
|
111
114
|
@created_at = Time.now
|
|
112
115
|
@_no_tools_instructed = false
|
|
113
116
|
|
|
@@ -878,10 +881,10 @@ end
|
|
|
878
881
|
private
|
|
879
882
|
|
|
880
883
|
def build_audit_log(config)
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
884
|
+
config ||= Ask::Agent.configuration.audit_log
|
|
885
|
+
return nil unless config
|
|
886
|
+
Ask::Agent::Policies::AuditLog.new(self, adapter: config)
|
|
887
|
+
end
|
|
885
888
|
|
|
886
889
|
# Build the approval queue + policy when approval is enabled.
|
|
887
890
|
#
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "ask/session"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Agent
|
|
8
|
+
# Adapter that bridges an Ask::Agent::Session to an Ask::Session::Host.
|
|
9
|
+
#
|
|
10
|
+
# Maps agent events to event-sourced ask-session events, persists a
|
|
11
|
+
# snapshot at the end of each run, and supports resume from the latest
|
|
12
|
+
# durable snapshot.
|
|
13
|
+
#
|
|
14
|
+
# adapter = SessionAdapter.create(agent: session, host: host)
|
|
15
|
+
# result = adapter.run("Hello")
|
|
16
|
+
#
|
|
17
|
+
# restored = SessionAdapter.resume(agent: session, host: host, session_id: id)
|
|
18
|
+
# restored.run("Follow up")
|
|
19
|
+
#
|
|
20
|
+
class SessionAdapter
|
|
21
|
+
SNAPSHOT_TYPE = "agent.snapshot"
|
|
22
|
+
|
|
23
|
+
# Raised when resume cannot find the ask-session record or its
|
|
24
|
+
# latest snapshot.
|
|
25
|
+
class Error < Ask::Agent::Error; end
|
|
26
|
+
|
|
27
|
+
attr_reader :agent, :host, :session_id
|
|
28
|
+
|
|
29
|
+
def initialize(agent:, host:, session_id: agent.id, metadata: {}, create: false)
|
|
30
|
+
@agent = agent
|
|
31
|
+
@host = host
|
|
32
|
+
@session_id = session_id
|
|
33
|
+
@current_trace_id = nil
|
|
34
|
+
@current_turn_id = nil
|
|
35
|
+
@current_causation_id = nil
|
|
36
|
+
|
|
37
|
+
@host.create(id: @session_id, metadata: metadata) if create
|
|
38
|
+
|
|
39
|
+
attach_event_handler
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Create a new ask-session record and return the adapter.
|
|
43
|
+
#
|
|
44
|
+
# @param agent [Ask::Agent::Session] the agent session
|
|
45
|
+
# @param host [Ask::Session::Host] the session host
|
|
46
|
+
# @param metadata [Hash] session metadata
|
|
47
|
+
# @return [SessionAdapter]
|
|
48
|
+
def self.create(agent:, host:, metadata: {})
|
|
49
|
+
record = host.create(id: agent.id, metadata: metadata)
|
|
50
|
+
new(agent: agent, host: host, session_id: record.id, metadata: metadata, create: false)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Attach to an existing ask-session and restore the latest snapshot.
|
|
54
|
+
#
|
|
55
|
+
# Contract: Ask::Session::Host#session raises
|
|
56
|
+
# Ask::Session::NotFoundError for a missing record (it never returns
|
|
57
|
+
# nil); both missing-session and missing-snapshot failures surface as
|
|
58
|
+
# SessionAdapter::Error.
|
|
59
|
+
#
|
|
60
|
+
# @param agent [Ask::Agent::Session] the agent session (will be populated from snapshot)
|
|
61
|
+
# @param host [Ask::Session::Host] the session host
|
|
62
|
+
# @param session_id [String] the ask-session record id
|
|
63
|
+
# @return [SessionAdapter]
|
|
64
|
+
# @raise [SessionAdapter::Error] if the session or snapshot is not found
|
|
65
|
+
def self.resume(agent:, host:, session_id:)
|
|
66
|
+
begin
|
|
67
|
+
host.session(session_id)
|
|
68
|
+
rescue Ask::Session::NotFoundError
|
|
69
|
+
raise Error, "Session not found: #{session_id.inspect}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
events = host.events(session_id)
|
|
73
|
+
snapshot_event = events.reverse_each.find { |e| e.type == SNAPSHOT_TYPE }
|
|
74
|
+
raise Error, "No snapshot found for session #{session_id.inspect}" unless snapshot_event
|
|
75
|
+
|
|
76
|
+
payload = snapshot_event.payload
|
|
77
|
+
messages = payload[:messages] || payload["messages"] || []
|
|
78
|
+
|
|
79
|
+
# Validate before mutating the agent so a malformed snapshot cannot
|
|
80
|
+
# leave it half-restored.
|
|
81
|
+
messages.each do |msg|
|
|
82
|
+
role = msg.is_a?(Hash) ? (msg[:role] || msg["role"]) : nil
|
|
83
|
+
raise Error, "Snapshot for session #{session_id.inspect} contains a message without a role" unless role
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
agent.instance_variable_set(:@turn_count, payload[:turn_count] || payload["turn_count"] || 0)
|
|
87
|
+
agent.chat.reset_messages!
|
|
88
|
+
messages.each do |msg|
|
|
89
|
+
agent.chat.add_message(
|
|
90
|
+
role: (msg[:role] || msg["role"]).to_sym,
|
|
91
|
+
content: deserialize_content(msg[:content] || msg["content"]),
|
|
92
|
+
tool_call_id: msg[:tool_call_id] || msg["tool_call_id"],
|
|
93
|
+
tool_calls: msg[:tool_calls] || msg["tool_calls"]
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
agent.instance_variable_set(:@messages, agent.chat.messages.dup)
|
|
97
|
+
|
|
98
|
+
new(agent: agent, host: host, session_id: session_id, create: false)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Current trace id for the running trace.
|
|
102
|
+
# @return [String, nil]
|
|
103
|
+
def current_trace_id
|
|
104
|
+
@current_trace_id
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Current turn identifier (stable per TurnStart).
|
|
108
|
+
# @return [Integer, nil]
|
|
109
|
+
def current_turn_id
|
|
110
|
+
@current_turn_id
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Run the agent and record events in the ask-session.
|
|
114
|
+
#
|
|
115
|
+
# @param message [String] the user message
|
|
116
|
+
# @param trace_id [String, nil] optional trace id (auto-generated if nil)
|
|
117
|
+
# @param causation_id [String, nil] optional causation id linking to the
|
|
118
|
+
# event that caused this run
|
|
119
|
+
# @param options [Hash] forwarded to agent.run
|
|
120
|
+
# @return [String] the agent response
|
|
121
|
+
def run(message, trace_id: nil, causation_id: nil, **options)
|
|
122
|
+
@current_trace_id = trace_id || "trace_#{SecureRandom.hex(8)}"
|
|
123
|
+
@current_causation_id = causation_id
|
|
124
|
+
|
|
125
|
+
# Record user input as a message event.
|
|
126
|
+
input_event = @host.send_message(
|
|
127
|
+
@session_id,
|
|
128
|
+
content: message.to_s,
|
|
129
|
+
trace_id: @current_trace_id,
|
|
130
|
+
causation_id: @current_causation_id
|
|
131
|
+
)
|
|
132
|
+
@current_causation_id ||= input_event.trace_id
|
|
133
|
+
|
|
134
|
+
begin
|
|
135
|
+
result = @agent.run(message, **options)
|
|
136
|
+
|
|
137
|
+
# Append snapshot for resume.
|
|
138
|
+
snapshot = build_snapshot
|
|
139
|
+
@host.append(
|
|
140
|
+
@session_id,
|
|
141
|
+
type: SNAPSHOT_TYPE,
|
|
142
|
+
payload: snapshot,
|
|
143
|
+
trace_id: @current_trace_id,
|
|
144
|
+
causation_id: @current_causation_id
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
result
|
|
148
|
+
rescue Ask::Agent::Aborted
|
|
149
|
+
@host.append(
|
|
150
|
+
@session_id,
|
|
151
|
+
type: "turn.aborted",
|
|
152
|
+
payload: { turn_id: @current_turn_id },
|
|
153
|
+
trace_id: @current_trace_id,
|
|
154
|
+
causation_id: @current_causation_id
|
|
155
|
+
)
|
|
156
|
+
raise
|
|
157
|
+
rescue => e
|
|
158
|
+
@host.append(
|
|
159
|
+
@session_id,
|
|
160
|
+
type: "turn.failed",
|
|
161
|
+
payload: { error: e.message, error_class: e.class.name },
|
|
162
|
+
trace_id: @current_trace_id,
|
|
163
|
+
causation_id: @current_causation_id
|
|
164
|
+
)
|
|
165
|
+
raise
|
|
166
|
+
ensure
|
|
167
|
+
@current_turn_id = nil
|
|
168
|
+
@current_causation_id = nil
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Request agent abort without writing a terminal session event.
|
|
173
|
+
def abort
|
|
174
|
+
@agent.abort
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Close the ask-session (terminal: :closed).
|
|
178
|
+
def close
|
|
179
|
+
@host.close(@session_id)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Abort the ask-session (terminal: :aborted).
|
|
183
|
+
def abort_session
|
|
184
|
+
@host.abort(@session_id)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def attach_event_handler
|
|
190
|
+
@agent.on_event do |event|
|
|
191
|
+
handle_event(event)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def handle_event(event)
|
|
196
|
+
trace_id = @current_trace_id
|
|
197
|
+
causation_id = @current_causation_id
|
|
198
|
+
turn_id = @current_turn_id
|
|
199
|
+
|
|
200
|
+
case event
|
|
201
|
+
when Events::TurnStart
|
|
202
|
+
@current_turn_id = (@current_turn_id || 0) + 1
|
|
203
|
+
turn_id = @current_turn_id
|
|
204
|
+
@host.append(@session_id, type: "turn.started", payload: { turn_id: turn_id }, trace_id: trace_id, causation_id: causation_id)
|
|
205
|
+
|
|
206
|
+
when Events::TextDelta
|
|
207
|
+
@host.append(@session_id, type: "model.streaming", payload: { content: event.content, turn_id: turn_id }, trace_id: trace_id, causation_id: causation_id)
|
|
208
|
+
|
|
209
|
+
when Events::ThinkingDelta
|
|
210
|
+
@host.append(@session_id, type: "model.thinking", payload: { content: event.content, turn_id: turn_id }, trace_id: trace_id, causation_id: causation_id)
|
|
211
|
+
|
|
212
|
+
when Events::ToolExecutionStart
|
|
213
|
+
@host.append(@session_id, type: "tool.use", payload: { tool_name: event.name, arguments: event.arguments, tool_call_id: event.id, turn_id: turn_id }, trace_id: trace_id, causation_id: causation_id)
|
|
214
|
+
|
|
215
|
+
when Events::ToolExecutionUpdate
|
|
216
|
+
@host.append(@session_id, type: "tool.delta", payload: { tool_name: event.name, tool_call_id: event.id, partial_result: event.partial_result, turn_id: turn_id }, trace_id: trace_id, causation_id: causation_id)
|
|
217
|
+
|
|
218
|
+
when Events::ToolExecutionEnd
|
|
219
|
+
@host.append(@session_id, type: "tool.result", payload: { tool_name: event.name, tool_call_id: event.id, result: event.result, is_error: event.is_error, duration_ms: event.duration_ms, turn_id: turn_id }, trace_id: trace_id, causation_id: causation_id)
|
|
220
|
+
|
|
221
|
+
when Events::TodoUpdated
|
|
222
|
+
@host.append(@session_id, type: "todos.updated", payload: { todos: event.todos }, trace_id: trace_id, causation_id: causation_id)
|
|
223
|
+
|
|
224
|
+
when Events::PlanProposed
|
|
225
|
+
@host.append(@session_id, type: "plan.proposed", payload: { plan: event.plan }, trace_id: trace_id, causation_id: causation_id)
|
|
226
|
+
|
|
227
|
+
when Events::PlanApproved
|
|
228
|
+
@host.append(@session_id, type: "plan.approved", payload: { plan: event.plan }, trace_id: trace_id, causation_id: causation_id)
|
|
229
|
+
|
|
230
|
+
when Events::PlanRejected
|
|
231
|
+
@host.append(@session_id, type: "plan.rejected", payload: { plan: event.plan }, trace_id: trace_id, causation_id: causation_id)
|
|
232
|
+
|
|
233
|
+
when Events::Error
|
|
234
|
+
@host.append(@session_id, type: "error", payload: { message: event.error, recoverable: event.recoverable }, trace_id: trace_id, causation_id: causation_id)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def build_snapshot
|
|
239
|
+
{
|
|
240
|
+
messages: (@agent.messages || []).map(&:to_h),
|
|
241
|
+
turn_count: @agent.turn_count || 0
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def self.deserialize_content(content)
|
|
246
|
+
content.is_a?(Array) ? content.map { |block| Ask::Content.from_h(block) } : content
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Ask
|
|
4
4
|
module Agent
|
|
5
5
|
class ToolExecutor
|
|
6
|
+
include Ask::Runtime::ToolExecutor
|
|
7
|
+
|
|
6
8
|
CRITICAL_ERROR_CLASSES = %w[
|
|
7
9
|
Ask::Unauthorized
|
|
8
10
|
Ask::Forbidden
|
|
@@ -22,23 +24,41 @@ module Ask
|
|
|
22
24
|
|
|
23
25
|
attr_writer :telemetry
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
# Public entry point — dispatches between two contracts:
|
|
28
|
+
#
|
|
29
|
+
# Batch API: execute(tool_calls, tools, hooks:, event_emitter:, ...)
|
|
30
|
+
# Runtime API: execute(tool_call, context:)
|
|
31
|
+
#
|
|
32
|
+
# The discriminator is the first argument: a Hash triggers the batch path,
|
|
33
|
+
# anything else (typically an Ask::Runtime::ToolCall) triggers the runtime path.
|
|
34
|
+
def execute(tool_calls_or_call, tools_or_context = nil, **rest)
|
|
35
|
+
if tool_calls_or_call.is_a?(Hash)
|
|
36
|
+
execute_batch(tool_calls_or_call, tools_or_context, **rest)
|
|
37
|
+
else
|
|
38
|
+
ctx = rest.key?(:context) ? rest[:context] : tools_or_context
|
|
39
|
+
_runtime_execute(tool_calls_or_call, context: ctx)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def execute_batch(tool_calls, tools, hooks:, event_emitter:, session_id: nil, turn: nil, result_callback: nil, runtime_event_sink: nil)
|
|
26
44
|
return [] if tool_calls.empty?
|
|
27
45
|
|
|
28
46
|
@total_executions = 0
|
|
29
47
|
@session_id = session_id
|
|
30
|
-
|
|
48
|
+
@turn = turn
|
|
49
|
+
@last_tools = tools
|
|
50
|
+
sibling_abort = CallbackAbortController.new
|
|
31
51
|
|
|
32
52
|
if @parallel
|
|
33
|
-
execute_parallel(tool_calls, tools, hooks, event_emitter, sibling_abort, &result_callback)
|
|
53
|
+
execute_parallel(tool_calls, tools, hooks, event_emitter, sibling_abort, runtime_event_sink, &result_callback)
|
|
34
54
|
else
|
|
35
|
-
execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort) do |id, result|
|
|
55
|
+
execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort, runtime_event_sink) do |id, result|
|
|
36
56
|
result_callback&.call(id, result)
|
|
37
57
|
end
|
|
38
58
|
end
|
|
39
59
|
end
|
|
40
60
|
|
|
41
|
-
def execute_parallel(tool_calls, tools, hooks, event_emitter, sibling_abort, &result_callback)
|
|
61
|
+
def execute_parallel(tool_calls, tools, hooks, event_emitter, sibling_abort, runtime_event_sink, &result_callback)
|
|
42
62
|
threads = []
|
|
43
63
|
mutex = Mutex.new
|
|
44
64
|
results = {}
|
|
@@ -58,7 +78,7 @@ module Ask
|
|
|
58
78
|
next
|
|
59
79
|
end
|
|
60
80
|
|
|
61
|
-
result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort)
|
|
81
|
+
result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort, runtime_event_sink)
|
|
62
82
|
mutex.synchronize { results[id] = result }
|
|
63
83
|
|
|
64
84
|
# Stream result back as it completes
|
|
@@ -87,12 +107,12 @@ module Ask
|
|
|
87
107
|
tool_calls.keys.filter_map { |id| results[id]&.merge(tool_call_id: id) }
|
|
88
108
|
end
|
|
89
109
|
|
|
90
|
-
def execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort, &result_callback)
|
|
110
|
+
def execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort, runtime_event_sink, &result_callback)
|
|
91
111
|
results = []
|
|
92
112
|
tool_calls.each do |id, tool_call|
|
|
93
113
|
break if sibling_abort.aborted?
|
|
94
114
|
|
|
95
|
-
result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort)
|
|
115
|
+
result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort, runtime_event_sink)
|
|
96
116
|
results << result.merge(tool_call_id: id)
|
|
97
117
|
result_callback&.call(id, result)
|
|
98
118
|
break if result[:critical_failure]
|
|
@@ -103,7 +123,300 @@ module Ask
|
|
|
103
123
|
|
|
104
124
|
private
|
|
105
125
|
|
|
106
|
-
|
|
126
|
+
class CallbackAbortController < ToolAbortController
|
|
127
|
+
def initialize
|
|
128
|
+
super
|
|
129
|
+
@abort_callbacks = []
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def abort!
|
|
133
|
+
callbacks_to_run = @mutex.synchronize do
|
|
134
|
+
was_aborted = @aborted
|
|
135
|
+
@aborted = true
|
|
136
|
+
was_aborted ? [] : @abort_callbacks.dup
|
|
137
|
+
end
|
|
138
|
+
callbacks_to_run.each(&:call)
|
|
139
|
+
self
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def on_abort(&block)
|
|
143
|
+
raise ArgumentError, "block required" unless block
|
|
144
|
+
|
|
145
|
+
already = @mutex.synchronize do
|
|
146
|
+
if @aborted
|
|
147
|
+
true
|
|
148
|
+
else
|
|
149
|
+
@abort_callbacks << block
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
block.call if already
|
|
154
|
+
self
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_runtime_tool_call(tool_call, effective_args: nil)
|
|
159
|
+
input = begin
|
|
160
|
+
raw = effective_args || tool_call.arguments
|
|
161
|
+
parsed = raw.is_a?(String) ? JSON.parse(raw) : raw
|
|
162
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
163
|
+
rescue JSON::ParserError
|
|
164
|
+
{}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
Ask::Runtime::ToolCall.new(
|
|
168
|
+
id: tool_call.id,
|
|
169
|
+
tool_name: tool_call.name,
|
|
170
|
+
input: input,
|
|
171
|
+
session_id: @session_id,
|
|
172
|
+
turn: @turn
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def build_runtime_context(abort_controller)
|
|
177
|
+
canceller = AbortControllerCancellerAdapter.new(abort_controller) if abort_controller
|
|
178
|
+
|
|
179
|
+
Ask::Runtime::ExecutionContext.new(
|
|
180
|
+
session_id: @session_id,
|
|
181
|
+
turn: @turn,
|
|
182
|
+
canceller: canceller
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
class AbortControllerCancellerAdapter
|
|
187
|
+
def initialize(abort_controller)
|
|
188
|
+
@abort_controller = abort_controller
|
|
189
|
+
@callbacks = []
|
|
190
|
+
@mutex = Mutex.new
|
|
191
|
+
|
|
192
|
+
if abort_controller.respond_to?(:on_abort)
|
|
193
|
+
abort_controller.on_abort { fire_callbacks }
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def cancelled?
|
|
198
|
+
@abort_controller.aborted?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def cancel
|
|
202
|
+
@abort_controller.abort!
|
|
203
|
+
fire_callbacks
|
|
204
|
+
self
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def on_cancel(&block)
|
|
208
|
+
raise ArgumentError, "block required" unless block
|
|
209
|
+
|
|
210
|
+
already = @mutex.synchronize do
|
|
211
|
+
if @abort_controller.aborted?
|
|
212
|
+
true
|
|
213
|
+
else
|
|
214
|
+
@callbacks << block
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
block.call if already
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def inspect
|
|
223
|
+
"#<Ask::Agent::ToolExecutor::AbortControllerCancellerAdapter cancelled=#{cancelled?}>"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def fire_callbacks
|
|
229
|
+
callbacks_to_run = @mutex.synchronize do
|
|
230
|
+
snapshot = @callbacks.dup
|
|
231
|
+
@callbacks.clear
|
|
232
|
+
snapshot
|
|
233
|
+
end
|
|
234
|
+
callbacks_to_run.each(&:call)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# ----------------------------------------------------------------
|
|
239
|
+
# Runtime contract: execute a single tool call and return ToolResult
|
|
240
|
+
# ----------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
# Implements Ask::Runtime::ToolExecutor#execute.
|
|
243
|
+
#
|
|
244
|
+
# This is the single-call entry point for the runtime contract.
|
|
245
|
+
# It does NOT run hooks, emit agent events, or manage batches — it is
|
|
246
|
+
# the pure runtime execution path that the batch API delegates to
|
|
247
|
+
# internally (via execute_single_tool).
|
|
248
|
+
#
|
|
249
|
+
# @param tool_call [Ask::Runtime::ToolCall] the tool-call request
|
|
250
|
+
# @param context [Ask::Runtime::ExecutionContext, nil] execution context
|
|
251
|
+
# @return [Ask::Runtime::ToolResult] the normalized result
|
|
252
|
+
def _runtime_execute(tool_call, context: nil)
|
|
253
|
+
context ||= Ask::Runtime::ExecutionContext.new
|
|
254
|
+
started_at = Time.now
|
|
255
|
+
started_call = tool_call.with(state: :running, started_at: started_at)
|
|
256
|
+
emit_runtime_event(context.event_sink, :tool_started,
|
|
257
|
+
Ask::Runtime::Events::ToolStarted.new(
|
|
258
|
+
tool_call: started_call, execution_context: context, timestamp: started_at
|
|
259
|
+
))
|
|
260
|
+
|
|
261
|
+
return runtime_cancel(started_call, context, started_at, "Cancelled before execution") if context.cancelled?
|
|
262
|
+
|
|
263
|
+
tool = find_tool_for_runtime(tool_call.tool_name)
|
|
264
|
+
|
|
265
|
+
result = if tool
|
|
266
|
+
invoke_tool_with_retry(tool, tool_call.id, tool_call.input, context.canceller)
|
|
267
|
+
else
|
|
268
|
+
Ask::Runtime::ToolResult.failure("Tool not found: #{tool_call.tool_name}")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
return runtime_cancel(started_call, context, started_at, "Cancelled during execution") if context.cancelled?
|
|
272
|
+
|
|
273
|
+
runtime_finish(started_call, context, result, started_at)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def runtime_finish(started_call, context, result, started_at)
|
|
277
|
+
finished_at = Time.now
|
|
278
|
+
duration = finished_at - started_at
|
|
279
|
+
result = Ask::Runtime::ToolResult.new(
|
|
280
|
+
result: result.result, outcome: result.outcome, duration: duration
|
|
281
|
+
)
|
|
282
|
+
state = if result.timeout?
|
|
283
|
+
:timed_out
|
|
284
|
+
elsif result.failure?
|
|
285
|
+
:failed
|
|
286
|
+
else
|
|
287
|
+
:completed
|
|
288
|
+
end
|
|
289
|
+
finished_call = started_call.with(
|
|
290
|
+
state: state,
|
|
291
|
+
tool_result: result,
|
|
292
|
+
error: result.error_message,
|
|
293
|
+
finished_at: finished_at
|
|
294
|
+
)
|
|
295
|
+
emit_runtime_terminal_event(
|
|
296
|
+
context.event_sink, state,
|
|
297
|
+
tool_call: finished_call, tool_result: result,
|
|
298
|
+
execution_context: context, timestamp: finished_at, duration: duration
|
|
299
|
+
)
|
|
300
|
+
result
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def runtime_cancel(started_call, context, started_at, reason)
|
|
304
|
+
finished_at = Time.now
|
|
305
|
+
duration = finished_at - started_at
|
|
306
|
+
result = Ask::Runtime::ToolResult.new(
|
|
307
|
+
result: Ask::Result.failure(reason), outcome: :cancelled, duration: duration
|
|
308
|
+
)
|
|
309
|
+
finished_call = started_call.with(
|
|
310
|
+
state: :cancelled,
|
|
311
|
+
tool_result: result,
|
|
312
|
+
error: result.error_message,
|
|
313
|
+
finished_at: finished_at
|
|
314
|
+
)
|
|
315
|
+
emit_runtime_terminal_event(
|
|
316
|
+
context.event_sink, :cancelled,
|
|
317
|
+
tool_call: finished_call, tool_result: result,
|
|
318
|
+
execution_context: context, timestamp: finished_at, duration: duration
|
|
319
|
+
)
|
|
320
|
+
result
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Find a tool by name for the runtime contract path.
|
|
324
|
+
# Uses the last-known tools list from execute_batch, or nil.
|
|
325
|
+
def find_tool_for_runtime(tool_name)
|
|
326
|
+
@last_tools&.find { |t| t.name == tool_name }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# ----------------------------------------------------------------
|
|
330
|
+
# Shared execution core — used by both batch and runtime paths
|
|
331
|
+
# ----------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
# Invoke a tool with retry logic, returning an Ask::Runtime::ToolResult.
|
|
334
|
+
#
|
|
335
|
+
# This is the single source of truth for tool invocation. It wraps
|
|
336
|
+
# try_call and handles retryable errors, producing a normalized
|
|
337
|
+
# ToolResult that both the batch and runtime paths consume.
|
|
338
|
+
def invoke_tool_with_retry(tool, tool_call_id, args, abort_controller = nil)
|
|
339
|
+
@max_retries.times do |attempt|
|
|
340
|
+
if abort_cancelled?(abort_controller)
|
|
341
|
+
return Ask::Runtime::ToolResult.cancelled("Aborted")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
result = try_call(tool, args, abort_controller)
|
|
345
|
+
return result unless result.failure? && retryable_error?(result)
|
|
346
|
+
|
|
347
|
+
sleep((2 ** attempt) * 0.5 + rand(0.0..0.5))
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
if abort_cancelled?(abort_controller)
|
|
351
|
+
return Ask::Runtime::ToolResult.cancelled("Aborted")
|
|
352
|
+
end
|
|
353
|
+
try_call(tool, args)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Check if the abort controller signals cancellation.
|
|
357
|
+
# Handles both ToolAbortController (aborted?) and Canceller (cancelled?).
|
|
358
|
+
def abort_cancelled?(controller)
|
|
359
|
+
return false unless controller
|
|
360
|
+
return true if controller.respond_to?(:aborted?) && controller.aborted?
|
|
361
|
+
return true if controller.respond_to?(:cancelled?) && controller.cancelled?
|
|
362
|
+
false
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Invoke a tool once, returning an Ask::Runtime::ToolResult.
|
|
366
|
+
#
|
|
367
|
+
# Normalizes all tool return types (Ask::Result, Hash, String, pending)
|
|
368
|
+
# into the canonical ToolResult shape. Preserves halted metadata for
|
|
369
|
+
# the batch path to consume.
|
|
370
|
+
def try_call(tool, args, abort_controller = nil)
|
|
371
|
+
raw = tool.call(args, abort_controller: abort_controller)
|
|
372
|
+
|
|
373
|
+
if raw.respond_to?(:pending?) && raw.pending?
|
|
374
|
+
return Ask::Runtime::ToolResult.success(data: raw)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
if raw.respond_to?(:ok?)
|
|
378
|
+
if raw.ok?
|
|
379
|
+
# Preserve the Ask::Result as the ToolResult's result object
|
|
380
|
+
# so downstream code can access metadata (e.g. halted flag).
|
|
381
|
+
Ask::Runtime::ToolResult.new(result: raw, outcome: :success)
|
|
382
|
+
else
|
|
383
|
+
Ask::Runtime::ToolResult.failure(extract_error_message(raw))
|
|
384
|
+
end
|
|
385
|
+
else
|
|
386
|
+
Ask::Runtime::ToolResult.success(data: raw)
|
|
387
|
+
end
|
|
388
|
+
rescue => e
|
|
389
|
+
Ask::Runtime::ToolResult.failure("#{e.class}: #{e.message}")
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Extract the output payload from an Ask::Result, unwrapping the wrapper
|
|
393
|
+
# so ToolResult.output carries the actual data.
|
|
394
|
+
def unwrap_output(result)
|
|
395
|
+
result.respond_to?(:output) ? result.output : result
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Extract a string error message from a failed Ask::Result.
|
|
399
|
+
def extract_error_message(result)
|
|
400
|
+
msg = result.respond_to?(:error_message) ? result.error_message : nil
|
|
401
|
+
msg || result.to_s
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Check if a ToolResult represents a retryable error.
|
|
405
|
+
def retryable_error?(tool_result)
|
|
406
|
+
return false unless tool_result.failure?
|
|
407
|
+
|
|
408
|
+
error_msg = tool_result.error_message.to_s
|
|
409
|
+
# Extract the class name from "ClassName: message" format.
|
|
410
|
+
# Split on ": " (colon-space) to avoid breaking on :: namespaces.
|
|
411
|
+
error_class_name = error_msg.split(": ").first
|
|
412
|
+
retryable_error_name?(error_class_name)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# ----------------------------------------------------------------
|
|
416
|
+
# Batch path: execute_single_tool (unchanged public behavior)
|
|
417
|
+
# ----------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def execute_single_tool(tool_call, tools, hooks, event_emitter, abort_controller = nil, runtime_event_sink = nil)
|
|
107
420
|
return aborted_result(tool_call) if abort_controller&.aborted?
|
|
108
421
|
|
|
109
422
|
tool = tools.find { |t| t.name == tool_call.name }
|
|
@@ -139,53 +452,101 @@ module Ask
|
|
|
139
452
|
name: tool_call.name, arguments: args, id: tool_call.id
|
|
140
453
|
))
|
|
141
454
|
|
|
455
|
+
runtime_call = build_runtime_tool_call(tool_call, effective_args: args)
|
|
456
|
+
# Expose the pending runtime call before execution starts so tools
|
|
457
|
+
# can observe the full pending → running → terminal lifecycle.
|
|
458
|
+
Thread.current[:ask_agent_runtime_call] = runtime_call
|
|
459
|
+
runtime_context = build_runtime_context(abort_controller)
|
|
460
|
+
|
|
142
461
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
462
|
+
start_timestamp = Time.now
|
|
143
463
|
Thread.current[:ask_agent_tool_call_id] = tool_call.id
|
|
144
|
-
|
|
145
|
-
|
|
464
|
+
Thread.current[:ask_agent_runtime_context] = runtime_context
|
|
465
|
+
|
|
466
|
+
# Transition to :running once execution is active and observable by tools.
|
|
467
|
+
running_call = runtime_call.with(state: :running, started_at: start_timestamp)
|
|
468
|
+
Thread.current[:ask_agent_runtime_call] = running_call
|
|
469
|
+
|
|
470
|
+
# Emit runtime ToolStarted event (one per tool call).
|
|
471
|
+
emit_runtime_event(runtime_event_sink, :tool_started,
|
|
472
|
+
Ask::Runtime::Events::ToolStarted.new(
|
|
473
|
+
tool_call: running_call, execution_context: runtime_context, timestamp: start_timestamp
|
|
474
|
+
))
|
|
475
|
+
|
|
476
|
+
# Shared execution core — invoke tool with retry, get ToolResult.
|
|
477
|
+
tool_result = begin
|
|
478
|
+
invoke_tool_with_retry(tool, tool_call.id, args, abort_controller)
|
|
146
479
|
rescue Exception => e
|
|
147
|
-
{
|
|
148
|
-
ensure
|
|
149
|
-
Thread.current[:ask_agent_tool_call_id] = nil
|
|
480
|
+
Ask::Runtime::ToolResult.failure("#{e.class}: #{e.message}")
|
|
150
481
|
end
|
|
151
|
-
|
|
482
|
+
|
|
483
|
+
# Classify into terminal state + legacy agent result hash.
|
|
484
|
+
terminal_state, legacy_result = classify_tool_result(tool_result, abort_controller)
|
|
485
|
+
|
|
486
|
+
# Transition runtime call to terminal state and attach ToolResult
|
|
487
|
+
# BEFORE clearing thread locals so tools can observe the full lifecycle
|
|
488
|
+
# including the unwrapped ToolResult.output.
|
|
489
|
+
finished_call = running_call.with(
|
|
490
|
+
state: terminal_state,
|
|
491
|
+
tool_result: tool_result,
|
|
492
|
+
error: tool_result.error_message,
|
|
493
|
+
finished_at: Time.now
|
|
494
|
+
)
|
|
495
|
+
Thread.current[:ask_agent_runtime_call] = finished_call
|
|
496
|
+
|
|
497
|
+
# Clear all thread locals — even on errors — so no stale or
|
|
498
|
+
# terminal runtime objects remain in Thread.current after cleanup.
|
|
499
|
+
Thread.current[:ask_agent_tool_call_id] = nil
|
|
500
|
+
Thread.current[:ask_agent_runtime_call] = nil
|
|
501
|
+
Thread.current[:ask_agent_runtime_context] = nil
|
|
502
|
+
|
|
503
|
+
duration_secs = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
504
|
+
duration_ms = (duration_secs * 1000).to_i
|
|
152
505
|
@total_executions += 1
|
|
153
506
|
|
|
507
|
+
# Emit runtime terminal event (exactly one per tool call).
|
|
508
|
+
emit_runtime_terminal_event(
|
|
509
|
+
runtime_event_sink, terminal_state,
|
|
510
|
+
tool_call: finished_call, tool_result: tool_result,
|
|
511
|
+
execution_context: runtime_context, timestamp: Time.now,
|
|
512
|
+
duration: duration_secs
|
|
513
|
+
)
|
|
514
|
+
|
|
154
515
|
return aborted_result(tool_call) if abort_controller&.aborted?
|
|
155
516
|
|
|
156
|
-
hook_result = hooks.run_after_tool(tool_call,
|
|
517
|
+
hook_result = hooks.run_after_tool(tool_call, legacy_result, {})
|
|
157
518
|
if hook_result&.dig(:action) == :transform
|
|
158
|
-
|
|
519
|
+
legacy_result = hook_result[:result]
|
|
159
520
|
end
|
|
160
521
|
|
|
161
|
-
is_error =
|
|
162
|
-
critical = is_error && critical_error?(
|
|
163
|
-
halted =
|
|
522
|
+
is_error = legacy_result[:is_error] == true
|
|
523
|
+
critical = is_error && critical_error?(legacy_result[:error])
|
|
524
|
+
halted = legacy_result[:halted] == true
|
|
164
525
|
|
|
165
526
|
if halted
|
|
166
527
|
abort_controller&.abort!
|
|
167
528
|
end
|
|
168
529
|
|
|
169
530
|
if is_error && @telemetry
|
|
170
|
-
@telemetry.log(:tool_error, session_id: @session_id, tool_name: tool_call.name, error_class:
|
|
531
|
+
@telemetry.log(:tool_error, session_id: @session_id, tool_name: tool_call.name, error_class: legacy_result[:error] || "RuntimeError", error_message: legacy_result[:result].to_s)
|
|
171
532
|
end
|
|
172
533
|
|
|
173
534
|
event_emitter.emit(Events::ToolExecutionEnd.new(
|
|
174
|
-
name: tool_call.name, id: tool_call.id, result:
|
|
535
|
+
name: tool_call.name, id: tool_call.id, result: legacy_result, is_error: is_error, duration_ms: duration_ms
|
|
175
536
|
))
|
|
176
537
|
|
|
177
538
|
message = if is_error
|
|
178
|
-
|
|
179
|
-
error_msg = if
|
|
180
|
-
|
|
181
|
-
elsif
|
|
182
|
-
|
|
539
|
+
inner = legacy_result[:result]
|
|
540
|
+
error_msg = if inner.is_a?(Hash) && inner[:error]
|
|
541
|
+
inner[:error].to_s
|
|
542
|
+
elsif inner.is_a?(String)
|
|
543
|
+
inner
|
|
183
544
|
else
|
|
184
|
-
|
|
545
|
+
legacy_result.to_s
|
|
185
546
|
end
|
|
186
547
|
"Tool #{tool_call.name} error: #{error_msg}"
|
|
187
548
|
else
|
|
188
|
-
|
|
549
|
+
legacy_result[:result].to_s
|
|
189
550
|
end
|
|
190
551
|
|
|
191
552
|
# Large outputs never enter the transcript: store the full message
|
|
@@ -200,8 +561,8 @@ module Ask
|
|
|
200
561
|
# Collect tool-produced deliverables (metadata[:artifact]) into the
|
|
201
562
|
# session's artifact store. Best-effort: a malformed artifact is
|
|
202
563
|
# noted in the message, never a tool failure.
|
|
203
|
-
if @artifact_store &&
|
|
204
|
-
(artifact =
|
|
564
|
+
if @artifact_store && legacy_result[:result].respond_to?(:metadata) &&
|
|
565
|
+
(artifact = legacy_result[:result].metadata[:artifact])
|
|
205
566
|
begin
|
|
206
567
|
attrs = symbolize_artifact(artifact)
|
|
207
568
|
@artifact_store.store(@session_id, **attrs)
|
|
@@ -210,8 +571,8 @@ module Ask
|
|
|
210
571
|
end
|
|
211
572
|
end
|
|
212
573
|
|
|
213
|
-
inner =
|
|
214
|
-
status = if
|
|
574
|
+
inner = legacy_result[:result]
|
|
575
|
+
status = if legacy_result[:is_error] == true
|
|
215
576
|
"error"
|
|
216
577
|
elsif inner.respond_to?(:pending?) && inner.pending?
|
|
217
578
|
"pending"
|
|
@@ -223,42 +584,33 @@ module Ask
|
|
|
223
584
|
tool_name: tool_call.name,
|
|
224
585
|
message: message,
|
|
225
586
|
status: status,
|
|
226
|
-
result:
|
|
587
|
+
result: legacy_result,
|
|
227
588
|
critical_failure: critical,
|
|
228
589
|
halted: halted
|
|
229
590
|
}
|
|
230
591
|
end
|
|
231
592
|
|
|
232
|
-
def
|
|
233
|
-
|
|
234
|
-
return { result: nil, is_error: true, error: "Aborted" } if abort_controller&.aborted?
|
|
235
|
-
|
|
236
|
-
result = try_call(tool, args, abort_controller)
|
|
237
|
-
return result unless result[:is_error] && retryable_error_name?(result[:error])
|
|
593
|
+
def emit_runtime_event(sink, event_type, event)
|
|
594
|
+
return unless sink
|
|
238
595
|
|
|
239
|
-
|
|
240
|
-
|
|
596
|
+
sink.emit(event_type, event: event)
|
|
597
|
+
end
|
|
241
598
|
|
|
242
|
-
|
|
243
|
-
|
|
599
|
+
def emit_runtime_terminal_event(sink, terminal_state, **attrs)
|
|
600
|
+
return unless sink
|
|
601
|
+
|
|
602
|
+
event_class = case terminal_state
|
|
603
|
+
when :completed then Ask::Runtime::Events::ToolCompleted
|
|
604
|
+
when :failed then Ask::Runtime::Events::ToolFailed
|
|
605
|
+
when :cancelled then Ask::Runtime::Events::ToolCancelled
|
|
606
|
+
when :timed_out then Ask::Runtime::Events::ToolTimedOut
|
|
607
|
+
else return
|
|
608
|
+
end
|
|
609
|
+
sink.emit(terminal_event_type(terminal_state), event: event_class.new(**attrs))
|
|
244
610
|
end
|
|
245
611
|
|
|
246
|
-
def
|
|
247
|
-
|
|
248
|
-
is_error = if result.respond_to?(:pending?) && result.pending?
|
|
249
|
-
false # async tool: work continues in the background
|
|
250
|
-
elsif result.respond_to?(:ok?)
|
|
251
|
-
!result.ok?
|
|
252
|
-
else
|
|
253
|
-
false
|
|
254
|
-
end
|
|
255
|
-
hash = { result: result, is_error: is_error }
|
|
256
|
-
if result.respond_to?(:metadata) && result.metadata&.dig(:halted)
|
|
257
|
-
hash[:halted] = true
|
|
258
|
-
end
|
|
259
|
-
hash
|
|
260
|
-
rescue => e
|
|
261
|
-
{ result: e.message, is_error: true, error: e.class.name }
|
|
612
|
+
def terminal_event_type(state)
|
|
613
|
+
"tool_#{state}".to_sym
|
|
262
614
|
end
|
|
263
615
|
|
|
264
616
|
# Store a large tool message in the output store and return a short
|
|
@@ -307,6 +659,103 @@ module Ask
|
|
|
307
659
|
aborted: true
|
|
308
660
|
}
|
|
309
661
|
end
|
|
662
|
+
|
|
663
|
+
# Map a ToolResult to a runtime terminal state and a legacy agent
|
|
664
|
+
# result hash. Returns [terminal_state_symbol, legacy_hash].
|
|
665
|
+
#
|
|
666
|
+
# This is the shared classification logic: the runtime path uses the
|
|
667
|
+
# ToolResult directly; the batch path wraps it in the agent's hash
|
|
668
|
+
# format for backward compatibility.
|
|
669
|
+
def classify_tool_result(tool_result, abort_controller)
|
|
670
|
+
if abort_controller&.aborted?
|
|
671
|
+
return [:cancelled, { result: "Aborted by sibling failure", is_error: true, error: "Aborted" }]
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
halted = tool_halted?(tool_result)
|
|
675
|
+
|
|
676
|
+
case tool_result.outcome
|
|
677
|
+
when :cancelled
|
|
678
|
+
[:cancelled, { result: tool_result.error_message, is_error: true, error: "Aborted" }]
|
|
679
|
+
when :failure
|
|
680
|
+
error_class = extract_error_class(tool_result.error_message)
|
|
681
|
+
[:failed, { result: tool_result.error_message, is_error: true, error: error_class }]
|
|
682
|
+
when :timeout
|
|
683
|
+
[:timed_out, { result: tool_result.error_message, is_error: true, error: "Timeout" }]
|
|
684
|
+
when :success
|
|
685
|
+
if tool_result.output.respond_to?(:pending?) && tool_result.output.pending?
|
|
686
|
+
[:completed, { result: tool_result.output, is_error: false, halted: halted }]
|
|
687
|
+
else
|
|
688
|
+
# Preserve the original Ask::Result in the legacy hash for
|
|
689
|
+
# backward compatibility — tools that return Ask::Result get
|
|
690
|
+
# their result object in [:result][:result].
|
|
691
|
+
legacy_inner = if tool_result.result.is_a?(Ask::Result)
|
|
692
|
+
tool_result.result
|
|
693
|
+
else
|
|
694
|
+
tool_result.output
|
|
695
|
+
end
|
|
696
|
+
[:completed, { result: legacy_inner, is_error: false, halted: halted }]
|
|
697
|
+
end
|
|
698
|
+
else
|
|
699
|
+
[:completed, { result: tool_result.output, is_error: false, halted: halted }]
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Detect if a tool result carries halted metadata.
|
|
704
|
+
# Checks the underlying Ask::Result (ToolResult.result) for halted
|
|
705
|
+
# metadata, since that's where tools set it via Ask::Result.ok(..., metadata: { halted: true }).
|
|
706
|
+
def tool_halted?(tool_result)
|
|
707
|
+
return false unless tool_result.success?
|
|
708
|
+
|
|
709
|
+
inner = tool_result.result
|
|
710
|
+
inner.respond_to?(:metadata) && inner.metadata&.dig(:halted) == true
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Extract the error class name from a tool failure message.
|
|
714
|
+
# Messages from try_call are formatted as "ClassName: message".
|
|
715
|
+
# Split on ": " (colon-space) to avoid breaking on :: namespaces.
|
|
716
|
+
def extract_error_class(error_message)
|
|
717
|
+
return "Error" unless error_message
|
|
718
|
+
error_message.split(": ").first || "Error"
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# Legacy classify_result for backward compatibility with code that
|
|
722
|
+
# passes raw agent result hashes. Delegates to the shared
|
|
723
|
+
# classify_tool_result after wrapping the hash in a ToolResult.
|
|
724
|
+
def classify_result(result, abort_controller)
|
|
725
|
+
tool_result = if abort_controller&.aborted?
|
|
726
|
+
Ask::Runtime::ToolResult.cancelled("Aborted by sibling failure")
|
|
727
|
+
elsif result[:is_error] == true
|
|
728
|
+
error_msg = extract_error_from_hash(result)
|
|
729
|
+
Ask::Runtime::ToolResult.failure(error_msg)
|
|
730
|
+
else
|
|
731
|
+
inner = result[:result]
|
|
732
|
+
if inner.respond_to?(:pending?) && inner.pending?
|
|
733
|
+
Ask::Runtime::ToolResult.success(data: inner)
|
|
734
|
+
else
|
|
735
|
+
data = if inner.is_a?(Ask::Result)
|
|
736
|
+
inner.output
|
|
737
|
+
elsif inner.is_a?(Hash) && inner.key?(:result)
|
|
738
|
+
inner[:result]
|
|
739
|
+
else
|
|
740
|
+
inner
|
|
741
|
+
end
|
|
742
|
+
Ask::Runtime::ToolResult.success(data: data)
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
classify_tool_result(tool_result, abort_controller)
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def extract_error_from_hash(result)
|
|
750
|
+
inner = result[:result]
|
|
751
|
+
if inner.is_a?(Hash) && inner[:error]
|
|
752
|
+
inner[:error].to_s
|
|
753
|
+
elsif inner.is_a?(String)
|
|
754
|
+
inner
|
|
755
|
+
else
|
|
756
|
+
result.to_s
|
|
757
|
+
end
|
|
758
|
+
end
|
|
310
759
|
end
|
|
311
760
|
end
|
|
312
761
|
end
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "ask/skills"
|
|
|
8
8
|
require "ask-llm-providers"
|
|
9
9
|
require "ask-tools"
|
|
10
10
|
require "ask-state-providers"
|
|
11
|
+
require "ask-runtime"
|
|
11
12
|
|
|
12
13
|
module Ask
|
|
13
14
|
module Agent
|
|
@@ -215,6 +216,7 @@ require_relative "agent/chat"
|
|
|
215
216
|
require_relative "agent/telemetry"
|
|
216
217
|
require_relative "agent/tool_abort_controller"
|
|
217
218
|
require_relative "agent/session"
|
|
219
|
+
require_relative "agent/session_adapter"
|
|
218
220
|
require_relative "agent/loop"
|
|
219
221
|
require_relative "agent/reflector"
|
|
220
222
|
require_relative "agent/evaluator"
|
|
@@ -258,6 +260,20 @@ module Ask
|
|
|
258
260
|
def current_tool_call_id
|
|
259
261
|
Thread.current[:ask_agent_tool_call_id]
|
|
260
262
|
end
|
|
263
|
+
|
|
264
|
+
# @return [Ask::Runtime::ToolCall, nil] the runtime tool call for the
|
|
265
|
+
# currently executing tool on this thread. Available only during tool
|
|
266
|
+
# execution inside ToolExecutor.
|
|
267
|
+
def current_runtime_call
|
|
268
|
+
Thread.current[:ask_agent_runtime_call]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# @return [Ask::Runtime::ExecutionContext, nil] the runtime execution
|
|
272
|
+
# context for the currently executing tool on this thread. Available
|
|
273
|
+
# only during tool execution inside ToolExecutor.
|
|
274
|
+
def current_runtime_context
|
|
275
|
+
Thread.current[:ask_agent_runtime_context]
|
|
276
|
+
end
|
|
261
277
|
end
|
|
262
278
|
end
|
|
263
279
|
end
|
data/lib/ask-agent.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-agent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.40.
|
|
4
|
+
version: 0.40.21
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: 0.12.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ask-runtime
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.1.0
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: ask-state-providers
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -107,6 +121,20 @@ dependencies:
|
|
|
107
121
|
- - "~>"
|
|
108
122
|
- !ruby/object:Gem::Version
|
|
109
123
|
version: '3.9'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: ask-session
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: 0.1.0
|
|
131
|
+
type: :runtime
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: 0.1.0
|
|
110
138
|
- !ruby/object:Gem::Dependency
|
|
111
139
|
name: minitest
|
|
112
140
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -202,6 +230,7 @@ files:
|
|
|
202
230
|
- lib/ask/agent/reflector.rb
|
|
203
231
|
- lib/ask/agent/scheduler.rb
|
|
204
232
|
- lib/ask/agent/session.rb
|
|
233
|
+
- lib/ask/agent/session_adapter.rb
|
|
205
234
|
- lib/ask/agent/stream_transforms/base.rb
|
|
206
235
|
- lib/ask/agent/stream_transforms/extract_json.rb
|
|
207
236
|
- lib/ask/agent/stream_transforms/pipeline.rb
|