ask-app-server 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/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +108 -0
- data/bin/ask-app-server +6 -0
- data/lib/ask/app_server/agent_adapter.rb +206 -0
- data/lib/ask/app_server/cli.rb +173 -0
- data/lib/ask/app_server/config.rb +258 -0
- data/lib/ask/app_server/event_translator.rb +194 -0
- data/lib/ask/app_server/permission_handler.rb +167 -0
- data/lib/ask/app_server/server.rb +384 -0
- data/lib/ask/app_server/session_manager.rb +237 -0
- data/lib/ask/app_server/session_store.rb +138 -0
- data/lib/ask/app_server/version.rb +7 -0
- data/lib/ask/app_server.rb +13 -0
- data/lib/ask-app-server.rb +15 -0
- metadata +201 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module AppServer
|
|
7
|
+
# Configuration loader for ask-app-server.
|
|
8
|
+
#
|
|
9
|
+
# Reads from a JSON config file, then merges environment variable overrides.
|
|
10
|
+
# Config file search order (first found wins):
|
|
11
|
+
# 1. ASK_APP_SERVER_CONFIG env var (explicit path)
|
|
12
|
+
# 2. ./.ask-app-server.json (project-local)
|
|
13
|
+
# 3. ~/.ask-app-server/config.json (user-global)
|
|
14
|
+
#
|
|
15
|
+
# Environment variable overrides:
|
|
16
|
+
# ASK_APP_SERVER_MODEL — model identifier (e.g., "opencode_go/deepseek-v4-flash")
|
|
17
|
+
# ASK_APP_SERVER_PERMISSIONS — permission mode (on_request, never)
|
|
18
|
+
# DEBUG — debug logging
|
|
19
|
+
#
|
|
20
|
+
# Example config file (~/.ask-app-server/config.json):
|
|
21
|
+
# {
|
|
22
|
+
# "model": "opencode_go/deepseek-v4-flash",
|
|
23
|
+
# "tools": ["bash", "read", "write", "edit", "glob", "grep"],
|
|
24
|
+
# "permissions": {
|
|
25
|
+
# "mode": "on_request",
|
|
26
|
+
# "blocked_tools": ["write", "edit", "bash", "destroy"],
|
|
27
|
+
# "timeout": 300
|
|
28
|
+
# },
|
|
29
|
+
# "system_prompt": "You are a helpful AI coding assistant.",
|
|
30
|
+
# "session": { "timeout": 600 },
|
|
31
|
+
# "custom_models": {
|
|
32
|
+
# "deepseek-v4-flash": {
|
|
33
|
+
# "provider": "opencode_go",
|
|
34
|
+
# "context": 1000000,
|
|
35
|
+
# "output": 384000
|
|
36
|
+
# }
|
|
37
|
+
# }
|
|
38
|
+
# }
|
|
39
|
+
class Config
|
|
40
|
+
DEFAULTS = {
|
|
41
|
+
model: "opencode_go/deepseek-v4-flash",
|
|
42
|
+
tools: %w[bash read write edit glob grep].freeze,
|
|
43
|
+
permissions: {
|
|
44
|
+
mode: :on_request,
|
|
45
|
+
blocked_tools: %w[write edit bash destroy].freeze,
|
|
46
|
+
timeout: 300
|
|
47
|
+
}.freeze,
|
|
48
|
+
system_prompt: nil,
|
|
49
|
+
session: {
|
|
50
|
+
timeout: 600
|
|
51
|
+
}.freeze,
|
|
52
|
+
custom_models: {}.freeze
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
CONFIG_FILE_PATHS = [
|
|
56
|
+
-> { ENV["ASK_APP_SERVER_CONFIG"] },
|
|
57
|
+
-> { File.expand_path(".ask-app-server.json") },
|
|
58
|
+
-> { File.expand_path("~/.ask-app-server/config.json") }
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
attr_reader :source_path
|
|
62
|
+
|
|
63
|
+
def initialize(config_path: nil)
|
|
64
|
+
@source_path = nil
|
|
65
|
+
@data = load_config(config_path)
|
|
66
|
+
@registered = false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Resolved model identifier (may include provider prefix).
|
|
70
|
+
def model
|
|
71
|
+
ENV.fetch("ASK_APP_SERVER_MODEL", @data[:model])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parse model into [provider, model_id].
|
|
75
|
+
# Supports "provider/model" format and bare model names.
|
|
76
|
+
def parsed_model
|
|
77
|
+
raw = model
|
|
78
|
+
if raw.include?("/")
|
|
79
|
+
parts = raw.split("/", 2)
|
|
80
|
+
[parts[0], parts[1]]
|
|
81
|
+
else
|
|
82
|
+
[nil, raw]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The model ID (without provider prefix).
|
|
87
|
+
def model_id
|
|
88
|
+
parsed_model[1]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The provider slug (nil if not specified).
|
|
92
|
+
def model_provider
|
|
93
|
+
parsed_model[0]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# List of tool names to load.
|
|
97
|
+
def tools
|
|
98
|
+
@data[:tools] || DEFAULTS[:tools]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Permission mode symbol (:on_request, :never).
|
|
102
|
+
def permission_mode
|
|
103
|
+
env_mode = ENV["ASK_APP_SERVER_PERMISSIONS"]
|
|
104
|
+
return env_mode.to_sym if env_mode
|
|
105
|
+
|
|
106
|
+
raw = @data.dig(:permissions, :mode)
|
|
107
|
+
raw ? raw.to_sym : DEFAULTS.dig(:permissions, :mode)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# List of tool names that require permission.
|
|
111
|
+
def blocked_tools
|
|
112
|
+
@data.dig(:permissions, :blocked_tools)&.map(&:to_s) || DEFAULTS.dig(:permissions, :blocked_tools)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Permission request timeout in seconds.
|
|
116
|
+
def permission_timeout
|
|
117
|
+
@data.dig(:permissions, :timeout) || DEFAULTS.dig(:permissions, :timeout)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# System prompt for the agent.
|
|
121
|
+
def system_prompt
|
|
122
|
+
@data[:system_prompt]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Session timeout in seconds.
|
|
126
|
+
def session_timeout
|
|
127
|
+
@data.dig(:session, :timeout) || DEFAULTS.dig(:session, :timeout)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# State persistence configuration.
|
|
131
|
+
# Returns nil if no state config is set (use in-memory defaults).
|
|
132
|
+
def state_config
|
|
133
|
+
@data[:state]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Path for SQLite state database (if configured).
|
|
137
|
+
def state_sqlite_path
|
|
138
|
+
cfg = state_config
|
|
139
|
+
return nil unless cfg
|
|
140
|
+
|
|
141
|
+
cfg[:sqlite_path] || cfg["sqlite_path"] || cfg[:path] || cfg["path"]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Whether debug logging is enabled.
|
|
145
|
+
def debug?
|
|
146
|
+
ENV["DEBUG"] == "1"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Custom model definitions from config.
|
|
150
|
+
def custom_models
|
|
151
|
+
@data[:custom_models] || {}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Register custom models into Ask::ModelCatalog so ask-agent can find them.
|
|
155
|
+
# Safe to call multiple times — models are registered only once.
|
|
156
|
+
def register_models!
|
|
157
|
+
return if @registered
|
|
158
|
+
@registered = true
|
|
159
|
+
|
|
160
|
+
custom_models.each do |model_id, cfg|
|
|
161
|
+
provider = cfg[:provider] || cfg["provider"]
|
|
162
|
+
context = cfg[:context] || cfg["context"] || 4096
|
|
163
|
+
output = cfg[:output] || cfg["output"] || 4096
|
|
164
|
+
|
|
165
|
+
# Build a ModelInfo-compatible struct and register it
|
|
166
|
+
model_info = OpenStruct.new(
|
|
167
|
+
id: model_id.to_s,
|
|
168
|
+
provider: provider.to_s,
|
|
169
|
+
chat?: true,
|
|
170
|
+
context: context,
|
|
171
|
+
output: output
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Use the singleton's register if available
|
|
175
|
+
if Ask::ModelCatalog.respond_to?(:instance)
|
|
176
|
+
catalog = Ask::ModelCatalog.instance
|
|
177
|
+
catalog.register(model_info) if catalog.respond_to?(:register)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if debug?
|
|
181
|
+
warn "[ask-app-server] Registered custom model: #{provider}/#{model_id} (#{context}ctx)"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# All config as a hash (for display).
|
|
187
|
+
def to_h
|
|
188
|
+
prov, mod = parsed_model
|
|
189
|
+
{
|
|
190
|
+
model: mod,
|
|
191
|
+
provider: prov,
|
|
192
|
+
tools: tools,
|
|
193
|
+
permissions: {
|
|
194
|
+
mode: permission_mode,
|
|
195
|
+
blocked_tools: blocked_tools,
|
|
196
|
+
timeout: permission_timeout
|
|
197
|
+
},
|
|
198
|
+
system_prompt: system_prompt,
|
|
199
|
+
session: { timeout: session_timeout },
|
|
200
|
+
custom_models: custom_models.keys,
|
|
201
|
+
source: source_path || "(defaults)"
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def load_config(config_path)
|
|
208
|
+
path = config_path || find_config_file
|
|
209
|
+
return deep_copy(DEFAULTS) unless path && File.exist?(path)
|
|
210
|
+
|
|
211
|
+
@source_path = File.expand_path(path)
|
|
212
|
+
raw = JSON.parse(File.read(@source_path))
|
|
213
|
+
|
|
214
|
+
merged = deep_merge(deep_copy(DEFAULTS), normalize_keys(raw))
|
|
215
|
+
merged
|
|
216
|
+
rescue JSON::ParserError => e
|
|
217
|
+
warn "[ask-app-server] Warning: Invalid config file #{path}: #{e.message}"
|
|
218
|
+
deep_copy(DEFAULTS)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def find_config_file
|
|
222
|
+
CONFIG_FILE_PATHS.each do |resolver|
|
|
223
|
+
path = resolver.call
|
|
224
|
+
return path if path && File.exist?(path)
|
|
225
|
+
end
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def deep_merge(a, b)
|
|
230
|
+
a.merge(b) do |_key, old_val, new_val|
|
|
231
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
232
|
+
deep_merge(old_val, new_val)
|
|
233
|
+
elsif old_val.is_a?(Array) && new_val.is_a?(Array)
|
|
234
|
+
new_val
|
|
235
|
+
else
|
|
236
|
+
new_val
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def deep_copy(obj)
|
|
242
|
+
case obj
|
|
243
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k] = deep_copy(v) }
|
|
244
|
+
when Array then obj.map { |v| deep_copy(v) }
|
|
245
|
+
else obj
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def normalize_keys(obj)
|
|
250
|
+
case obj
|
|
251
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = normalize_keys(v) }
|
|
252
|
+
when Array then obj.map { |v| normalize_keys(v) }
|
|
253
|
+
else obj
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module AppServer
|
|
7
|
+
# Translates ask-agent Events into app-server protocol events.
|
|
8
|
+
#
|
|
9
|
+
# ask-agent emits events like TurnStart, TextDelta, ToolExecutionStart, etc.
|
|
10
|
+
# The app-server protocol uses a different set: turn.started, model.streaming,
|
|
11
|
+
# tool.updated, turn.completed, turn.failed, message.upserted.
|
|
12
|
+
#
|
|
13
|
+
# This class maps between the two models, emitting app-server protocol events
|
|
14
|
+
# that clients such as the Python Telegram bot and Vercel AI SDK expect.
|
|
15
|
+
class EventTranslator
|
|
16
|
+
attr_reader :session_id, :turn_id
|
|
17
|
+
|
|
18
|
+
def initialize(session_id)
|
|
19
|
+
@session_id = session_id
|
|
20
|
+
@turn_id = nil
|
|
21
|
+
@seq = 0
|
|
22
|
+
@events = []
|
|
23
|
+
@streaming_text = +""
|
|
24
|
+
@turn_active = false
|
|
25
|
+
@in_reflection = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Translate an ask-agent event into zero or more app-server events.
|
|
29
|
+
# Returns an array of event hashes (may be empty).
|
|
30
|
+
def translate(agent_event)
|
|
31
|
+
case agent_event
|
|
32
|
+
when Ask::Agent::Events::TurnStart
|
|
33
|
+
translate_turn_start
|
|
34
|
+
when Ask::Agent::Events::TextDelta
|
|
35
|
+
translate_text_delta(agent_event)
|
|
36
|
+
when Ask::Agent::Events::ToolCallDelta
|
|
37
|
+
# ToolCallDelta is informational; we track calls but don't emit
|
|
38
|
+
# until execution actually starts.
|
|
39
|
+
[]
|
|
40
|
+
when Ask::Agent::Events::ToolExecutionStart
|
|
41
|
+
translate_tool_start(agent_event)
|
|
42
|
+
when Ask::Agent::Events::ToolExecutionUpdate
|
|
43
|
+
translate_tool_update(agent_event)
|
|
44
|
+
when Ask::Agent::Events::ToolExecutionEnd
|
|
45
|
+
translate_tool_end(agent_event)
|
|
46
|
+
when Ask::Agent::Events::MessageEnd
|
|
47
|
+
# Fires after the LLM response is complete (before tool execution).
|
|
48
|
+
# Not mapped directly; we already streamed the text.
|
|
49
|
+
[]
|
|
50
|
+
when Ask::Agent::Events::TurnEnd
|
|
51
|
+
# Fires after tool execution completes for one recursive iteration.
|
|
52
|
+
# The session may continue with more tool calls.
|
|
53
|
+
[]
|
|
54
|
+
when Ask::Agent::Events::ReflectionStart
|
|
55
|
+
# Reflection is an internal detail — skip
|
|
56
|
+
@in_reflection = true
|
|
57
|
+
[]
|
|
58
|
+
when Ask::Agent::Events::ReflectionDelta
|
|
59
|
+
# Treat reflection text as regular model output
|
|
60
|
+
translate_text_delta(agent_event)
|
|
61
|
+
when Ask::Agent::Events::ReflectionEnd
|
|
62
|
+
@in_reflection = false
|
|
63
|
+
[]
|
|
64
|
+
when Ask::Agent::Events::CompactionStart
|
|
65
|
+
[]
|
|
66
|
+
when Ask::Agent::Events::CompactionEnd
|
|
67
|
+
[]
|
|
68
|
+
when Ask::Agent::Events::Error
|
|
69
|
+
translate_error(agent_event)
|
|
70
|
+
when Ask::Agent::Events::SessionEnd
|
|
71
|
+
translate_session_end(agent_event)
|
|
72
|
+
when Ask::Agent::Events::MaxTurnsExceeded
|
|
73
|
+
translate_turn_failed("Max turns exceeded (#{agent_event.max_turns})")
|
|
74
|
+
when Ask::Agent::Events::LoopDetected
|
|
75
|
+
translate_turn_failed("Loop detected on tool: #{agent_event.tool_name}")
|
|
76
|
+
else
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# All events emitted since last poll.
|
|
82
|
+
def pending_events
|
|
83
|
+
@events
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Drain and return all pending events, clearing the buffer.
|
|
87
|
+
def drain_events
|
|
88
|
+
evs = @events.dup
|
|
89
|
+
@events.clear
|
|
90
|
+
evs
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The last sequence number we emitted.
|
|
94
|
+
def last_seq
|
|
95
|
+
@seq
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def next_seq
|
|
101
|
+
@seq += 1
|
|
102
|
+
@seq
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def translate_turn_start
|
|
106
|
+
@turn_id = SecureRandom.uuid
|
|
107
|
+
@turn_active = true
|
|
108
|
+
@streaming_text = +""
|
|
109
|
+
|
|
110
|
+
ev = build_event("turn.started", { turnId: @turn_id })
|
|
111
|
+
[ev]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def translate_text_delta(event)
|
|
115
|
+
content = event.content.to_s
|
|
116
|
+
return [] if content.empty?
|
|
117
|
+
|
|
118
|
+
@streaming_text << content
|
|
119
|
+
|
|
120
|
+
ev = build_event("model.streaming", { delta: content })
|
|
121
|
+
[ev]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def translate_tool_start(event)
|
|
125
|
+
ev = build_event("tool.updated", {
|
|
126
|
+
toolName: event.name,
|
|
127
|
+
kind: "started",
|
|
128
|
+
input: event.arguments
|
|
129
|
+
})
|
|
130
|
+
[ev]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def translate_tool_update(event)
|
|
134
|
+
ev = build_event("tool.updated", {
|
|
135
|
+
toolName: event.name,
|
|
136
|
+
kind: "updated",
|
|
137
|
+
output: event.partial_result.to_s
|
|
138
|
+
})
|
|
139
|
+
[ev]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def translate_tool_end(event)
|
|
143
|
+
kind = event.is_error ? "failed" : "completed"
|
|
144
|
+
ev = build_event("tool.updated", {
|
|
145
|
+
toolName: event.name,
|
|
146
|
+
kind: kind,
|
|
147
|
+
output: event.result.to_s,
|
|
148
|
+
durationMs: event.duration_ms
|
|
149
|
+
})
|
|
150
|
+
[ev]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def translate_session_end(event)
|
|
154
|
+
@turn_active = false
|
|
155
|
+
response_text = event.result.to_s
|
|
156
|
+
|
|
157
|
+
ev = build_event("turn.completed", {
|
|
158
|
+
response: response_text,
|
|
159
|
+
turnCount: event.turn_count,
|
|
160
|
+
toolCallsMade: event.tool_calls_made,
|
|
161
|
+
inputTokens: event.input_tokens,
|
|
162
|
+
outputTokens: event.output_tokens,
|
|
163
|
+
cost: event.cost
|
|
164
|
+
})
|
|
165
|
+
[ev]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def translate_error(event)
|
|
169
|
+
return [] if @turn_active
|
|
170
|
+
translate_turn_failed(event.error)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def translate_turn_failed(message)
|
|
174
|
+
@turn_active = false
|
|
175
|
+
ev = build_event("turn.failed", {
|
|
176
|
+
error: { message: message.to_s }
|
|
177
|
+
})
|
|
178
|
+
[ev]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def build_event(type, payload)
|
|
182
|
+
event = {
|
|
183
|
+
type: type,
|
|
184
|
+
seq: next_seq,
|
|
185
|
+
payload: payload,
|
|
186
|
+
turnId: @turn_id,
|
|
187
|
+
sessionId: @session_id
|
|
188
|
+
}
|
|
189
|
+
@events << event
|
|
190
|
+
event
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module AppServer
|
|
7
|
+
# Protocol-aware permission handler that integrates with ask-agent's
|
|
8
|
+
# before_tool_call hook system.
|
|
9
|
+
#
|
|
10
|
+
# When a blocked tool is called, this handler:
|
|
11
|
+
# 1. Sends an `interaction/requestPermission` protocol message to the client
|
|
12
|
+
# 2. Blocks the tool thread until the client responds (or timeout)
|
|
13
|
+
# 3. Returns { action: :proceed } if approved, { action: :block } if denied
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# handler = PermissionHandler.new(mode: :on_request)
|
|
17
|
+
# handler.on_request { |req_id, tool_name, args| send_protocol_message(...) }
|
|
18
|
+
#
|
|
19
|
+
# # Wire into ask-agent session
|
|
20
|
+
# session = Ask::Agent::Session.new(hooks: { before_tool: [handler] })
|
|
21
|
+
#
|
|
22
|
+
# # When the client responds:
|
|
23
|
+
# handler.handle_response(request_id, "approve")
|
|
24
|
+
class PermissionHandler
|
|
25
|
+
# Default tools that require permission.
|
|
26
|
+
DEFAULT_BLOCKED_TOOLS = %i[write edit bash destroy].freeze
|
|
27
|
+
|
|
28
|
+
# Default timeout in seconds.
|
|
29
|
+
DEFAULT_TIMEOUT = 300
|
|
30
|
+
|
|
31
|
+
attr_reader :mode
|
|
32
|
+
|
|
33
|
+
# @param mode [Symbol] :on_request (ask for dangerous tools) or :never (allow all)
|
|
34
|
+
# @param blocked_tools [Array<Symbol>] list of tool names that require permission
|
|
35
|
+
# @param timeout [Integer] seconds to wait for client response
|
|
36
|
+
def initialize(mode: :on_request, blocked_tools: nil, timeout: DEFAULT_TIMEOUT)
|
|
37
|
+
@mode = mode
|
|
38
|
+
@blocked_tools = (blocked_tools || DEFAULT_BLOCKED_TOOLS).map(&:to_sym)
|
|
39
|
+
@timeout = timeout
|
|
40
|
+
@pending = {}
|
|
41
|
+
@sender = nil
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@logger = Logger.new($stdout, level: ENV["DEBUG"] ? Logger::DEBUG : Logger::WARN)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Register a callback for sending the protocol message.
|
|
47
|
+
# The callback receives (request_id, tool_name, tool_arguments).
|
|
48
|
+
def on_request(&block)
|
|
49
|
+
@sender = block
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Hook interface for Ask::Agent::Session's before_tool_call chain.
|
|
53
|
+
# Returns { action: :proceed } or { action: :block, reason: "..." }.
|
|
54
|
+
def before_tool_call(tool_call, _context = {})
|
|
55
|
+
return { action: :proceed } unless @blocked_tools.include?(tool_call.name.to_sym)
|
|
56
|
+
return { action: :proceed } if @mode == :never
|
|
57
|
+
|
|
58
|
+
request_approval(tool_call)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Called by the server when the client responds to a permission request.
|
|
62
|
+
#
|
|
63
|
+
# @param request_id [String] the ID that was sent in the permission request
|
|
64
|
+
# @param decision [String] "approve" or "deny"
|
|
65
|
+
# @param reason [String, nil] optional reason from the client
|
|
66
|
+
def handle_response(request_id, decision, reason: nil)
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
entry = @pending[request_id]
|
|
69
|
+
return false unless entry
|
|
70
|
+
|
|
71
|
+
entry[:responded] = true
|
|
72
|
+
entry[:approved] = (decision.to_s == "approve")
|
|
73
|
+
entry[:reason] = reason
|
|
74
|
+
entry[:condition].signal
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Cancel all pending permission requests (e.g., on session shutdown).
|
|
80
|
+
def cancel_all!
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
@pending.each_value do |entry|
|
|
83
|
+
entry[:responded] = true
|
|
84
|
+
entry[:approved] = false
|
|
85
|
+
entry[:reason] = "Permission request cancelled"
|
|
86
|
+
entry[:condition].signal
|
|
87
|
+
end
|
|
88
|
+
@pending.clear
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Number of pending permission requests.
|
|
93
|
+
def pending_count
|
|
94
|
+
@mutex.synchronize { @pending.size }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Are there any pending permission requests?
|
|
98
|
+
def pending?
|
|
99
|
+
pending_count > 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def request_approval(tool_call)
|
|
105
|
+
request_id = SecureRandom.uuid
|
|
106
|
+
condition = ConditionVariable.new
|
|
107
|
+
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
@pending[request_id] = {
|
|
110
|
+
tool_call: tool_call,
|
|
111
|
+
condition: condition,
|
|
112
|
+
responded: false,
|
|
113
|
+
approved: false,
|
|
114
|
+
reason: nil,
|
|
115
|
+
created_at: Time.now
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
@logger.debug("Requesting permission for #{tool_call.name} (#{request_id})")
|
|
120
|
+
|
|
121
|
+
# Send the permission request via the registered callback
|
|
122
|
+
@sender&.call(request_id, tool_call.name.to_s, tool_call.arguments)
|
|
123
|
+
|
|
124
|
+
# Block the tool thread until the client responds
|
|
125
|
+
response_reason = nil
|
|
126
|
+
was_approved = false
|
|
127
|
+
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
deadline = Time.now + @timeout
|
|
130
|
+
|
|
131
|
+
loop do
|
|
132
|
+
entry = @pending[request_id]
|
|
133
|
+
break unless entry # cancelled or already processed
|
|
134
|
+
break if entry[:responded] # response received
|
|
135
|
+
|
|
136
|
+
remaining = deadline - Time.now
|
|
137
|
+
if remaining <= 0
|
|
138
|
+
@pending.delete(request_id)
|
|
139
|
+
@logger.debug("Permission request #{request_id} timed out")
|
|
140
|
+
return { action: :block, reason: "Permission request timed out after #{@timeout}s" }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
condition.wait(@mutex, remaining)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
entry = @pending.delete(request_id)
|
|
147
|
+
if entry
|
|
148
|
+
was_approved = entry[:approved]
|
|
149
|
+
response_reason = entry[:reason]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if was_approved
|
|
154
|
+
@logger.debug("Permission granted for #{request_id}")
|
|
155
|
+
{ action: :proceed }
|
|
156
|
+
else
|
|
157
|
+
@logger.debug("Permission denied for #{request_id}: #{response_reason}")
|
|
158
|
+
{ action: :block, reason: response_reason || "Permission denied" }
|
|
159
|
+
end
|
|
160
|
+
rescue => e
|
|
161
|
+
@pending.delete(request_id) rescue nil
|
|
162
|
+
@logger.debug("Permission error: #{e.message}")
|
|
163
|
+
{ action: :block, reason: "Permission error: #{e.message}" }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|