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.
@@ -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