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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 816f21ed178b31227a58afeeed65f2428dbe9cce7d4a6ddab2c6fda7a6a60a89
4
- data.tar.gz: 4dec09ce3cc94c6abc0e94bd5dee9f6ac9bde083dc7995af601a6650e692fd37
3
+ metadata.gz: cf7561a6eebc134d4e84ba829e637638a783eb7dd425c1b1c24d2d879e5ff47e
4
+ data.tar.gz: 73cdf03eec1b27f3e50cbfa28399bdf17994ea1b5925be36ce463ebf48c6669d
5
5
  SHA512:
6
- metadata.gz: 8de0c033834105f7c147115bd84b97306fc91b787690fc9e82563835f53b8fad69ff90dfabeeada12be9a8651e9ecfffd960d8e2fa6e84ffff4709f30519a5de
7
- data.tar.gz: b5d46db9e66b242ea35fafaf86c67401ad27ee8ef0099ad4811a3d8442e63bd54aa2b16be5e4a7b2aa0f7305603cf39d1b5497e66648f59a9d701489bbdc6c8c
6
+ metadata.gz: 37b261bba11ac89e4051b2f631305ed6741ae8ba6a18f0973a2fa7a04416d3dd84d2ca9e4517d01f4776a21fbf143125a51bcceb74b67ce361d9c4ea35e15279
7
+ data.tar.gz: bc3051e56b9b68a003da46e1ee4c4ec10265239bfc2482c6cc335caf510f099256e1487526ead9277687004a0bb4c68926efb9f51db338b103f69188f21ed60a
data/CHANGELOG.md CHANGED
@@ -1,4 +1,147 @@
1
- ## [0.15.0] — 2026-07-24
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
@@ -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.each do |tc|
259
- idx = tc[:index] || 0
260
- calls_acc[idx] ||= { id: tc[:id], name: tc[:name], arguments: +"" }
261
- calls_acc[idx][:id] ||= tc[:id]
262
- calls_acc[idx][:name] ||= tc[:name]
263
- calls_acc[idx][:arguments] << tc[:arguments].to_s if tc[:arguments]
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
- id = tc[:id] || tc["id"]
284
- next unless id
285
- hash[id] = ToolCallInfo.new(
286
- id: id,
287
- name: tc[:name] || tc["name"] || "",
288
- arguments: tc[:arguments] || tc["arguments"] || ""
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).last,
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, :default_max_turns, :compactor_enabled,
7
- :compactor_threshold, :parallel_tool_execution, :max_tool_retries,
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
@@ -11,6 +11,7 @@ module Ask
11
11
 
12
12
  MessageStart = Data.define
13
13
  TextDelta = Data.define(:content)
14
+ ThinkingDelta = Data.define(:content)
14
15
  ToolCallDelta = Data.define(:name, :arguments, :id)
15
16
  MessageEnd = Data.define(:tool_calls)
16
17
 
@@ -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
- def initialize(output: $stdout, path: nil)
8
- @entries = []
9
- @mutex = Mutex.new
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
- if path
12
- @io = File.open(path, "a")
13
- @io.sync = true
14
- else
15
- @io = output
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
- entry = {
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
- duration: result[:duration_ms]
26
- }
82
+ result: result&.to_s&.to_s[0, 500],
83
+ duration_ms: result[:duration_ms]
84
+ })
85
+ end
27
86
 
28
- @mutex.synchronize do
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 entries
37
- @mutex.synchronize { @entries.dup }
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
@@ -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.each do |id, tc|
32
- event_emitter.emit(Events::ToolCallDelta.new(
33
- name: tc.name, arguments: tc.arguments, id: tc.id
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
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.15.0"
5
+ VERSION = "0.23.0"
6
6
  end
7
7
  end
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.15.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