ask-agent 0.15.0 → 0.23.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 +144 -1
- data/README.md +7 -0
- data/lib/ask/agent/chat.rb +30 -17
- data/lib/ask/agent/configuration.rb +4 -3
- data/lib/ask/agent/events.rb +1 -0
- data/lib/ask/agent/extensions/audit_log/active_record_writer.rb +70 -0
- data/lib/ask/agent/extensions/audit_log.rb +166 -19
- data/lib/ask/agent/loop.rb +11 -4
- data/lib/ask/agent/session.rb +12 -2
- data/lib/ask/agent/streaming.rb +190 -0
- data/lib/ask/agent/sub_agent.rb +190 -0
- data/lib/ask/agent/version.rb +1 -1
- data/lib/ask/agent.rb +24 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf7561a6eebc134d4e84ba829e637638a783eb7dd425c1b1c24d2d879e5ff47e
|
|
4
|
+
data.tar.gz: 73cdf03eec1b27f3e50cbfa28399bdf17994ea1b5925be36ce463ebf48c6669d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37b261bba11ac89e4051b2f631305ed6741ae8ba6a18f0973a2fa7a04416d3dd84d2ca9e4517d01f4776a21fbf143125a51bcceb74b67ce361d9c4ea35e15279
|
|
7
|
+
data.tar.gz: bc3051e56b9b68a003da46e1ee4c4ec10265239bfc2482c6cc335caf510f099256e1487526ead9277687004a0bb4c68926efb9f51db338b103f69188f21ed60a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,147 @@
|
|
|
1
|
-
## [0.
|
|
1
|
+
## [0.23.0] — 2026-07-30
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **`Ask::Agent::Configuration#default_provider` — global default provider**.
|
|
6
|
+
Pins which provider serves the default model when the model id is
|
|
7
|
+
registered under multiple providers (e.g. the same model on several
|
|
8
|
+
OpenAI-compatible endpoints). `Chat#build_provider` falls back to the
|
|
9
|
+
global default before the model's own catalog entry. A per-chat
|
|
10
|
+
`provider:` override or a Definition-level `provider` always wins.
|
|
11
|
+
|
|
12
|
+
## [0.22.0] — 2026-07-26
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`Ask::Agent::Extensions::AuditLog` — event-driven audit logging with pluggable adapters**.
|
|
17
|
+
Subscribes to all session events and writes them to a configurable adapter.
|
|
18
|
+
Ships with two built-in adapters:
|
|
19
|
+
|
|
20
|
+
- **`FileAdapter`** — appends JSON lines to a file (development/quick-start)
|
|
21
|
+
- **`ActiveRecordWriter`** — writes to an `ask_audit_logs` table, auto-creates it
|
|
22
|
+
on first write using `CREATE TABLE IF NOT EXISTS`. Works with or without Rails
|
|
23
|
+
migrations.
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# Global config (all sessions)
|
|
27
|
+
Ask::Agent.configure { |c| c.audit_log = { adapter: :active_record } }
|
|
28
|
+
|
|
29
|
+
# Per-session
|
|
30
|
+
session = Ask::Agent::Session.new(model: "gpt-4o", audit_log: { adapter: :file })
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Events logged: `session_start`, `session_end`, `turn_end`, `tool_execution_start`,
|
|
34
|
+
`tool_execution_end`, `error`, `max_turns_exceeded`, `loop_detected`,
|
|
35
|
+
`compaction_end`, `evaluation_blocked`.
|
|
36
|
+
|
|
37
|
+
Sensitive arguments (password, token, api_key, sql, etc.) are redacted automatically.
|
|
38
|
+
|
|
39
|
+
- **12 tests** for AuditLog — adapter contract, event subscription, config integration,
|
|
40
|
+
sensitive arg redaction, legacy hook interface.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- `Session#initialize` now accepts `audit_log:` parameter and falls back to
|
|
45
|
+
`Ask::Agent.configuration.audit_log`.
|
|
46
|
+
- `Ask::Agent::Configuration#audit_log` — new accessor for global audit log config.
|
|
47
|
+
|
|
48
|
+
## [0.21.0] — 2026-07-26
|
|
49
|
+
|
|
50
|
+
- Version bump only.
|
|
51
|
+
|
|
52
|
+
## [0.20.0] — 2026-07-26
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **`Events::ThinkingDelta`** — new event emitted when a chunk has thinking/reasoning
|
|
57
|
+
content. The loop already received chunks with `.thinking` data from providers like
|
|
58
|
+
DeepSeek and Claude, but it wasn't exposed as a dedicated event. Now it is.
|
|
59
|
+
- **`Ask::Agent::Streaming`** — framework-agnostic SSE streaming module. Returns a
|
|
60
|
+
Rack-compatible Enumerator that yields SSE-formatted strings as the agent runs.
|
|
61
|
+
Works with any Rack server without requiring Rails or ActionController::Live.
|
|
62
|
+
|
|
63
|
+
Two modes:
|
|
64
|
+
- **Enumerable mode** (no block) — for Rack/Roda/Sinatra:
|
|
65
|
+
```ruby
|
|
66
|
+
stream = Ask::Agent::Streaming.run(session, prompt)
|
|
67
|
+
[200, { "Content-Type" => "text/event-stream" }, stream]
|
|
68
|
+
```
|
|
69
|
+
- **Block mode** — for Rails `ActionController::Live::SSE`:
|
|
70
|
+
```ruby
|
|
71
|
+
Ask::Agent::Streaming.run(session, prompt) do |type, data|
|
|
72
|
+
sse.write(data, event: type)
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **19 tests** for Streaming + ThinkingDelta — Enumerator mode, block mode, custom
|
|
77
|
+
event maps, error handling, SSE line format, event structure.
|
|
78
|
+
|
|
79
|
+
## [0.19.0] — 2026-07-26
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
|
|
83
|
+
- **`Ask::Agent::SubAgent.new("definition_name")` — create sub-agents from
|
|
84
|
+
filesystem definitions**. Passing a string looks up an agent definition
|
|
85
|
+
by name (same convention as `Ask::Agent.new("name")`), reading model,
|
|
86
|
+
tools, instructions, and other settings from the definition files.
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# agents/web_search/agent.rb defines model, tools, instructions
|
|
90
|
+
search = Ask::Agent::SubAgent.new("web_search")
|
|
91
|
+
|
|
92
|
+
coordinator = Ask::Agent::Session.new(
|
|
93
|
+
model: "gpt-4o",
|
|
94
|
+
tools: [search, Ask::Tools::Shell::Bash]
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **VCR-based integration tests** for SubAgent. Real API calls are recorded
|
|
99
|
+
and replayed via VCR cassettes. Run with `OPENAI_API_KEY` set to record,
|
|
100
|
+
or without to replay existing cassettes.
|
|
101
|
+
|
|
102
|
+
### Changed
|
|
103
|
+
|
|
104
|
+
- `Ask::Agent::SubAgent.new(name:, ...)` now supports `provider:` parameter
|
|
105
|
+
for provider-specific sub-agents.
|
|
106
|
+
|
|
107
|
+
## [0.18.0] — 2026-07-26
|
|
108
|
+
|
|
109
|
+
### Added
|
|
110
|
+
|
|
111
|
+
- **`Ask::Agent::SubAgent` — delegate tasks to a specialized sub-agent tool**.
|
|
112
|
+
A self-contained tool class that satisfies the tool duck type (`name`,
|
|
113
|
+
`description`, `params_schema`, `call`). When the coordinator LLM calls it,
|
|
114
|
+
a fresh sub-agent session runs independently with its own model, tools,
|
|
115
|
+
and instructions.
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
search = Ask::Agent::SubAgent.new(
|
|
119
|
+
name: "web_search",
|
|
120
|
+
description: "Search the web for current information",
|
|
121
|
+
model: "gpt-4o-mini",
|
|
122
|
+
tools: [MyApp::Tools::WebSearch],
|
|
123
|
+
system_prompt: "You are a research assistant."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
coordinator = Ask::Agent::Session.new(
|
|
127
|
+
model: "gpt-4o",
|
|
128
|
+
tools: [search, Ask::Tools::Shell::Bash]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
coordinator.run("What's the latest Rails release and how stable is it?")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Removed
|
|
135
|
+
|
|
136
|
+
- **`Ask::Agent.sub_agent_tool`** factory method — replaced by the
|
|
137
|
+
`Ask::Agent::SubAgent` class directly. The class IS the tool, no
|
|
138
|
+
factory or wrapper needed.
|
|
139
|
+
|
|
140
|
+
## [0.17.0] — 2026-07-26
|
|
141
|
+
|
|
142
|
+
### Added
|
|
143
|
+
|
|
144
|
+
- Bump ask-tools dependency for `Ask::Tools::SubAgent` support
|
|
2
145
|
|
|
3
146
|
### Added
|
|
4
147
|
|
data/README.md
CHANGED
|
@@ -218,6 +218,7 @@ c.middleware.use :model_fallback,
|
|
|
218
218
|
```ruby
|
|
219
219
|
Ask::Agent.configure do |c|
|
|
220
220
|
c.default_model = "claude-sonnet-4"
|
|
221
|
+
c.default_provider = :anthropic
|
|
221
222
|
c.default_max_turns = 50
|
|
222
223
|
c.compactor_enabled = true
|
|
223
224
|
c.compactor_threshold = 0.8
|
|
@@ -226,6 +227,12 @@ Ask::Agent.configure do |c|
|
|
|
226
227
|
end
|
|
227
228
|
```
|
|
228
229
|
|
|
230
|
+
`default_provider` pins which provider serves the default model when the
|
|
231
|
+
model name doesn't uniquely identify one (for example, the same model id
|
|
232
|
+
registered under multiple OpenAI-compatible providers). A `provider:`
|
|
233
|
+
passed to `Session.new` or declared in an agent `Definition` always wins
|
|
234
|
+
over the global default.
|
|
235
|
+
|
|
229
236
|
## Persistence
|
|
230
237
|
|
|
231
238
|
```ruby
|
data/lib/ask/agent/chat.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Ask
|
|
|
19
19
|
ToolCallInfo = Data.define(:id, :name, :arguments)
|
|
20
20
|
|
|
21
21
|
ChatChunk = Data.define(:content, :tool_calls, :thinking, :input_tokens, :output_tokens) do
|
|
22
|
-
def tool_call? = !tool_calls.empty?
|
|
22
|
+
def tool_call? = !tool_calls.to_a.empty?
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
class Chat
|
|
@@ -68,7 +68,8 @@ module Ask
|
|
|
68
68
|
metadata: {
|
|
69
69
|
input_tokens: response_msg.input_tokens,
|
|
70
70
|
output_tokens: response_msg.output_tokens,
|
|
71
|
-
cost: response_msg.cost
|
|
71
|
+
cost: response_msg.cost,
|
|
72
|
+
thinking: response_msg.thinking
|
|
72
73
|
}.compact
|
|
73
74
|
)
|
|
74
75
|
|
|
@@ -115,7 +116,7 @@ module Ask
|
|
|
115
116
|
end
|
|
116
117
|
|
|
117
118
|
def build_provider
|
|
118
|
-
slug = @provider_override&.to_s || @model_info.provider
|
|
119
|
+
slug = @provider_override&.to_s || Ask::Agent.configuration.default_provider&.to_s || @model_info.provider
|
|
119
120
|
klass = Ask::Provider.resolve(slug)
|
|
120
121
|
klass.new(provider_config(slug))
|
|
121
122
|
end
|
|
@@ -255,12 +256,17 @@ module Ask
|
|
|
255
256
|
def accumulate_tool_calls(raw_chunk, calls_acc)
|
|
256
257
|
return unless raw_chunk.tool_call?
|
|
257
258
|
|
|
258
|
-
raw_chunk.tool_calls
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
259
|
+
tool_calls = raw_chunk.tool_calls
|
|
260
|
+
return unless tool_calls.respond_to?(:each)
|
|
261
|
+
|
|
262
|
+
tool_calls.each do |tc|
|
|
263
|
+
next unless tc.respond_to?(:[])
|
|
264
|
+
idx = tc[:index] || tc["index"] || 0
|
|
265
|
+
calls_acc[idx] ||= { id: tc[:id] || tc["id"], name: tc[:name] || tc["name"], arguments: +"" }
|
|
266
|
+
calls_acc[idx][:id] ||= tc[:id] || tc["id"]
|
|
267
|
+
calls_acc[idx][:name] ||= tc[:name] || tc["name"]
|
|
268
|
+
arguments = tc[:arguments] || tc["arguments"]
|
|
269
|
+
calls_acc[idx][:arguments] << arguments.to_s if arguments
|
|
264
270
|
end
|
|
265
271
|
end
|
|
266
272
|
|
|
@@ -279,14 +285,21 @@ module Ask
|
|
|
279
285
|
|
|
280
286
|
def build_tool_call_hash(raw_calls)
|
|
281
287
|
hash = {}
|
|
288
|
+
return hash unless raw_calls.respond_to?(:each)
|
|
289
|
+
|
|
282
290
|
raw_calls.each do |tc|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
291
|
+
# Provider tool calls come as an Array of Hashes.
|
|
292
|
+
# A Hash argument (key-value pairs) means the caller passed
|
|
293
|
+
# a Hash instead of an Array — iterate values instead.
|
|
294
|
+
if tc.is_a?(Hash)
|
|
295
|
+
id = tc[:id] || tc["id"]
|
|
296
|
+
next unless id
|
|
297
|
+
hash[id] = ToolCallInfo.new(
|
|
298
|
+
id: id,
|
|
299
|
+
name: tc[:name] || tc["name"] || "",
|
|
300
|
+
arguments: tc[:arguments] || tc["arguments"] || ""
|
|
301
|
+
)
|
|
302
|
+
end
|
|
290
303
|
end
|
|
291
304
|
hash
|
|
292
305
|
end
|
|
@@ -298,7 +311,7 @@ module Ask
|
|
|
298
311
|
content: stream.accumulated_text,
|
|
299
312
|
tool_calls: build_current_tool_calls(calls_acc),
|
|
300
313
|
tool_results: {},
|
|
301
|
-
thinking: stream.chunks.filter_map(&:thinking).
|
|
314
|
+
thinking: stream.chunks.filter_map(&:thinking).join,
|
|
302
315
|
input_tokens: tokens[:input],
|
|
303
316
|
output_tokens: tokens[:output],
|
|
304
317
|
cost: cost
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
module Ask
|
|
4
4
|
module Agent
|
|
5
5
|
class Configuration
|
|
6
|
-
attr_accessor :default_model, :
|
|
7
|
-
:
|
|
8
|
-
:prompt_caching, :default_evaluator_model
|
|
6
|
+
attr_accessor :default_model, :default_provider, :default_max_turns,
|
|
7
|
+
:compactor_enabled, :compactor_threshold, :parallel_tool_execution,
|
|
8
|
+
:max_tool_retries, :prompt_caching, :default_evaluator_model,
|
|
9
|
+
:audit_log
|
|
9
10
|
|
|
10
11
|
# @return [Middleware::Pipeline] the middleware pipeline for provider calls
|
|
11
12
|
attr_reader :middleware
|
data/lib/ask/agent/events.rb
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
module Extensions
|
|
6
|
+
class AuditLog
|
|
7
|
+
# ActiveRecord adapter for the audit log.
|
|
8
|
+
# Auto-creates the +ask_audit_logs+ table on first write using
|
|
9
|
+
# CREATE TABLE IF NOT EXISTS, so it works with or without Rails
|
|
10
|
+
# migrations. Rails users can also run:
|
|
11
|
+
#
|
|
12
|
+
# rails generate ask_rails:install
|
|
13
|
+
#
|
|
14
|
+
# to get a proper migration file. The migration uses
|
|
15
|
+
# +if_not_exists: true+ so it won't conflict with auto-creation.
|
|
16
|
+
class ActiveRecordWriter < Adapter
|
|
17
|
+
TABLE_NAME = "ask_audit_logs"
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@table_checked = false
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def write(entry)
|
|
25
|
+
return unless defined?(ActiveRecord::Base)
|
|
26
|
+
|
|
27
|
+
ensure_table!
|
|
28
|
+
conn = ActiveRecord::Base.connection
|
|
29
|
+
conn.execute(
|
|
30
|
+
"INSERT INTO #{TABLE_NAME} (session_id, event_type, data, timestamp, created_at, updated_at) " \
|
|
31
|
+
"VALUES (#{quote(entry[:session_id])}, #{quote(entry[:event_type])}, " \
|
|
32
|
+
"#{quote(entry[:data].to_json)}, #{quote(entry[:timestamp])}, " \
|
|
33
|
+
"#{quote(Time.now.utc.iso8601(3))}, #{quote(Time.now.utc.iso8601(3))})"
|
|
34
|
+
)
|
|
35
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
36
|
+
warn "[ask-agent] AuditLog::ActiveRecordWriter write failed: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def ensure_table!
|
|
42
|
+
return if @table_checked
|
|
43
|
+
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
return if @table_checked
|
|
46
|
+
conn = ActiveRecord::Base.connection
|
|
47
|
+
unless conn.table_exists?(TABLE_NAME)
|
|
48
|
+
conn.create_table(TABLE_NAME, if_not_exists: true) do |t|
|
|
49
|
+
t.string :session_id, null: false
|
|
50
|
+
t.string :event_type, null: false
|
|
51
|
+
t.jsonb :data, default: {}
|
|
52
|
+
t.datetime :timestamp, null: false
|
|
53
|
+
t.timestamps
|
|
54
|
+
|
|
55
|
+
t.index [:session_id, :event_type]
|
|
56
|
+
t.index :timestamp
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
@table_checked = true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def quote(value)
|
|
64
|
+
ActiveRecord::Base.connection.quote(value)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -1,40 +1,187 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Ask
|
|
4
6
|
module Agent
|
|
5
7
|
module Extensions
|
|
8
|
+
# Event-driven audit log for agent sessions.
|
|
9
|
+
#
|
|
10
|
+
# Subscribes to all session events and writes them to a configurable
|
|
11
|
+
# adapter. Ships with an ActiveRecord adapter; custom adapters can
|
|
12
|
+
# implement the simple {Adapter} interface.
|
|
13
|
+
#
|
|
14
|
+
# @example Enable globally (ActiveRecord)
|
|
15
|
+
# Ask::Agent.configure do |c|
|
|
16
|
+
# c.audit_log = { adapter: :active_record }
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Per-session with custom adapter
|
|
20
|
+
# Session.new(model: "gpt-4o", audit_log: { adapter: MyWriter.new })
|
|
21
|
+
#
|
|
6
22
|
class AuditLog
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
23
|
+
# Pluggable adapter interface.
|
|
24
|
+
# Implement +write(entry)+ to persist a structured event hash.
|
|
25
|
+
class Adapter
|
|
26
|
+
def write(entry)
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
end
|
|
10
30
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@
|
|
31
|
+
# Built-in adapter: appends JSON lines to a file.
|
|
32
|
+
class FileAdapter < Adapter
|
|
33
|
+
def initialize(path: "tmp/agent_audit.jsonl")
|
|
34
|
+
@path = path
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def write(entry)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
File.open(@path, "a") { |f| f.puts(JSON.generate(entry)) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Event types that get persisted (not every delta/stream event).
|
|
46
|
+
STORED_EVENTS = {
|
|
47
|
+
Events::SessionStart => "session_start",
|
|
48
|
+
Events::SessionEnd => "session_end",
|
|
49
|
+
Events::TurnEnd => "turn_end",
|
|
50
|
+
Events::ToolExecutionStart => "tool_execution_start",
|
|
51
|
+
Events::ToolExecutionEnd => "tool_execution_end",
|
|
52
|
+
Events::Error => "error",
|
|
53
|
+
Events::MaxTurnsExceeded => "max_turns_exceeded",
|
|
54
|
+
Events::LoopDetected => "loop_detected",
|
|
55
|
+
Events::CompactionEnd => "compaction_end",
|
|
56
|
+
Events::EvaluationBlocked => "evaluation_blocked"
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
def initialize(session, adapter: nil)
|
|
60
|
+
@session = session
|
|
61
|
+
@session_id = session.id
|
|
62
|
+
@adapter = resolve(adapter)
|
|
63
|
+
subscribe! if @adapter
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Subscribe to session events and log stored event types.
|
|
67
|
+
def subscribe!
|
|
68
|
+
@session.on_event do |event|
|
|
69
|
+
type = STORED_EVENTS[event.class]
|
|
70
|
+
next unless type
|
|
71
|
+
|
|
72
|
+
write_entry(type, extract(event))
|
|
16
73
|
end
|
|
17
74
|
end
|
|
18
75
|
|
|
76
|
+
# Legacy hook interface — kept for backward compatibility.
|
|
77
|
+
# Called by the hooks system after each tool execution.
|
|
19
78
|
def after_tool_call(tool_call, result, _context)
|
|
20
|
-
|
|
21
|
-
timestamp: Time.now.utc.iso8601(3),
|
|
79
|
+
write_entry("tool_call", {
|
|
22
80
|
tool_name: tool_call.name,
|
|
23
81
|
arguments: tool_call.arguments,
|
|
24
|
-
result: result,
|
|
25
|
-
|
|
26
|
-
}
|
|
82
|
+
result: result&.to_s&.to_s[0, 500],
|
|
83
|
+
duration_ms: result[:duration_ms]
|
|
84
|
+
})
|
|
85
|
+
end
|
|
27
86
|
|
|
28
|
-
|
|
29
|
-
@entries << entry
|
|
30
|
-
@io.puts entry.to_json
|
|
31
|
-
end
|
|
87
|
+
private
|
|
32
88
|
|
|
89
|
+
def resolve(adapter)
|
|
90
|
+
return nil if adapter.nil?
|
|
91
|
+
return adapter if adapter.is_a?(Adapter)
|
|
92
|
+
|
|
93
|
+
case adapter
|
|
94
|
+
when :active_record
|
|
95
|
+
require "ask/agent/extensions/audit_log/active_record_writer"
|
|
96
|
+
AuditLog::ActiveRecordWriter.new
|
|
97
|
+
when Hash
|
|
98
|
+
resolve(adapter[:adapter] || adapter[:writer])
|
|
99
|
+
when Symbol, String
|
|
100
|
+
# Try to load adapter by convention:
|
|
101
|
+
# :active_record → ask/agent/extensions/audit_log/active_record_writer
|
|
102
|
+
name = adapter.to_s
|
|
103
|
+
begin
|
|
104
|
+
require "ask/agent/extensions/audit_log/#{name}_writer"
|
|
105
|
+
klass_name = name.split("_").map(&:capitalize).join
|
|
106
|
+
klass = AuditLog.const_get(klass_name)
|
|
107
|
+
klass.new
|
|
108
|
+
rescue LoadError
|
|
109
|
+
warn "[ask-agent] AuditLog: adapter not found: #{name}"
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
adapter
|
|
114
|
+
end
|
|
115
|
+
rescue LoadError
|
|
116
|
+
warn "[ask-agent] AuditLog: ActiveRecord adapter not available"
|
|
33
117
|
nil
|
|
34
118
|
end
|
|
35
119
|
|
|
36
|
-
def
|
|
37
|
-
|
|
120
|
+
def write_entry(type, data)
|
|
121
|
+
entry = {
|
|
122
|
+
session_id: @session_id,
|
|
123
|
+
event_type: type,
|
|
124
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
125
|
+
data: data
|
|
126
|
+
}
|
|
127
|
+
@adapter.write(entry)
|
|
128
|
+
rescue => e
|
|
129
|
+
warn "[ask-agent] AuditLog write failed: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def extract(event)
|
|
133
|
+
case event
|
|
134
|
+
when Events::SessionStart
|
|
135
|
+
{}
|
|
136
|
+
when Events::SessionEnd
|
|
137
|
+
{
|
|
138
|
+
turn_count: event.turn_count,
|
|
139
|
+
tool_calls_made: event.tool_calls_made,
|
|
140
|
+
input_tokens: event.input_tokens,
|
|
141
|
+
output_tokens: event.output_tokens,
|
|
142
|
+
cost: event.cost
|
|
143
|
+
}
|
|
144
|
+
when Events::TurnEnd
|
|
145
|
+
{
|
|
146
|
+
turn_number: event.turn_number,
|
|
147
|
+
tool_results_count: event.tool_results&.length || 0,
|
|
148
|
+
input_tokens: event.input_tokens,
|
|
149
|
+
output_tokens: event.output_tokens,
|
|
150
|
+
cost: event.cost
|
|
151
|
+
}
|
|
152
|
+
when Events::ToolExecutionStart
|
|
153
|
+
{ name: event.name, id: event.id, args: safe_args(event.arguments) }
|
|
154
|
+
when Events::ToolExecutionEnd
|
|
155
|
+
{
|
|
156
|
+
name: event.name, id: event.id,
|
|
157
|
+
duration_ms: event.duration_ms,
|
|
158
|
+
is_error: event.is_error,
|
|
159
|
+
result: event.result&.to_s&.to_s[0, 500]
|
|
160
|
+
}
|
|
161
|
+
when Events::Error
|
|
162
|
+
{ message: event.error, recoverable: event.recoverable }
|
|
163
|
+
when Events::MaxTurnsExceeded
|
|
164
|
+
{ max_turns: event.max_turns }
|
|
165
|
+
when Events::LoopDetected
|
|
166
|
+
{ tool_name: event.tool_name, repeated_count: event.repeated_count }
|
|
167
|
+
when Events::CompactionEnd
|
|
168
|
+
{ tokens_before: event.tokens_before, tokens_after: event.tokens_after }
|
|
169
|
+
when Events::EvaluationBlocked
|
|
170
|
+
{ feedback: event.feedback, scores: event.scores }
|
|
171
|
+
else
|
|
172
|
+
{}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def safe_args(args)
|
|
177
|
+
return {} unless args.is_a?(Hash)
|
|
178
|
+
|
|
179
|
+
safe = args.dup
|
|
180
|
+
%w[password secret token api_key key auth_token access_token sql command].each do |sensitive|
|
|
181
|
+
safe[sensitive] = "[REDACTED]" if safe.key?(sensitive)
|
|
182
|
+
safe[sensitive.to_sym] = "[REDACTED]" if safe.key?(sensitive.to_sym)
|
|
183
|
+
end
|
|
184
|
+
safe
|
|
38
185
|
end
|
|
39
186
|
end
|
|
40
187
|
end
|
data/lib/ask/agent/loop.rb
CHANGED
|
@@ -27,11 +27,18 @@ module Ask
|
|
|
27
27
|
event_emitter.emit(Events::TextDelta.new(content: chunk.content))
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
if chunk.respond_to?(:thinking) && chunk.thinking.to_s.strip.length > 0
|
|
31
|
+
event_emitter.emit(Events::ThinkingDelta.new(content: chunk.thinking))
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
if chunk.tool_call?
|
|
31
|
-
chunk.tool_calls
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
calls = chunk.tool_calls
|
|
36
|
+
if calls.respond_to?(:each)
|
|
37
|
+
calls.each do |id, tc|
|
|
38
|
+
event_emitter.emit(Events::ToolCallDelta.new(
|
|
39
|
+
name: tc.name, arguments: tc.arguments, id: tc.id
|
|
40
|
+
))
|
|
41
|
+
end
|
|
35
42
|
end
|
|
36
43
|
end
|
|
37
44
|
end
|
data/lib/ask/agent/session.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Ask
|
|
|
21
21
|
compactor: nil, hooks: {}, state: nil, persistence: nil,
|
|
22
22
|
id: nil, system_prompt: nil, parallel_tools: true,
|
|
23
23
|
reflector: nil, telemetry: true, meta_agent: nil,
|
|
24
|
-
agent_dir: nil, evaluator: nil, **chat_options)
|
|
24
|
+
agent_dir: nil, evaluator: nil, audit_log: nil, **chat_options)
|
|
25
25
|
@id = id || SecureRandom.uuid
|
|
26
26
|
@agent_dir = agent_dir
|
|
27
27
|
@max_turns = max_turns
|
|
@@ -47,6 +47,7 @@ module Ask
|
|
|
47
47
|
@tool_executor = ToolExecutor.new(max_retries: max_tool_retries, parallel: parallel_tools)
|
|
48
48
|
@compactor = compactor ? build_compactor(compactor) : nil
|
|
49
49
|
@hooks = Hooks.new(hooks)
|
|
50
|
+
@audit_log = build_audit_log(audit_log)
|
|
50
51
|
|
|
51
52
|
@system_context = build_system_context(system_prompt)
|
|
52
53
|
apply_system_context
|
|
@@ -229,6 +230,10 @@ module Ask
|
|
|
229
230
|
try_auto_meta_agent
|
|
230
231
|
end
|
|
231
232
|
|
|
233
|
+
# Capture messages before emitting SessionEnd so event handlers
|
|
234
|
+
# can access agent.messages during the callback
|
|
235
|
+
@messages = @chat.messages.dup
|
|
236
|
+
|
|
232
237
|
emit(Events::SessionEnd.new(
|
|
233
238
|
result: response,
|
|
234
239
|
turn_count: @turn_count,
|
|
@@ -237,7 +242,6 @@ module Ask
|
|
|
237
242
|
output_tokens: @total_output_tokens,
|
|
238
243
|
cost: @total_cost
|
|
239
244
|
))
|
|
240
|
-
@messages = @chat.messages.dup
|
|
241
245
|
|
|
242
246
|
response
|
|
243
247
|
end
|
|
@@ -331,6 +335,12 @@ module Ask
|
|
|
331
335
|
|
|
332
336
|
private
|
|
333
337
|
|
|
338
|
+
def build_audit_log(config)
|
|
339
|
+
config ||= Ask::Agent.configuration.audit_log
|
|
340
|
+
return nil unless config
|
|
341
|
+
Ask::Agent::Extensions::AuditLog.new(self, adapter: config)
|
|
342
|
+
end
|
|
343
|
+
|
|
334
344
|
def build_chat(model, system_prompt, tools, **chat_options)
|
|
335
345
|
if model.respond_to?(:ask)
|
|
336
346
|
model
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Agent
|
|
7
|
+
# Framework-agnostic SSE streaming for agent sessions.
|
|
8
|
+
#
|
|
9
|
+
# Returns a Rack-compatible Enumerator that yields SSE-formatted strings
|
|
10
|
+
# as the agent runs. Works with any Rack server (Puma, Falcon, etc.)
|
|
11
|
+
# without requiring ActionController::Live or Rails.
|
|
12
|
+
#
|
|
13
|
+
# @example In a Rails controller (with ActionController::Live::SSE)
|
|
14
|
+
# def create
|
|
15
|
+
# response.headers["Content-Type"] = "text/event-stream"
|
|
16
|
+
# sse = SSE.new(response.stream)
|
|
17
|
+
#
|
|
18
|
+
# Ask::Agent::Streaming.run(session, prompt) do |type, data|
|
|
19
|
+
# sse.write(data, event: type)
|
|
20
|
+
# end
|
|
21
|
+
# ensure
|
|
22
|
+
# sse&.close
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @example In a Rack app (raw Enumerator)
|
|
26
|
+
# stream = Ask::Agent::Streaming.run(session, prompt)
|
|
27
|
+
# [200, { "Content-Type" => "text/event-stream" }, stream]
|
|
28
|
+
#
|
|
29
|
+
# @example With custom event mapping
|
|
30
|
+
# stream = Ask::Agent::Streaming.run(session, prompt) do |event|
|
|
31
|
+
# case event
|
|
32
|
+
# when Events::TextDelta
|
|
33
|
+
# { type: "delta", data: { content: event.content } }
|
|
34
|
+
# when Events::ToolExecutionStart
|
|
35
|
+
# { type: "tool_start", data: { name: event.name, id: event.id } }
|
|
36
|
+
# else
|
|
37
|
+
# nil # skip unhandled events
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
module Streaming
|
|
41
|
+
DEFAULT_EVENT_MAP = {
|
|
42
|
+
Events::TextDelta => "delta",
|
|
43
|
+
Events::ThinkingDelta => "thinking",
|
|
44
|
+
Events::ToolCallDelta => "tool_call_delta",
|
|
45
|
+
Events::ToolExecutionStart => "tool_start",
|
|
46
|
+
Events::ToolExecutionUpdate => "tool_update",
|
|
47
|
+
Events::ToolExecutionEnd => "tool_end",
|
|
48
|
+
Events::SessionEnd => "done",
|
|
49
|
+
Events::Error => "error"
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
# Run an agent session and stream events as SSE-formatted strings.
|
|
54
|
+
#
|
|
55
|
+
# Two modes:
|
|
56
|
+
#
|
|
57
|
+
# 1. **No block** — returns a Rack-compatible Enumerator that yields
|
|
58
|
+
# raw SSE strings: "data: {\"type\":\"delta\",\"content\":\"...\"}\n\n"
|
|
59
|
+
#
|
|
60
|
+
# 2. **With block** — calls the block for each event with
|
|
61
|
+
# `(event_type_string, data_hash)`. The block is responsible for
|
|
62
|
+
# writing/handling the data. This mode is designed for use with
|
|
63
|
+
# Rails' `ActionController::Live::SSE#write`.
|
|
64
|
+
#
|
|
65
|
+
# @param session [Session] the agent session to run
|
|
66
|
+
# @param prompt [String] the user's message
|
|
67
|
+
# @param event_map [Hash<Class, String>] optional custom event-to-type mapping
|
|
68
|
+
# @yield [type, data] called for each event (only in block mode)
|
|
69
|
+
# @yieldparam type [String] the SSE event type name
|
|
70
|
+
# @yieldparam data [Hash] the event data payload
|
|
71
|
+
# @return [Enumerator, nil] Enumerator in no-block mode, nil in block mode
|
|
72
|
+
def run(session, prompt, event_map: {}, &block)
|
|
73
|
+
mapping = DEFAULT_EVENT_MAP.merge(event_map)
|
|
74
|
+
|
|
75
|
+
if block
|
|
76
|
+
run_with_block(session, prompt, mapping, &block)
|
|
77
|
+
nil
|
|
78
|
+
else
|
|
79
|
+
run_with_enumerator(session, prompt, mapping)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def run_with_block(session, prompt, mapping)
|
|
86
|
+
errors = []
|
|
87
|
+
|
|
88
|
+
session.on_event do |event|
|
|
89
|
+
type = event_type(event, mapping)
|
|
90
|
+
data = event_data(event)
|
|
91
|
+
next unless type
|
|
92
|
+
|
|
93
|
+
yield(type, data)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Emit start event
|
|
97
|
+
yield("start", { session_id: session.id })
|
|
98
|
+
|
|
99
|
+
session.run(prompt)
|
|
100
|
+
|
|
101
|
+
# If errors accumulated during tool execution, emit them
|
|
102
|
+
errors.each { |err| yield("error", { message: err }) }
|
|
103
|
+
rescue => e
|
|
104
|
+
yield("error", { message: e.message })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def run_with_enumerator(session, prompt, mapping)
|
|
108
|
+
Enumerator.new do |yielder|
|
|
109
|
+
errors = []
|
|
110
|
+
|
|
111
|
+
session.on_event do |event|
|
|
112
|
+
type = event_type(event, mapping)
|
|
113
|
+
data = event_data(event)
|
|
114
|
+
next unless type
|
|
115
|
+
|
|
116
|
+
yielder << sse_line(type, data)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Emit start event
|
|
120
|
+
yielder << sse_line("start", { session_id: session.id })
|
|
121
|
+
|
|
122
|
+
session.run(prompt)
|
|
123
|
+
|
|
124
|
+
errors.each { |err| yielder << sse_line("error", { message: err }) }
|
|
125
|
+
|
|
126
|
+
yielder << sse_line("close", {})
|
|
127
|
+
rescue => e
|
|
128
|
+
yielder << sse_line("error", { message: e.message })
|
|
129
|
+
ensure
|
|
130
|
+
yielder << sse_line("close", {})
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def event_type(event, mapping)
|
|
135
|
+
# Check for a direct class match
|
|
136
|
+
return mapping[event.class] if mapping.key?(event.class)
|
|
137
|
+
|
|
138
|
+
# Check for a superclass match (e.g. ToolExecutionStart is a kind of event)
|
|
139
|
+
event.class.ancestors.each do |ancestor|
|
|
140
|
+
return mapping[ancestor] if mapping.key?(ancestor)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def event_data(event)
|
|
147
|
+
case event
|
|
148
|
+
when Events::TextDelta
|
|
149
|
+
{ content: event.content }
|
|
150
|
+
when Events::ThinkingDelta
|
|
151
|
+
{ content: event.content }
|
|
152
|
+
when Events::ToolCallDelta
|
|
153
|
+
{ name: event.name, arguments: event.arguments, id: event.id }
|
|
154
|
+
when Events::ToolExecutionStart
|
|
155
|
+
{ name: event.name, id: event.id, args: safe_args(event.arguments) }
|
|
156
|
+
when Events::ToolExecutionUpdate
|
|
157
|
+
{ id: event.id, partial_result: event.partial_result.to_s.truncate(200) }
|
|
158
|
+
when Events::ToolExecutionEnd
|
|
159
|
+
{ name: event.name, id: event.id, duration_ms: event.duration_ms, is_error: event.is_error }
|
|
160
|
+
when Events::SessionEnd
|
|
161
|
+
{ turn_count: event.turn_count, tool_calls_made: event.tool_calls_made,
|
|
162
|
+
input_tokens: event.input_tokens, output_tokens: event.output_tokens,
|
|
163
|
+
cost: event.cost }
|
|
164
|
+
when Events::SessionStart
|
|
165
|
+
{}
|
|
166
|
+
when Events::Error
|
|
167
|
+
{ message: event.error, recoverable: event.recoverable }
|
|
168
|
+
else
|
|
169
|
+
{}
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def sse_line(type, data)
|
|
174
|
+
payload = data.merge(type: type)
|
|
175
|
+
"data: #{JSON.generate(payload)}\n\n"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def safe_args(args)
|
|
179
|
+
return {} unless args.is_a?(Hash)
|
|
180
|
+
|
|
181
|
+
safe = args.dup
|
|
182
|
+
%w[password secret token api_key key auth_token access_token sql command].each do |sensitive|
|
|
183
|
+
safe[sensitive] = "[REDACTED]" if safe.key?(sensitive)
|
|
184
|
+
end
|
|
185
|
+
safe
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Agent
|
|
5
|
+
# A tool that delegates a task to a specialized sub-agent.
|
|
6
|
+
#
|
|
7
|
+
# The coordinator agent sees this as a regular tool — when the LLM calls it,
|
|
8
|
+
# a fresh sub-agent session runs independently with its own model, tools,
|
|
9
|
+
# and instructions, and returns the result.
|
|
10
|
+
#
|
|
11
|
+
# Satisfies the tool duck type (name, description, params_schema, call)
|
|
12
|
+
# so it can be passed directly in the tools array.
|
|
13
|
+
#
|
|
14
|
+
# @example From a filesystem definition
|
|
15
|
+
# # agents/web_search/agent.rb defines a WebSearch agent.
|
|
16
|
+
# # Use it as a sub-agent by passing its name:
|
|
17
|
+
#
|
|
18
|
+
# search = Ask::Agent::SubAgent.new("web_search")
|
|
19
|
+
#
|
|
20
|
+
# coordinator = Ask::Agent::Session.new(
|
|
21
|
+
# model: "gpt-4o",
|
|
22
|
+
# tools: [search, Ask::Tools::Shell::Bash]
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# @example Inline configuration
|
|
26
|
+
# search = Ask::Agent::SubAgent.new(
|
|
27
|
+
# name: "web_search",
|
|
28
|
+
# description: "Search the web for current information",
|
|
29
|
+
# model: "gpt-4o-mini",
|
|
30
|
+
# tools: [Ask::Tools::WebSearch],
|
|
31
|
+
# system_prompt: "You are a research assistant."
|
|
32
|
+
# )
|
|
33
|
+
#
|
|
34
|
+
# @example Using with a different provider
|
|
35
|
+
# review = Ask::Agent::SubAgent.new(
|
|
36
|
+
# name: "code_review",
|
|
37
|
+
# model: "claude-sonnet-4",
|
|
38
|
+
# provider: :anthropic,
|
|
39
|
+
# tools: [Ask::Tools::Shell::Read, Ask::Tools::Shell::Grep],
|
|
40
|
+
# system_prompt: "You are a senior code reviewer."
|
|
41
|
+
# )
|
|
42
|
+
class SubAgent
|
|
43
|
+
# @return [String] tool name visible to the LLM
|
|
44
|
+
attr_reader :name
|
|
45
|
+
|
|
46
|
+
# @return [String] tool description visible to the LLM
|
|
47
|
+
attr_reader :description
|
|
48
|
+
|
|
49
|
+
# Create a new SubAgent tool.
|
|
50
|
+
#
|
|
51
|
+
# When given a String, looks up a filesystem agent definition by name
|
|
52
|
+
# (matching the convention used by {Ask::Agent.new}). Model, tools,
|
|
53
|
+
# instructions, and other settings are read from the definition files.
|
|
54
|
+
#
|
|
55
|
+
# When given keyword arguments, configures the sub-agent inline.
|
|
56
|
+
#
|
|
57
|
+
# @overload initialize(definition_name)
|
|
58
|
+
# @param definition_name [String] Name of a filesystem agent definition.
|
|
59
|
+
# @raise [Ask::Agent::UnknownAgent] If no definition is found.
|
|
60
|
+
#
|
|
61
|
+
# @overload initialize(name:, description: nil, model:, tools: [],
|
|
62
|
+
# system_prompt: nil, provider: nil, max_turns: 10, **session_opts)
|
|
63
|
+
# @param name [String] Tool name (e.g. "web_search").
|
|
64
|
+
# @param description [String, nil] Tool description. Auto-generated
|
|
65
|
+
# from the model and tools count if not provided.
|
|
66
|
+
# @param model [String] Model identifier for the sub-agent session.
|
|
67
|
+
# @param tools [Array<Class, Object>] Tools available to the sub-agent.
|
|
68
|
+
# @param system_prompt [String, nil] Instructions for the sub-agent.
|
|
69
|
+
# @param provider [Symbol, nil] Provider override.
|
|
70
|
+
# @param max_turns [Integer] Max conversation turns for the sub-agent.
|
|
71
|
+
# @param session_opts [Hash] Additional options forwarded to Session.new.
|
|
72
|
+
def initialize(definition_name = nil, name: nil, description: nil, model: nil,
|
|
73
|
+
tools: [], system_prompt: nil, provider: nil,
|
|
74
|
+
max_turns: 10, **session_opts)
|
|
75
|
+
if definition_name
|
|
76
|
+
from_definition(definition_name, **session_opts)
|
|
77
|
+
else
|
|
78
|
+
@name = name
|
|
79
|
+
@description = description || default_description(model, tools)
|
|
80
|
+
@model = model
|
|
81
|
+
@tools = tools.map { |t| t.is_a?(Class) ? t.new : t }
|
|
82
|
+
@system_prompt = system_prompt
|
|
83
|
+
@provider = provider
|
|
84
|
+
@max_turns = max_turns
|
|
85
|
+
@session_opts = session_opts
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# JSON Schema for the tool's parameter.
|
|
90
|
+
#
|
|
91
|
+
# @return [Hash]
|
|
92
|
+
def params_schema
|
|
93
|
+
{
|
|
94
|
+
type: "object",
|
|
95
|
+
properties: {
|
|
96
|
+
"task" => {
|
|
97
|
+
type: "string",
|
|
98
|
+
description: "The task to delegate to the sub-agent"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
required: ["task"],
|
|
102
|
+
additionalProperties: false
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Provider-specific parameters (none by default).
|
|
107
|
+
#
|
|
108
|
+
# @return [Hash]
|
|
109
|
+
def provider_params
|
|
110
|
+
{}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Execute the sub-agent with the given task.
|
|
114
|
+
#
|
|
115
|
+
# Creates a fresh session for each call, runs the task, and returns the
|
|
116
|
+
# result. If the sub-agent fails, returns an error result — the
|
|
117
|
+
# coordinator can decide how to proceed.
|
|
118
|
+
#
|
|
119
|
+
# @param args [Hash, String] Arguments from the LLM.
|
|
120
|
+
# @param abort_controller [Object, nil] Optional abort controller.
|
|
121
|
+
# @return [Ask::Result]
|
|
122
|
+
def call(args = {}, abort_controller = nil)
|
|
123
|
+
task = extract_task(args)
|
|
124
|
+
|
|
125
|
+
session_opts = {
|
|
126
|
+
model: @model,
|
|
127
|
+
tools: @tools.map(&:class),
|
|
128
|
+
max_turns: @max_turns
|
|
129
|
+
}
|
|
130
|
+
session_opts[:provider] = @provider if @provider
|
|
131
|
+
session_opts[:system_prompt] = @system_prompt if @system_prompt
|
|
132
|
+
session_opts.merge!(@session_opts)
|
|
133
|
+
|
|
134
|
+
session = Session.new(**session_opts)
|
|
135
|
+
result = session.run(task.to_s)
|
|
136
|
+
Ask::Result.ok(data: result.to_s)
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
Ask::Result.failure("SubAgent '#{@name}' error: #{e.message}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Human-readable representation.
|
|
142
|
+
#
|
|
143
|
+
# @return [String]
|
|
144
|
+
def inspect
|
|
145
|
+
"#<Ask::Agent::SubAgent name=#{@name.inspect}>"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def from_definition(definition_name, **session_opts)
|
|
151
|
+
Ask::Agent.rediscover!
|
|
152
|
+
entry = Ask::Agent.definitions[definition_name.to_s]
|
|
153
|
+
raise UnknownAgent, "Unknown agent: #{definition_name.inspect}" unless entry
|
|
154
|
+
|
|
155
|
+
klass, dir = entry
|
|
156
|
+
config = klass._config
|
|
157
|
+
|
|
158
|
+
@name = definition_name
|
|
159
|
+
@model = config[:model]
|
|
160
|
+
@description = "Delegate to #{definition_name} sub-agent (#{@model})"
|
|
161
|
+
@provider = config[:provider]
|
|
162
|
+
|
|
163
|
+
# Resolve tools from definition
|
|
164
|
+
resolved_tools = Ask::Agent.__send__(:resolve_definition_tools, config[:tools] || [], dir)
|
|
165
|
+
@tools = resolved_tools.map { |t| t.is_a?(Class) ? t.new : t }
|
|
166
|
+
|
|
167
|
+
# Load instructions from definition
|
|
168
|
+
prompt = klass.instructions_content
|
|
169
|
+
@system_prompt = prompt
|
|
170
|
+
|
|
171
|
+
@max_turns = config[:max_turns] || 10
|
|
172
|
+
# Merge any options from the definition config
|
|
173
|
+
@session_opts = (config[:options] || {}).merge(session_opts)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def extract_task(args)
|
|
177
|
+
case args
|
|
178
|
+
when Hash then (args["task"] || args[:task] || args.to_s).to_s
|
|
179
|
+
else args.to_s
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def default_description(model, tools)
|
|
184
|
+
desc = "Delegate to a sub-agent (#{model}"
|
|
185
|
+
desc += " with #{tools.size} tool(s)" if tools.any?
|
|
186
|
+
desc + ")"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
data/lib/ask/agent/version.rb
CHANGED
data/lib/ask/agent.rb
CHANGED
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
require "ask/skills"
|
|
8
8
|
require "ask-llm-providers"
|
|
9
9
|
require "ask-tools"
|
|
10
|
+
require "ask-state-providers"
|
|
10
11
|
|
|
11
12
|
module Ask
|
|
12
13
|
module Agent
|
|
@@ -244,6 +245,29 @@ require_relative "agent/skills/load_skill_tool"
|
|
|
244
245
|
require_relative "agent/scheduler"
|
|
245
246
|
require_relative "agent/definition"
|
|
246
247
|
require_relative "agent/cli"
|
|
248
|
+
require_relative "agent/streaming"
|
|
249
|
+
require_relative "agent/sub_agent"
|
|
247
250
|
|
|
248
251
|
# Test helpers (loaded on demand)
|
|
249
252
|
autoload :Test, "ask/agent/test"
|
|
253
|
+
|
|
254
|
+
# Convenience method on the top-level Ask module.
|
|
255
|
+
# Provides a quick one-shot chat without instantiating a Session directly.
|
|
256
|
+
#
|
|
257
|
+
# Ask.chat("Hello")
|
|
258
|
+
# Ask.chat("Tell me about X", model: "gpt-4o")
|
|
259
|
+
# Ask.chat("Stream this") { |chunk| puts chunk.content }
|
|
260
|
+
#
|
|
261
|
+
module Ask
|
|
262
|
+
def self.chat(message, model: nil, system_prompt: nil, &block)
|
|
263
|
+
session = Agent::Session.new(
|
|
264
|
+
model: model || Agent.configuration.default_model,
|
|
265
|
+
system_prompt: system_prompt
|
|
266
|
+
)
|
|
267
|
+
if block
|
|
268
|
+
session.run(message, &block)
|
|
269
|
+
else
|
|
270
|
+
session.run(message)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
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.
|
|
4
|
+
version: 0.23.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -174,6 +174,7 @@ files:
|
|
|
174
174
|
- lib/ask/agent/evaluator.rb
|
|
175
175
|
- lib/ask/agent/events.rb
|
|
176
176
|
- lib/ask/agent/extensions/audit_log.rb
|
|
177
|
+
- lib/ask/agent/extensions/audit_log/active_record_writer.rb
|
|
177
178
|
- lib/ask/agent/extensions/permissions.rb
|
|
178
179
|
- lib/ask/agent/extensions/rate_limiter.rb
|
|
179
180
|
- lib/ask/agent/hooks.rb
|
|
@@ -196,6 +197,8 @@ files:
|
|
|
196
197
|
- lib/ask/agent/stream_transforms/pipeline.rb
|
|
197
198
|
- lib/ask/agent/stream_transforms/text_buffer.rb
|
|
198
199
|
- lib/ask/agent/stream_transforms/thinking_separator.rb
|
|
200
|
+
- lib/ask/agent/streaming.rb
|
|
201
|
+
- lib/ask/agent/sub_agent.rb
|
|
199
202
|
- lib/ask/agent/system_context.rb
|
|
200
203
|
- lib/ask/agent/telemetry.rb
|
|
201
204
|
- lib/ask/agent/test.rb
|