ask-agent 0.1.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.
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "json"
5
+
6
+ module Ask
7
+ module Agent
8
+ class MetaAgent
9
+ Result = Data.define(:issue, :file, :line, :confidence, :suggestion, :evidence, :meta_pr, :recommendation_id, :suggested_code)
10
+
11
+ def initialize(telemetry: nil, model: nil, **chat_options)
12
+ @telemetry = telemetry || Telemetry.new
13
+ @model = model || Ask::Agent.configuration.default_model
14
+ @chat_options = chat_options
15
+ @agent_source = nil
16
+ end
17
+
18
+ def analyze(error_threshold: 3, loop_threshold: 2, max_turns_threshold: 2)
19
+ load_source
20
+ telemetry_data = @telemetry.read
21
+ resolved_ids = resolved_recommendation_ids
22
+ recommendations = @telemetry.read_recommendations(status: "open")
23
+
24
+ unless has_data?(telemetry_data, error_threshold, loop_threshold, max_turns_threshold)
25
+ return []
26
+ end
27
+
28
+ results = call_llm_for_analysis(telemetry_data, recommendations, resolved_ids)
29
+ return [] if results.empty?
30
+
31
+ results.each do |r|
32
+ r[:recommendation_id] = @telemetry.track_recommendation(build_result(r))
33
+ end
34
+
35
+ results.map { |r| build_result(r) }
36
+ end
37
+
38
+ def generate_report(results = nil)
39
+ results ||= analyze
40
+ return "No improvement opportunities found." if results.empty?
41
+
42
+ lines = []
43
+ lines << "# Agent Self-Improvement Report"
44
+ lines << "*Generated: #{Time.now.utc.iso8601}*"
45
+ lines << ""
46
+
47
+ results.each_with_index do |result, i|
48
+ lines << "## #{i + 1}. #{result.issue}"
49
+ lines << ""
50
+ lines << "| | |"
51
+ lines << "|---|---|"
52
+ lines << "| **File** | `#{result.file}:#{result.line}` |"
53
+ lines << "| **Confidence** | #{result.confidence} |"
54
+ lines << "| **Recommendation ID** | `#{result.recommendation_id}` |"
55
+ lines << ""
56
+ if result.suggested_code
57
+ lines << "### Suggested Code"
58
+ lines << ""
59
+ lines << "```ruby"
60
+ lines << result.suggested_code
61
+ lines << "```"
62
+ end
63
+ lines << "---"
64
+ end
65
+
66
+ lines.join("\n")
67
+ end
68
+
69
+ def track_resolution(recommendation_id)
70
+ return false unless recommendation_id
71
+ @telemetry.track_resolution(recommendation_id)
72
+ end
73
+
74
+ def auto_resolve!
75
+ load_source
76
+ count = 0
77
+
78
+ @telemetry.read_recommendations(status: "open").each do |rec|
79
+ source = @agent_source[rec["file"]]
80
+ next unless source && rec["suggested_code"]
81
+ if source.include?(rec["suggested_code"].strip)
82
+ @telemetry.track_resolution(rec["recommendation_id"])
83
+ count += 1
84
+ end
85
+ end
86
+ count
87
+ end
88
+
89
+ private
90
+
91
+ def has_data?(telemetry_data, error_threshold, loop_threshold, max_turns_threshold)
92
+ telemetry_data["tool_error"]&.size.to_i >= error_threshold ||
93
+ telemetry_data["loop_detected"]&.size.to_i >= loop_threshold ||
94
+ telemetry_data["max_turns_exceeded"]&.size.to_i >= max_turns_threshold
95
+ end
96
+
97
+ def resolved_recommendation_ids
98
+ @telemetry.read_recommendations(status: "resolved").map { |r| r["recommendation_id"] }
99
+ end
100
+
101
+ def call_llm_for_analysis(telemetry_data, existing_recommendations, resolved_ids)
102
+ prompt = build_analysis_prompt(telemetry_data, existing_recommendations, resolved_ids)
103
+ chat = RubyLLM::Chat.new(model: @model, **@chat_options)
104
+ response = chat.ask(prompt)
105
+ parse_llm_response(response.content.to_s)
106
+ rescue => e
107
+ warn "[MetaAgent] LLM analysis failed: #{e.class}: #{e.message}"
108
+ []
109
+ end
110
+
111
+ def build_analysis_prompt(telemetry_data, existing_recommendations, resolved_ids)
112
+ parts = []
113
+ parts << "You are analyzing telemetry from the Agent runtime."
114
+ parts << "Detect patterns in errors and suggest specific code improvements."
115
+ parts << ""
116
+
117
+ tool_errors = telemetry_data["tool_error"] || []
118
+ loop_events = telemetry_data["loop_detected"] || []
119
+ max_turn_events = telemetry_data["max_turns_exceeded"] || []
120
+
121
+ parts << "=== Telemetry Summary ==="
122
+ parts << "Tool errors: #{tool_errors.size} total"
123
+ unless tool_errors.empty?
124
+ by_error = tool_errors.group_by { |e| [e.dig("details", "tool_name"), e.dig("details", "error_class")] }
125
+ by_error.each do |(tool_name, error_class), entries|
126
+ parts << " - `#{tool_name}` raised `#{error_class}` (#{entries.size}x)"
127
+ end
128
+ end
129
+ parts << "Loop detections: #{loop_events.size} total"
130
+ parts << "Max turns exceeded: #{max_turn_events.size} total"
131
+ parts << ""
132
+ parts << "=== Instructions ==="
133
+ parts << 'Respond with a JSON array only: [{"issue": "...", "confidence": "high", "file": "...", "line": 0, "suggestion": "...", "suggested_code": ""}]'
134
+ parts << 'If no issues, respond with []'
135
+
136
+ parts.join("\n")
137
+ end
138
+
139
+ def parse_llm_response(text)
140
+ text = text.strip
141
+ text = text[/\[.*\]/m] || text
142
+ JSON.parse(text)
143
+ rescue JSON::ParserError
144
+ []
145
+ end
146
+
147
+ def build_result(r)
148
+ Result.new(
149
+ issue: r[:issue] || r["issue"],
150
+ file: r[:file] || r["file"],
151
+ line: (r[:line] || r["line"]).to_i,
152
+ confidence: r[:confidence] || r["confidence"],
153
+ suggestion: r[:suggestion] || r["suggestion"],
154
+ evidence: r[:evidence] || r["evidence"] || [],
155
+ meta_pr: r[:meta_pr] || r["meta_pr"],
156
+ recommendation_id: r[:recommendation_id] || r["recommendation_id"],
157
+ suggested_code: r[:suggested_code] || r["suggested_code"]
158
+ )
159
+ end
160
+
161
+ def load_source
162
+ return @agent_source if @agent_source
163
+ source = {}
164
+ Dir[File.expand_path("../**/*.rb", __dir__)].each do |file|
165
+ rel = file.sub("#{File.expand_path("../..", __dir__)}/", "")
166
+ source[rel] = File.read(file)
167
+ end
168
+ @agent_source = source
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ module Persistence
6
+ class Base
7
+ def save(session_id, data)
8
+ raise NotImplementedError
9
+ end
10
+
11
+ def load(session_id)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def delete(session_id)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ def list
20
+ raise NotImplementedError
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ module Persistence
6
+ class InMemory < Base
7
+ def initialize
8
+ @store = {}
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def save(session_id, data)
13
+ @mutex.synchronize { @store[session_id] = data }
14
+ end
15
+
16
+ def load(session_id)
17
+ @mutex.synchronize { @store[session_id] }
18
+ end
19
+
20
+ def delete(session_id)
21
+ @mutex.synchronize { @store.delete(session_id) }
22
+ end
23
+
24
+ def list
25
+ @mutex.synchronize { @store.keys }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ class Reflector
6
+ attr_reader :reflection_count
7
+
8
+ def initialize(model:, max_reflections: 1)
9
+ @model = model
10
+ @max_reflections = max_reflections
11
+ @reflection_count = 0
12
+ end
13
+
14
+ def reflect?(tool_calls_made)
15
+ return false if @reflection_count >= @max_reflections
16
+ tool_calls_made > 0
17
+ end
18
+
19
+ def evaluate(response:, event_emitter:)
20
+ return { decision: :deliver } unless response
21
+
22
+ event_emitter.emit(Events::ReflectionStart.new(reflection_number: @reflection_count + 1))
23
+
24
+ result = build_eval_chat.ask(reflection_prompt(response)) do |chunk|
25
+ if chunk.content.to_s.strip.length > 0
26
+ event_emitter.emit(Events::ReflectionDelta.new(content: chunk.content))
27
+ end
28
+ end
29
+
30
+ decision = parse_decision(result.content.to_s)
31
+
32
+ event_emitter.emit(Events::ReflectionEnd.new(
33
+ decision: decision[:decision],
34
+ feedback: decision[:feedback]
35
+ ))
36
+
37
+ @reflection_count += 1
38
+ decision
39
+ end
40
+
41
+ def reset!
42
+ @reflection_count = 0
43
+ end
44
+
45
+ private
46
+
47
+ def reflection_prompt(response)
48
+ <<~PROMPT.strip
49
+ Evaluate this assistant response. Is it accurate, complete, and helpful?
50
+ Reply with JSON only — no other text:
51
+ {"decision": "deliver"} or {"decision": "improve", "feedback": "<what to fix>"}
52
+
53
+ Response: #{response}
54
+ PROMPT
55
+ end
56
+
57
+ def parse_decision(text)
58
+ parsed = JSON.parse(text)
59
+ {
60
+ decision: parsed["decision"] == "improve" ? :improve : :deliver,
61
+ feedback: parsed["feedback"]
62
+ }
63
+ rescue JSON::ParserError
64
+ { decision: :deliver, feedback: nil }
65
+ end
66
+
67
+ def build_eval_chat
68
+ model_id = model_id_from(@model)
69
+ RubyLLM::Chat.new(model: model_id)
70
+ end
71
+
72
+ def model_id_from(model)
73
+ case model
74
+ when RubyLLM::Chat then model.model.respond_to?(:id) ? model.model.id : model.model.to_s
75
+ when String then model
76
+ else model.to_s
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Ask
7
+ module Agent
8
+ class Session
9
+ attr_reader :id, :chat, :tools, :turn_count, :created_at, :messages
10
+ attr_reader :tool_calls_made
11
+
12
+ def reflection_count
13
+ @reflector&.reflection_count || 0
14
+ end
15
+
16
+ attr_reader :meta_agent_results
17
+
18
+ def initialize(model:, tools: [], max_turns: 25, max_tool_retries: 3,
19
+ compactor: nil, hooks: {}, persistence: nil,
20
+ id: nil, system_prompt: nil, parallel_tools: true,
21
+ reflector: nil, telemetry: true, meta_agent: nil, **chat_options)
22
+ @id = id || SecureRandom.uuid
23
+ @max_turns = max_turns
24
+ @max_tool_retries = max_tool_retries
25
+ @parallel_tools = parallel_tools
26
+ @event_handlers = { all: [] }
27
+ @running = false
28
+ @deleted = false
29
+ @abort_requested = false
30
+ @turn_count = 0
31
+ @created_at = Time.now
32
+ @_no_tools_instructed = false
33
+
34
+ @telemetry = telemetry.is_a?(Telemetry) ? telemetry : Telemetry.new(enabled: !!telemetry)
35
+
36
+ @chat = build_chat(model, system_prompt, tools, **chat_options)
37
+ @tools = resolve_tools(tools)
38
+ register_tools_on_chat
39
+ @loop = Loop.new(max_turns: max_turns)
40
+ @tool_executor = ToolExecutor.new(max_retries: max_tool_retries, parallel: parallel_tools)
41
+ @compactor = compactor ? build_compactor(compactor) : nil
42
+ @hooks = Hooks.new(hooks)
43
+ @persistence = persistence
44
+
45
+ reflector_opts = reflector.is_a?(Hash) ? reflector : {}
46
+ @reflector = if reflector
47
+ Reflector.new(
48
+ model: @chat,
49
+ max_reflections: reflector_opts[:max_reflections] || 1
50
+ )
51
+ end
52
+
53
+ @meta_agent_config = meta_agent
54
+ @meta_agent_results = nil
55
+
56
+ @compactor&.chat = @chat
57
+ end
58
+
59
+ def run(message, tools: nil)
60
+ raise "Session deleted" if @deleted
61
+ raise "Session already running" if @running
62
+
63
+ @running = true
64
+ @abort_requested = false
65
+ @turn_count = 0
66
+ @loop.reset!
67
+
68
+ emit(Events::SessionStart.new)
69
+
70
+ active_tools = resolve_tools(tools || [])
71
+ active_tools = @tools if active_tools.empty?
72
+
73
+ if active_tools.empty? && !@_no_tools_instructed
74
+ @chat.add_message(role: :system, content: "You have no tools available. Do not claim you can look up information or use tools of any kind. Just respond based on your existing knowledge.")
75
+ @_no_tools_instructed = true
76
+ end
77
+
78
+ begin
79
+ @tool_executor.telemetry = @telemetry
80
+
81
+ response = @loop.run_turn(
82
+ chat: @chat,
83
+ message: message,
84
+ tools: active_tools,
85
+ tool_executor: @tool_executor,
86
+ compactor: @compactor,
87
+ hooks: @hooks,
88
+ event_emitter: self,
89
+ session_id: @id
90
+ )
91
+ rescue MaxTurnsExceeded => e
92
+ emit(Events::MaxTurnsExceeded.new(max_turns: @max_turns))
93
+ @telemetry.log(:max_turns_exceeded, session_id: @id, max_turns: @max_turns)
94
+ response = last_content
95
+ rescue LoopDetected => e
96
+ emit(Events::LoopDetected.new(tool_name: e.message, repeated_count: 3))
97
+ @telemetry.log(:loop_detected, session_id: @id, tool_name: e.message, repeated_count: 3)
98
+ response = last_content
99
+ rescue RubyLLM::ContextLengthExceededError
100
+ if @compactor && !@compactor.overflow_recovered?
101
+ @compactor.recover_from_overflow
102
+ retry
103
+ end
104
+ response = "I'm sorry, the conversation has grown too long. Please start a new session."
105
+ rescue StandardError => e
106
+ emit(Events::Error.new(error: e.message, recoverable: true))
107
+ raise
108
+ ensure
109
+ @running = false
110
+ persist! if @persistence
111
+ end
112
+
113
+ @tool_calls_made = @tool_executor.total_executions
114
+
115
+ if @reflector && @reflector.reflect?(@tool_calls_made) && !@abort_requested
116
+ eval_result = @reflector.evaluate(response: response, event_emitter: self)
117
+ @telemetry.log(:reflection_end, session_id: @id, decision: eval_result[:decision], feedback: eval_result[:feedback])
118
+
119
+ if eval_result[:decision] == :improve && !@abort_requested
120
+ @chat.add_message(
121
+ role: :system,
122
+ content: "Improve your last response: #{eval_result[:feedback]}"
123
+ )
124
+
125
+ response = @loop.run_turn(
126
+ chat: @chat,
127
+ message: "",
128
+ tools: active_tools,
129
+ tool_executor: @tool_executor,
130
+ compactor: @compactor,
131
+ hooks: @hooks,
132
+ event_emitter: self,
133
+ session_id: @id
134
+ )
135
+ end
136
+ end
137
+
138
+ if @meta_agent_config
139
+ @telemetry.increment_session_count!
140
+ try_auto_meta_agent
141
+ end
142
+
143
+ emit(Events::SessionEnd.new(result: response, turn_count: @turn_count, tool_calls_made: @tool_calls_made))
144
+ @messages = @chat.messages.dup
145
+
146
+ response
147
+ end
148
+
149
+ def on_event(&block)
150
+ @event_handlers[:all] << block
151
+ self
152
+ end
153
+
154
+ def on(type, &block)
155
+ @event_handlers[type] ||= []
156
+ @event_handlers[type] << block
157
+ self
158
+ end
159
+
160
+ def emit(event)
161
+ @event_handlers[:all].each { |h| h.call(event) }
162
+ handlers = @event_handlers[event.class]
163
+ handlers&.each { |h| h.call(event) }
164
+ end
165
+
166
+ def running? = @running
167
+ def deleted? = @deleted
168
+
169
+ def save
170
+ persist! if @persistence
171
+ end
172
+
173
+ def self.load(id, adapter:)
174
+ data = adapter.load(id)
175
+ return nil unless data
176
+
177
+ session = new(
178
+ id: data[:id],
179
+ model: data.dig(:metadata, :model),
180
+ tools: data.dig(:metadata, :tools)&.map(&:constantize) || [],
181
+ persistence: adapter
182
+ )
183
+
184
+ data[:messages].each do |msg|
185
+ session.chat.add_message(
186
+ role: msg[:role].to_sym,
187
+ content: msg[:content],
188
+ tool_call_id: msg[:tool_call_id]
189
+ )
190
+ end
191
+
192
+ session
193
+ end
194
+
195
+ def delete
196
+ @deleted = true
197
+ @persistence&.delete(@id)
198
+ end
199
+
200
+ def abort
201
+ @abort_requested = true
202
+ end
203
+
204
+ def abort_requested? = @abort_requested
205
+
206
+ def reset_messages!
207
+ @chat.reset_messages!
208
+ @messages = []
209
+ end
210
+
211
+ private
212
+
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
+ def build_chat(model, system_prompt, tools, **chat_options)
223
+ if model.respond_to?(:ask)
224
+ model
225
+ else
226
+ chat = RubyLLM::Chat.new(model: model, **chat_options)
227
+ chat.with_instructions(system_prompt) if system_prompt
228
+ chat
229
+ end
230
+ end
231
+
232
+ def resolve_tools(tools)
233
+ tools.map do |tool|
234
+ tool.is_a?(Class) ? tool.new : tool
235
+ end
236
+ end
237
+
238
+ def build_compactor(config)
239
+ compactor = Compactor.new(
240
+ threshold: config[:threshold] || 0.8,
241
+ strategy: config[:strategy] || :proactive
242
+ )
243
+ compactor.chat = @chat
244
+ compactor
245
+ end
246
+
247
+ def persist!
248
+ @persistence.save(@id, {
249
+ id: @id,
250
+ messages: @chat.messages.map { |m|
251
+ {
252
+ role: m.role,
253
+ content: m.content.to_s,
254
+ tool_call_id: m.tool_call_id,
255
+ created_at: Time.now.iso8601
256
+ }
257
+ },
258
+ metadata: {
259
+ model: @chat.model.respond_to?(:id) ? @chat.model.id : @chat.model,
260
+ tools: @tools.map { |t| t.class.name },
261
+ max_turns: @max_turns,
262
+ turn_count: @turn_count,
263
+ created_at: @created_at.iso8601,
264
+ updated_at: Time.now.iso8601
265
+ }
266
+ })
267
+ end
268
+
269
+ def try_auto_meta_agent
270
+ return unless @meta_agent_config
271
+ return unless @meta_agent_config[:auto]
272
+
273
+ interval = @meta_agent_config[:interval] || 10
274
+ count = @telemetry.session_count
275
+ return unless count >= interval
276
+
277
+ agent = MetaAgent.new(
278
+ telemetry: @telemetry,
279
+ model: model_id_from(@chat),
280
+ **@meta_agent_config[:chat_options].to_h
281
+ )
282
+
283
+ results = agent.analyze
284
+ @meta_agent_results = results
285
+ emit(Events::MetaAgentAnalysis.new(results: results, count: results.size))
286
+ @telemetry.reset_session_count!
287
+ end
288
+
289
+ def model_id_from(chat)
290
+ chat.model.respond_to?(:id) ? chat.model.id : chat.model.to_s
291
+ end
292
+
293
+ def last_content
294
+ @chat.messages.reverse_each.lazy
295
+ .select { |m| m.role == :assistant && m.content.to_s.strip.length > 0 }
296
+ .first&.content.to_s
297
+ end
298
+ end
299
+ end
300
+ end