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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+
7
+ module Ask
8
+ module Agent
9
+ class Telemetry
10
+ TELEMETRY_DIR = File.expand_path("~/.ask/agent/telemetry")
11
+ EVENT_TYPES = %i[tool_error loop_detected max_turns_exceeded compaction_end reflection_end].freeze
12
+
13
+ attr_reader :enabled
14
+
15
+ def initialize(enabled: true, dir: nil)
16
+ @enabled = enabled
17
+ @dir = dir || TELEMETRY_DIR
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def log(event_type, data)
22
+ return unless @enabled
23
+ return unless EVENT_TYPES.include?(event_type)
24
+
25
+ entry = {
26
+ timestamp: Time.now.utc.iso8601(3),
27
+ event_type: event_type,
28
+ session_id: data[:session_id],
29
+ details: data.reject { |k, _| k == :session_id }
30
+ }
31
+
32
+ dir = File.join(@dir, event_type.to_s)
33
+ FileUtils.mkdir_p(dir)
34
+
35
+ filename = "#{entry[:timestamp].tr(':', '-')}_#{SecureRandom.hex(4)}.json"
36
+
37
+ @mutex.synchronize do
38
+ File.write(File.join(dir, filename), JSON.pretty_generate(entry) + "\n")
39
+ end
40
+ end
41
+
42
+ def read(event_type = nil)
43
+ return {} unless File.directory?(@dir)
44
+
45
+ entries = []
46
+ dirs = event_type ? [File.join(@dir, event_type.to_s)] : Dir[File.join(@dir, "*")].select { |d| File.directory?(d) }
47
+
48
+ dirs.each do |dir|
49
+ Dir[File.join(dir, "*.json")].sort.each do |file|
50
+ entries << JSON.parse(File.read(file))
51
+ rescue JSON::ParserError
52
+ nil
53
+ end
54
+ end
55
+
56
+ entries.group_by { |e| e["event_type"] }
57
+ end
58
+
59
+ def track_recommendation(recommendation)
60
+ return unless @enabled
61
+
62
+ entry = recommendation.to_h.merge(
63
+ recommendation_id: "rec_#{SecureRandom.hex(8)}",
64
+ timestamp: Time.now.utc.iso8601(3),
65
+ status: "open"
66
+ )
67
+
68
+ rec_dir = File.join(@dir, "recommendations")
69
+ FileUtils.mkdir_p(rec_dir)
70
+
71
+ filename = "#{entry[:timestamp].tr(':', '-')}_#{entry[:recommendation_id]}.json"
72
+
73
+ @mutex.synchronize do
74
+ File.write(File.join(rec_dir, filename), JSON.pretty_generate(entry) + "\n")
75
+ end
76
+
77
+ entry[:recommendation_id]
78
+ end
79
+
80
+ def track_resolution(recommendation_id)
81
+ return unless @enabled
82
+
83
+ rec_dir = File.join(@dir, "recommendations")
84
+ return unless File.directory?(rec_dir)
85
+
86
+ Dir[File.join(rec_dir, "*.json")].each do |file|
87
+ entry = JSON.parse(File.read(file))
88
+ next unless entry["recommendation_id"] == recommendation_id
89
+
90
+ entry["status"] = "resolved"
91
+ entry["resolved_at"] = Time.now.utc.iso8601(3)
92
+
93
+ @mutex.synchronize do
94
+ File.write(file, JSON.pretty_generate(entry) + "\n")
95
+ end
96
+ return true
97
+ rescue JSON::ParserError
98
+ nil
99
+ end
100
+
101
+ false
102
+ end
103
+
104
+ def read_recommendations(status: nil)
105
+ rec_dir = File.join(@dir, "recommendations")
106
+ return [] unless File.directory?(rec_dir)
107
+
108
+ entries = Dir[File.join(rec_dir, "*.json")].sort.map do |file|
109
+ JSON.parse(File.read(file))
110
+ rescue JSON::ParserError
111
+ nil
112
+ end.compact
113
+
114
+ return entries unless status
115
+ entries.select { |e| e["status"] == status }
116
+ end
117
+
118
+ def increment_session_count!
119
+ return unless @enabled
120
+
121
+ FileUtils.mkdir_p(@dir)
122
+ path = File.join(@dir, "session_counter.json")
123
+
124
+ @mutex.synchronize do
125
+ count = File.exist?(path) ? JSON.parse(File.read(path))["count"] : 0
126
+ File.write(path, JSON.pretty_generate({ count: count + 1, updated_at: Time.now.utc.iso8601(3) }) + "\n")
127
+ end
128
+ end
129
+
130
+ def session_count
131
+ return 0 unless @enabled
132
+
133
+ path = File.join(@dir, "session_counter.json")
134
+ return 0 unless File.exist?(path)
135
+
136
+ JSON.parse(File.read(path))["count"]
137
+ rescue JSON::ParserError
138
+ 0
139
+ end
140
+
141
+ def reset_session_count!
142
+ return unless @enabled
143
+
144
+ path = File.join(@dir, "session_counter.json")
145
+ @mutex.synchronize do
146
+ File.write(path, JSON.pretty_generate({ count: 0, updated_at: Time.now.utc.iso8601(3) }) + "\n")
147
+ end
148
+ end
149
+
150
+ def clear!
151
+ return unless File.directory?(@dir)
152
+
153
+ Dir[File.join(@dir, "**/*.json")].each { |f| File.delete(f) }
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ class ToolAbortController
6
+ def initialize
7
+ @aborted = false
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def abort!
12
+ @mutex.synchronize { @aborted = true }
13
+ end
14
+
15
+ def aborted?
16
+ @mutex.synchronize { @aborted }
17
+ end
18
+
19
+ def reset!
20
+ @mutex.synchronize { @aborted = false }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ class ToolExecutor
6
+ CRITICAL_ERROR_CLASSES = %w[
7
+ Ask::Unauthorized
8
+ Ask::Forbidden
9
+ Ask::PaymentRequired
10
+ ].freeze
11
+
12
+ attr_reader :total_executions
13
+
14
+ def initialize(max_retries: 3, parallel: true)
15
+ @max_retries = max_retries
16
+ @parallel = parallel
17
+ @total_executions = 0
18
+ end
19
+
20
+ attr_writer :telemetry
21
+
22
+ def execute(tool_calls, tools, hooks:, event_emitter:, session_id: nil)
23
+ return [] if tool_calls.empty?
24
+
25
+ @total_executions = 0
26
+ @session_id = session_id
27
+ sibling_abort = ToolAbortController.new
28
+
29
+ if @parallel
30
+ execute_parallel(tool_calls, tools, hooks, event_emitter, sibling_abort)
31
+ else
32
+ execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort)
33
+ end
34
+ end
35
+
36
+ def execute_parallel(tool_calls, tools, hooks, event_emitter, sibling_abort, &result_callback)
37
+ threads = []
38
+ mutex = Mutex.new
39
+ results = {}
40
+
41
+ tool_calls.each do |id, tool_call|
42
+ threads << Thread.new do
43
+ begin
44
+ if sibling_abort.aborted?
45
+ mutex.synchronize { results[id] = aborted_result(tool_call) }
46
+ next
47
+ end
48
+
49
+ result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort)
50
+ mutex.synchronize { results[id] = result }
51
+
52
+ # Stream result back as it completes
53
+ result_callback&.call(tool_call.id, result)
54
+
55
+ if result[:critical_failure]
56
+ sibling_abort.abort!
57
+ end
58
+ rescue => e
59
+ mutex.synchronize do
60
+ results[id] = {
61
+ tool_name: tool_call.name, message: e.message,
62
+ status: "error", is_error: true, critical_failure: false
63
+ }
64
+ end
65
+ result_callback&.call(tool_call.id, results[id])
66
+ end
67
+ end
68
+ end
69
+
70
+ threads.each(&:join)
71
+ tool_calls.keys.map { |id| results[id] }.compact
72
+ end
73
+
74
+ def execute_sequential(tool_calls, tools, hooks, event_emitter, sibling_abort)
75
+ results = []
76
+ tool_calls.each do |id, tool_call|
77
+ break if sibling_abort.aborted?
78
+
79
+ result = execute_single_tool(tool_call, tools, hooks, event_emitter, sibling_abort)
80
+ results << result
81
+ break if result[:critical_failure]
82
+ end
83
+ results
84
+ end
85
+
86
+ private
87
+
88
+ def execute_single_tool(tool_call, tools, hooks, event_emitter, abort_controller = nil)
89
+ return aborted_result(tool_call) if abort_controller&.aborted?
90
+
91
+ tool = tools.find { |t| t.name == tool_call.name }
92
+
93
+ unless tool
94
+ return { tool_name: tool_call.name, message: "Tool not found", status: "error", is_error: true }
95
+ end
96
+
97
+ hook_result = hooks.run_before_tool(tool_call, {})
98
+ case hook_result&.dig(:action)
99
+ when :block
100
+ return { tool_name: tool_call.name, message: hook_result[:reason], status: "blocked", is_error: true }
101
+ when :short_circuit
102
+ return { tool_name: tool_call.name, **hook_result[:result], status: "short_circuited" }
103
+ end
104
+
105
+ return aborted_result(tool_call) if abort_controller&.aborted?
106
+
107
+ args = hook_result&.dig(:arguments) || tool_call.arguments
108
+
109
+ event_emitter.emit(Events::ToolExecutionStart.new(
110
+ name: tool_call.name, arguments: args, id: tool_call.id
111
+ ))
112
+
113
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
114
+ result = begin
115
+ execute_with_retry(tool, tool_call.id, args, abort_controller)
116
+ rescue Exception => e
117
+ { result: e.message, is_error: true, error: e.class.name }
118
+ end
119
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).to_i
120
+ @total_executions += 1
121
+
122
+ return aborted_result(tool_call) if abort_controller&.aborted?
123
+
124
+ hook_result = hooks.run_after_tool(tool_call, result, {})
125
+ if hook_result&.dig(:action) == :transform
126
+ result = hook_result[:result]
127
+ end
128
+
129
+ is_error = result[:is_error] == true
130
+ critical = is_error && critical_error?(result[:error])
131
+
132
+ if is_error && @telemetry
133
+ @telemetry.log(:tool_error, session_id: @session_id, tool_name: tool_call.name, error_class: result[:error] || "RuntimeError", error_message: result[:result].to_s)
134
+ end
135
+
136
+ event_emitter.emit(Events::ToolExecutionEnd.new(
137
+ name: tool_call.name, id: tool_call.id, result: result, is_error: is_error, duration_ms: duration
138
+ ))
139
+
140
+ message = if is_error
141
+ tool_result = result[:result]
142
+ error_msg = if tool_result.is_a?(Hash) && tool_result[:error]
143
+ tool_result[:error].to_s
144
+ elsif tool_result.is_a?(String)
145
+ tool_result
146
+ else
147
+ result.to_s
148
+ end
149
+ "Tool #{tool_call.name} error: #{error_msg}"
150
+ else
151
+ result[:result].to_s
152
+ end
153
+
154
+ {
155
+ tool_name: tool_call.name,
156
+ message: message,
157
+ status: is_error ? "error" : "success",
158
+ result: result,
159
+ critical_failure: critical
160
+ }
161
+ end
162
+
163
+ def execute_with_retry(tool, tool_call_id, args, abort_controller = nil)
164
+ @max_retries.times do |attempt|
165
+ return { result: nil, is_error: true, error: "Aborted" } if abort_controller&.aborted?
166
+
167
+ result = try_call(tool, args)
168
+ return result unless result[:is_error] && retryable_error_name?(result[:error])
169
+
170
+ sleep((2 ** attempt) * 0.5 + rand(0.0..0.5))
171
+ end
172
+
173
+ return { result: nil, is_error: true, error: "Aborted" } if abort_controller&.aborted?
174
+ try_call(tool, args)
175
+ end
176
+
177
+ def try_call(tool, args)
178
+ result = tool.call(args)
179
+ { result: result, is_error: false }
180
+ rescue => e
181
+ { result: e.message, is_error: true, error: e.class.name }
182
+ end
183
+
184
+ def retryable_error_name?(error_name)
185
+ retryable = %w[Timeout::Error Errno::ETIMEDOUT
186
+ Ask::RateLimitError Ask::ServerError
187
+ Ask::RateLimitError Ask::ServiceUnavailableError]
188
+ retryable.include?(error_name)
189
+ end
190
+
191
+ def critical_error?(error_class_name)
192
+ return false unless error_class_name
193
+ CRITICAL_ERROR_CLASSES.any? { |klass| error_class_name == klass.name }
194
+ end
195
+
196
+ def aborted_result(tool_call)
197
+ {
198
+ tool_name: tool_call.name,
199
+ message: "Aborted by sibling failure",
200
+ status: "aborted",
201
+ is_error: true,
202
+ aborted: true
203
+ }
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Agent
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/ask/agent.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "fileutils"
5
+ require "json"
6
+ require "securerandom"
7
+ require "time"
8
+
9
+ module Ask
10
+ module Agent
11
+ class Error < StandardError; end
12
+ class LoopDetected < Error; end
13
+ class MaxTurnsExceeded < Error; end
14
+ class Aborted < Error; end
15
+ class ToolExecutionError < Error; end
16
+ class CompactionFailed < Error; end
17
+ class SessionNotPersisted < Error; end
18
+
19
+ module Extensions
20
+ autoload :PermissionGate, "ask/agent/extensions/permission_gate"
21
+ autoload :RateLimiter, "ask/agent/extensions/rate_limiter"
22
+ autoload :AuditLog, "ask/agent/extensions/audit_log"
23
+ end
24
+
25
+ class << self
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ def configure
31
+ yield configuration
32
+ end
33
+
34
+ def load_extensions
35
+ Dir[File.expand_path("agent/extensions/*.rb", __dir__)].each { |f| require f }
36
+ rescue Errno::ENOENT
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ require_relative "agent/version"
43
+ require_relative "agent/events"
44
+ require_relative "agent/telemetry"
45
+ require_relative "agent/tool_abort_controller"
46
+ require_relative "agent/session"
47
+ require_relative "agent/loop"
48
+ require_relative "agent/reflector"
49
+ require_relative "agent/tool_executor"
50
+ require_relative "agent/compactor"
51
+ require_relative "agent/hooks"
52
+ require_relative "agent/configuration"
53
+ require_relative "agent/meta_agent"
54
+ require_relative "agent/persistence/base"
55
+ require_relative "agent/persistence/in_memory"
data/lib/ask-agent.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ask/tools"
4
+ require "ask/tools/shell"
5
+ require "ask/agent"
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ask-agent
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kaka Ruto
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ask-tools
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ask-tools-shell
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: ruby_llm
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.14'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.14'
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.25'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '5.25'
68
+ - !ruby/object:Gem::Dependency
69
+ name: mocha
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.1'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.1'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '13.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '13.0'
96
+ description: Agent loop, session management, tool execution, context compaction, hooks,
97
+ and extensions.
98
+ email:
99
+ - kaka@myrrlabs.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - LICENSE
105
+ - README.md
106
+ - lib/ask-agent.rb
107
+ - lib/ask/agent.rb
108
+ - lib/ask/agent/compactor.rb
109
+ - lib/ask/agent/configuration.rb
110
+ - lib/ask/agent/events.rb
111
+ - lib/ask/agent/extensions/audit_log.rb
112
+ - lib/ask/agent/extensions/permission_gate.rb
113
+ - lib/ask/agent/extensions/rate_limiter.rb
114
+ - lib/ask/agent/hooks.rb
115
+ - lib/ask/agent/loop.rb
116
+ - lib/ask/agent/meta_agent.rb
117
+ - lib/ask/agent/persistence/base.rb
118
+ - lib/ask/agent/persistence/in_memory.rb
119
+ - lib/ask/agent/reflector.rb
120
+ - lib/ask/agent/session.rb
121
+ - lib/ask/agent/telemetry.rb
122
+ - lib/ask/agent/tool_abort_controller.rb
123
+ - lib/ask/agent/tool_executor.rb
124
+ - lib/ask/agent/version.rb
125
+ homepage: https://github.com/ask-rb/ask-agent
126
+ licenses:
127
+ - MIT
128
+ metadata: {}
129
+ rdoc_options: []
130
+ require_paths:
131
+ - lib
132
+ required_ruby_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '3.2'
137
+ required_rubygems_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ requirements: []
143
+ rubygems_version: 4.0.3
144
+ specification_version: 4
145
+ summary: Agent runtime for the ask-rb ecosystem
146
+ test_files: []