ask-agent 0.15.0 → 0.20.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: 44a7d741bb255935ebfa4790a7da3d36dc4b4200ab8ebd1e061f8d31b194c798
4
+ data.tar.gz: ad33f40cffc85328e4cc753291db6fa5730fd7edffc52fcf4f96b34eade7a863
5
5
  SHA512:
6
- metadata.gz: 8de0c033834105f7c147115bd84b97306fc91b787690fc9e82563835f53b8fad69ff90dfabeeada12be9a8651e9ecfffd960d8e2fa6e84ffff4709f30519a5de
7
- data.tar.gz: b5d46db9e66b242ea35fafaf86c67401ad27ee8ef0099ad4811a3d8442e63bd54aa2b16be5e4a7b2aa0f7305603cf39d1b5497e66648f59a9d701489bbdc6c8c
6
+ metadata.gz: 3e8c63b1465e8090d050e92b03773ec1a38caf9b2c4b22abbb08f5b19007e50317d5befa058343ea6c08e11450ad0a5c5c2f99609ef64d5f00ef9855893f5086
7
+ data.tar.gz: 02e39e5fdb5ecdcd91dc57572a0594d90155534ed7dc282b1029a4041fe7943b94e45c5066e1c86c75b1cb0efb7824a5eb522dcd650c81ad41cfd5177dfafc98
data/CHANGELOG.md CHANGED
@@ -1,4 +1,69 @@
1
- ## [0.15.0] — 2026-07-24
1
+ ## [0.19.0] — 2026-07-26
2
+
3
+ ### Added
4
+
5
+ - **`Ask::Agent::SubAgent.new("definition_name")` — create sub-agents from
6
+ filesystem definitions**. Passing a string looks up an agent definition
7
+ by name (same convention as `Ask::Agent.new("name")`), reading model,
8
+ tools, instructions, and other settings from the definition files.
9
+
10
+ ```ruby
11
+ # agents/web_search/agent.rb defines model, tools, instructions
12
+ search = Ask::Agent::SubAgent.new("web_search")
13
+
14
+ coordinator = Ask::Agent::Session.new(
15
+ model: "gpt-4o",
16
+ tools: [search, Ask::Tools::Shell::Bash]
17
+ )
18
+ ```
19
+
20
+ - **VCR-based integration tests** for SubAgent. Real API calls are recorded
21
+ and replayed via VCR cassettes. Run with `OPENAI_API_KEY` set to record,
22
+ or without to replay existing cassettes.
23
+
24
+ ### Changed
25
+
26
+ - `Ask::Agent::SubAgent.new(name:, ...)` now supports `provider:` parameter
27
+ for provider-specific sub-agents.
28
+
29
+ ## [0.18.0] — 2026-07-26
30
+
31
+ ### Added
32
+
33
+ - **`Ask::Agent::SubAgent` — delegate tasks to a specialized sub-agent tool**.
34
+ A self-contained tool class that satisfies the tool duck type (`name`,
35
+ `description`, `params_schema`, `call`). When the coordinator LLM calls it,
36
+ a fresh sub-agent session runs independently with its own model, tools,
37
+ and instructions.
38
+
39
+ ```ruby
40
+ search = Ask::Agent::SubAgent.new(
41
+ name: "web_search",
42
+ description: "Search the web for current information",
43
+ model: "gpt-4o-mini",
44
+ tools: [MyApp::Tools::WebSearch],
45
+ system_prompt: "You are a research assistant."
46
+ )
47
+
48
+ coordinator = Ask::Agent::Session.new(
49
+ model: "gpt-4o",
50
+ tools: [search, Ask::Tools::Shell::Bash]
51
+ )
52
+
53
+ coordinator.run("What's the latest Rails release and how stable is it?")
54
+ ```
55
+
56
+ ### Removed
57
+
58
+ - **`Ask::Agent.sub_agent_tool`** factory method — replaced by the
59
+ `Ask::Agent::SubAgent` class directly. The class IS the tool, no
60
+ factory or wrapper needed.
61
+
62
+ ## [0.17.0] — 2026-07-26
63
+
64
+ ### Added
65
+
66
+ - Bump ask-tools dependency for `Ask::Tools::SubAgent` support
2
67
 
3
68
  ### Added
4
69
 
@@ -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
 
@@ -27,6 +27,10 @@ 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
35
  chunk.tool_calls.each do |id, tc|
32
36
  event_emitter.emit(Events::ToolCallDelta.new(
@@ -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.20.0"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -244,6 +244,29 @@ require_relative "agent/skills/load_skill_tool"
244
244
  require_relative "agent/scheduler"
245
245
  require_relative "agent/definition"
246
246
  require_relative "agent/cli"
247
+ require_relative "agent/streaming"
248
+ require_relative "agent/sub_agent"
247
249
 
248
250
  # Test helpers (loaded on demand)
249
251
  autoload :Test, "ask/agent/test"
252
+
253
+ # Convenience method on the top-level Ask module.
254
+ # Provides a quick one-shot chat without instantiating a Session directly.
255
+ #
256
+ # Ask.chat("Hello")
257
+ # Ask.chat("Tell me about X", model: "gpt-4o")
258
+ # Ask.chat("Stream this") { |chunk| puts chunk.content }
259
+ #
260
+ module Ask
261
+ def self.chat(message, model: nil, system_prompt: nil, &block)
262
+ session = Agent::Session.new(
263
+ model: model || Agent.configuration.default_model,
264
+ system_prompt: system_prompt
265
+ )
266
+ if block
267
+ session.run(message, &block)
268
+ else
269
+ session.run(message)
270
+ end
271
+ end
272
+ 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.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -196,6 +196,8 @@ files:
196
196
  - lib/ask/agent/stream_transforms/pipeline.rb
197
197
  - lib/ask/agent/stream_transforms/text_buffer.rb
198
198
  - lib/ask/agent/stream_transforms/thinking_separator.rb
199
+ - lib/ask/agent/streaming.rb
200
+ - lib/ask/agent/sub_agent.rb
199
201
  - lib/ask/agent/system_context.rb
200
202
  - lib/ask/agent/telemetry.rb
201
203
  - lib/ask/agent/test.rb