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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +114 -0
- data/lib/ask/agent/compactor.rb +151 -0
- data/lib/ask/agent/configuration.rb +19 -0
- data/lib/ask/agent/events.rb +36 -0
- data/lib/ask/agent/extensions/audit_log.rb +42 -0
- data/lib/ask/agent/extensions/permission_gate.rb +70 -0
- data/lib/ask/agent/extensions/rate_limiter.rb +42 -0
- data/lib/ask/agent/hooks.rb +30 -0
- data/lib/ask/agent/loop.rb +114 -0
- data/lib/ask/agent/meta_agent.rb +172 -0
- data/lib/ask/agent/persistence/base.rb +25 -0
- data/lib/ask/agent/persistence/in_memory.rb +30 -0
- data/lib/ask/agent/reflector.rb +81 -0
- data/lib/ask/agent/session.rb +300 -0
- data/lib/ask/agent/telemetry.rb +157 -0
- data/lib/ask/agent/tool_abort_controller.rb +24 -0
- data/lib/ask/agent/tool_executor.rb +207 -0
- data/lib/ask/agent/version.rb +7 -0
- data/lib/ask/agent.rb +55 -0
- data/lib/ask-agent.rb +5 -0
- metadata +146 -0
|
@@ -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
|