ask-agent 0.1.0 → 0.1.1

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: 3a067aefe3faa67280e7ba4cde37994b52f27d5e665e86510e16ee8acf594f80
4
- data.tar.gz: 4d059bcc2cf98b1fa159c4ab7eba622ba1e529063b7d517343fd3a4f59b97180
3
+ metadata.gz: e875e155f72f3bc773ff218b64243ac918354fac930b0181e02ae1a74d0bc4eb
4
+ data.tar.gz: 9ec62c98c64b7307f445311cce0f6fd6bf81a9f7fde52daa505cd532d4734520
5
5
  SHA512:
6
- metadata.gz: 4746d44442fbf8bdf9f67b325914cc57401857151caa2e56d5930d2134e19f03ae18d1064039e414e343539efa4c9c6be26ed2badd59898e3c5deb176f7bebe0
7
- data.tar.gz: 1c8319c4cb60213a2de06b7cba0fc8443b217918586559af1c0a61e212d4cd30a58eb1d05784d4a5aca0737af65740dd4042285a8b1dc0976d4957cc426b0a5f
6
+ metadata.gz: 14d6490ab16f5e675e05ac3e72f37c113edcfd7e7339c10fe6a73290b88e4cab0ab8535d21860e51d09aac36c7a7066b3dbfe1f19b04d5c873a301094fb610fe
7
+ data.tar.gz: 729b178fb7f474e2fe5c84ce692169ba184fefe1b761d7ddc2a1dfaf39c57d184155f9f67e186bd91b13faaab05a5e79aac49107055d0089269ca10e9e100f94
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ # Response message returned by {Chat#ask}.
6
+ # Presents a message-like response interface for ask-agent internal use.
7
+ ResponseMessage = Data.define(:content, :tool_calls, :thinking) do
8
+ def tool_call? = !tool_calls.empty?
9
+ def to_s = content.to_s
10
+ end
11
+
12
+ # Tool call data used in {ResponseMessage} and {ChatChunk}.
13
+ ToolCallInfo = Data.define(:id, :name, :arguments)
14
+
15
+ # Chunk yielded during streaming from {Chat#ask}.
16
+ ChatChunk = Data.define(:content, :tool_calls, :thinking) do
17
+ def tool_call? = !tool_calls.empty?
18
+ end
19
+
20
+ # Thin wrapper around {Ask::Provider} + an internal message array that
21
+ # presents a Chat-like API for ask-agent internal use.
22
+ #
23
+ # Manages conversation history, resolves the correct provider/model,
24
+ # handles streaming chunk accumulation, and normalises tool call
25
+ # formats between Ask::Provider (Array of Hashes) and ask-agent
26
+ # internal usage (Hash of { id => ToolCallInfo }).
27
+ class Chat
28
+ # @return [String] model ID (e.g. "gpt-4o")
29
+ attr_reader :model
30
+
31
+ # @return [Array<Ask::Message>] all messages in the conversation
32
+ attr_reader :messages
33
+
34
+ # @param model [String, #ask] model ID or chat-like object
35
+ # @param tools [Array<Ask::Tool>] tool instances available to the chat
36
+ # @param temperature [Float, nil] sampling temperature
37
+ # @param schema [Ask::Schema, Hash, nil] structured output schema
38
+ def initialize(model:, tools: [], temperature: nil, schema: nil, **)
39
+ @model_id = model.respond_to?(:id) ? model.id : model.to_s
40
+ @model_info = resolve_model(@model_id)
41
+ @tools = tools
42
+ @temperature = temperature
43
+ @schema = schema
44
+ @messages = []
45
+ @provider = nil
46
+ end
47
+
48
+ # Send a user message and get a completion response.
49
+ #
50
+ # @param message [String, nil] user message text
51
+ # @yield [ChatChunk] streaming chunks (only when a block is given)
52
+ # @return [ResponseMessage] the assistant's response
53
+ def ask(message = nil, &block)
54
+ @messages << Ask::Message.new(role: :user, content: message.to_s) if message
55
+
56
+ stream = block_given?
57
+ tool_defs = @tools.map { |t| Ask::ToolDef.from_tool(t) }
58
+
59
+ # Accumulator for tool calls during streaming (keyed by index)
60
+ calls_acc = {}
61
+
62
+ result = provider.chat(
63
+ @messages.map(&:to_h),
64
+ model: @model_id,
65
+ tools: tool_defs,
66
+ temperature: @temperature,
67
+ stream: stream,
68
+ schema: @schema&.respond_to?(:to_json_schema) ? @schema.to_json_schema : @schema
69
+ ) do |raw_chunk|
70
+ next unless block_given?
71
+
72
+ # Accumulate tool calls by index during streaming
73
+ accumulate_tool_calls(raw_chunk, calls_acc)
74
+
75
+ # Yield adapted chunk with current tool call state
76
+ yield ChatChunk.new(
77
+ content: raw_chunk.content,
78
+ tool_calls: build_current_tool_calls(calls_acc),
79
+ thinking: raw_chunk.respond_to?(:thinking) ? raw_chunk.thinking : nil
80
+ )
81
+ end
82
+
83
+ response_msg = if stream
84
+ build_stream_response(result, calls_acc)
85
+ else
86
+ build_response(result)
87
+ end
88
+
89
+ # Store assistant response in conversation history
90
+ @messages << Ask::Message.new(
91
+ role: :assistant,
92
+ content: response_msg.content,
93
+ tool_calls: response_msg.tool_calls&.values&.map { |tc|
94
+ { id: tc.id, type: "function", name: tc.name, arguments: tc.arguments }
95
+ }
96
+ )
97
+
98
+ response_msg
99
+ end
100
+
101
+ # Add a message to the conversation history.
102
+ #
103
+ # @param role [Symbol] :system, :user, :assistant, :tool
104
+ # @param content [String, nil] message content
105
+ # @param tool_call_id [String, nil] tool call ID (for tool results)
106
+ # @param tool_calls [Array<Hash>, nil] tool call invocations
107
+ def add_message(role:, content: nil, tool_call_id: nil, tool_calls: nil)
108
+ @messages << Ask::Message.new(
109
+ role: role,
110
+ content: content,
111
+ tool_call_id: tool_call_id,
112
+ tool_calls: tool_calls
113
+ )
114
+ end
115
+
116
+ # Set or replace the system prompt.
117
+ #
118
+ # @param prompt [String] system instructions
119
+ # @return [self]
120
+ def with_instructions(prompt)
121
+ @messages.reject! { |m| m.role == :system }
122
+ @messages.unshift(Ask::Message.new(role: :system, content: prompt))
123
+ self
124
+ end
125
+
126
+ # Clear all messages from the conversation.
127
+ def reset_messages!
128
+ @messages.clear
129
+ end
130
+
131
+ private
132
+
133
+ # Resolve model info from the catalog.
134
+ def resolve_model(model_id)
135
+ Ask::ModelCatalog.find(model_id)
136
+ rescue Ask::ModelNotFound
137
+ nil
138
+ end
139
+
140
+ # Lazily resolve and instantiate the LLM provider.
141
+ def provider
142
+ @provider ||= build_provider
143
+ end
144
+
145
+ def build_provider
146
+ slug = @model_info&.provider || "openai"
147
+ klass = Ask::Provider.resolve(slug)
148
+ klass.new(provider_config(slug))
149
+ end
150
+
151
+ def provider_config(slug, extra_keys: {})
152
+ env_key = "#{slug.upcase}_API_KEY"
153
+ key = ENV[env_key] || ENV["OPENCODE_API_KEY"] || ENV["OPENAI_API_KEY"]
154
+ base = ENV["#{slug.upcase}_API_BASE"] || ENV["OPENCODE_API_BASE"]
155
+ config = { api_key: key }
156
+ config[:"#{slug}_api_key"] = key
157
+ config[:"#{slug}_api_base"] = base if base
158
+ Ask::LLM::Config.new(config)
159
+ end
160
+
161
+ # Accumulate partial tool calls from streaming chunks.
162
+ def accumulate_tool_calls(raw_chunk, calls_acc)
163
+ return unless raw_chunk.tool_call?
164
+
165
+ raw_chunk.tool_calls.each do |tc|
166
+ idx = tc[:index] || 0
167
+ calls_acc[idx] ||= { id: tc[:id], name: tc[:name], arguments: +"" }
168
+ calls_acc[idx][:id] ||= tc[:id]
169
+ calls_acc[idx][:name] ||= tc[:name]
170
+ calls_acc[idx][:arguments] << tc[:arguments].to_s if tc[:arguments]
171
+ end
172
+ end
173
+
174
+ # Build current snapshot of tool calls from accumulator.
175
+ def build_current_tool_calls(calls_acc)
176
+ hash = {}
177
+ calls_acc.each_value do |tc_data|
178
+ next unless tc_data[:id]
179
+ hash[tc_data[:id]] = ToolCallInfo.new(
180
+ id: tc_data[:id],
181
+ name: tc_data[:name] || "",
182
+ arguments: tc_data[:arguments]
183
+ )
184
+ end
185
+ hash
186
+ end
187
+
188
+ # Convert Ask::Provider tool_calls (Array of Hashes) to Hash.
189
+ def build_tool_call_hash(raw_calls)
190
+ hash = {}
191
+ raw_calls.each do |tc|
192
+ id = tc[:id] || tc["id"]
193
+ next unless id
194
+ hash[id] = ToolCallInfo.new(
195
+ id: id,
196
+ name: tc[:name] || tc["name"] || "",
197
+ arguments: tc[:arguments] || tc["arguments"] || ""
198
+ )
199
+ end
200
+ hash
201
+ end
202
+
203
+ # Build response from streaming result.
204
+ def build_stream_response(stream, calls_acc)
205
+ thinking = stream.chunks.filter_map(&:thinking).last
206
+ ResponseMessage.new(
207
+ content: stream.accumulated_text,
208
+ tool_calls: build_current_tool_calls(calls_acc),
209
+ thinking: thinking
210
+ )
211
+ end
212
+
213
+ # Build response from non-streaming result.
214
+ def build_response(msg)
215
+ tool_calls = msg.tool_calls ? build_tool_call_hash(msg.tool_calls) : {}
216
+ thinking = msg.respond_to?(:thinking) ? msg.thinking : nil
217
+ ResponseMessage.new(content: msg.content.to_s, tool_calls: tool_calls, thinking: thinking)
218
+ end
219
+ end
220
+ end
221
+ end
@@ -138,8 +138,8 @@ module Ask
138
138
 
139
139
  def build_llm_chat
140
140
  if @llm.respond_to?(:ask) then @llm
141
- elsif @llm.is_a?(String) then RubyLLM::Chat.new(model: @llm)
142
- else RubyLLM::Chat.new end
141
+ elsif @llm.is_a?(String) then Ask::Agent::Chat.new(model: @llm)
142
+ else Ask::Agent::Chat.new(model: Ask::Agent.configuration.default_model) end
143
143
  end
144
144
 
145
145
  def extract_summary
@@ -100,7 +100,7 @@ module Ask
100
100
 
101
101
  def call_llm_for_analysis(telemetry_data, existing_recommendations, resolved_ids)
102
102
  prompt = build_analysis_prompt(telemetry_data, existing_recommendations, resolved_ids)
103
- chat = RubyLLM::Chat.new(model: @model, **@chat_options)
103
+ chat = Ask::Agent::Chat.new(model: @model, **@chat_options)
104
104
  response = chat.ask(prompt)
105
105
  parse_llm_response(response.content.to_s)
106
106
  rescue => e
@@ -66,12 +66,12 @@ module Ask
66
66
 
67
67
  def build_eval_chat
68
68
  model_id = model_id_from(@model)
69
- RubyLLM::Chat.new(model: model_id)
69
+ Ask::Agent::Chat.new(model: model_id)
70
70
  end
71
71
 
72
72
  def model_id_from(model)
73
73
  case model
74
- when RubyLLM::Chat then model.model.respond_to?(:id) ? model.model.id : model.model.to_s
74
+ when Ask::Agent::Chat then model.model.to_s
75
75
  when String then model
76
76
  else model.to_s
77
77
  end
@@ -35,7 +35,6 @@ module Ask
35
35
 
36
36
  @chat = build_chat(model, system_prompt, tools, **chat_options)
37
37
  @tools = resolve_tools(tools)
38
- register_tools_on_chat
39
38
  @loop = Loop.new(max_turns: max_turns)
40
39
  @tool_executor = ToolExecutor.new(max_retries: max_tool_retries, parallel: parallel_tools)
41
40
  @compactor = compactor ? build_compactor(compactor) : nil
@@ -96,7 +95,7 @@ module Ask
96
95
  emit(Events::LoopDetected.new(tool_name: e.message, repeated_count: 3))
97
96
  @telemetry.log(:loop_detected, session_id: @id, tool_name: e.message, repeated_count: 3)
98
97
  response = last_content
99
- rescue RubyLLM::ContextLengthExceededError
98
+ rescue Ask::ContextLengthExceeded
100
99
  if @compactor && !@compactor.overflow_recovered?
101
100
  @compactor.recover_from_overflow
102
101
  retry
@@ -210,20 +209,11 @@ module Ask
210
209
 
211
210
  private
212
211
 
213
- def register_tools_on_chat
214
- return unless @tools.any?
215
-
216
- def @chat.handle_tool_calls(response, &)
217
- @on[:end_message]&.call(response) if @on[:end_message]
218
- response
219
- end
220
- end
221
-
222
212
  def build_chat(model, system_prompt, tools, **chat_options)
223
213
  if model.respond_to?(:ask)
224
214
  model
225
215
  else
226
- chat = RubyLLM::Chat.new(model: model, **chat_options)
216
+ chat = Ask::Agent::Chat.new(model: model, tools: tools, **chat_options)
227
217
  chat.with_instructions(system_prompt) if system_prompt
228
218
  chat
229
219
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Agent
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.1"
6
6
  end
7
7
  end
data/lib/ask/agent.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ruby_llm"
4
3
  require "fileutils"
5
4
  require "json"
6
5
  require "securerandom"
@@ -41,6 +40,7 @@ end
41
40
 
42
41
  require_relative "agent/version"
43
42
  require_relative "agent/events"
43
+ require_relative "agent/chat"
44
44
  require_relative "agent/telemetry"
45
45
  require_relative "agent/tool_abort_controller"
46
46
  require_relative "agent/session"
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.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -10,7 +10,7 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: ask-tools
13
+ name: ask-core
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
@@ -24,7 +24,7 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.1'
26
26
  - !ruby/object:Gem::Dependency
27
- name: ask-tools-shell
27
+ name: ask-llm-providers
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
@@ -38,19 +38,33 @@ dependencies:
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.1'
40
40
  - !ruby/object:Gem::Dependency
41
- name: ruby_llm
41
+ name: ask-tools
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - ">="
44
+ - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '1.14'
47
- type: :development
46
+ version: '0.1'
47
+ type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
- - - ">="
51
+ - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '1.14'
53
+ version: '0.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ask-tools-shell
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.1'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: minitest
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -105,6 +119,7 @@ files:
105
119
  - README.md
106
120
  - lib/ask-agent.rb
107
121
  - lib/ask/agent.rb
122
+ - lib/ask/agent/chat.rb
108
123
  - lib/ask/agent/compactor.rb
109
124
  - lib/ask/agent/configuration.rb
110
125
  - lib/ask/agent/events.rb