ask-coding-providers 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c705fccdf37e62127cbbff2ad58fa7102ac202429ab07fe291fe8b649ab3ddd4
4
+ data.tar.gz: 3884a19f15a0a7595b0d4a56787f38ff00d500438d4656d78db8ed3cb42332f9
5
+ SHA512:
6
+ metadata.gz: 360d444a0f376927de910213f38d30f3ac1cfca4c86b1c85e8e44e5cd60f91dcb57feb4ce976d6124bf20a10d8bf5c2e920eaa682fd6393e74d087f5cfd72e06
7
+ data.tar.gz: 9860f5295b9926fa6317aeb6f3cb25c94c8082eed66b8a04d5f6f3d5a377c6c4d695f356772d9edc7dbdc9b6f1ee026bea5b3ff809968fcf758526128a6eb66a
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Ask
6
+ module CodingProviders
7
+ module ACP
8
+ # Adapter that connects to any ACP-speaking coding agent over stdio.
9
+ #
10
+ # ACP (Agent Client Protocol) is a JSON-RPC 2.0 standard for agent-editor
11
+ # communication. This adapter wraps the ACP client into the generic
12
+ # {Ask::CodingProviders::Adapter} interface. It is the **primary
13
+ # recommended adapter** for new deployments.
14
+ #
15
+ # Works with any ACP-compatible agent:
16
+ # - **Codex** — `ACP_COMMAND='codex acp'` (native ACP support)
17
+ # - **Claude Code** — `ACP_COMMAND='claude acp'` (native ACP support)
18
+ # - **OpenCode** — `ACP_COMMAND='opencode acp'` (native ACP support)
19
+ # - **ZCode** — via ACP bridges like william0wang/zcode-acp or
20
+ # alexeygrigorev/zcode-acp
21
+ # - **Gemini CLI** — `ACP_COMMAND='gemini-cli acp'` (native ACP support)
22
+ #
23
+ # @example
24
+ # adapter = Ask::CodingProviders::ACP::Adapter.new(
25
+ # command: ["codex", "acp"],
26
+ # cwd: Dir.pwd
27
+ # )
28
+ # adapter.start
29
+ # sid = adapter.create_session("/tmp")
30
+ # adapter.send_and_stream(sid, "Hello") { |ev| puts ev }
31
+ class Adapter < Ask::CodingProviders::Adapter
32
+ # @param command [Array<String>] the command to spawn the ACP agent
33
+ # @param cwd [String] working directory
34
+ # @param request_timeout [Float] timeout for ACP requests
35
+ def initialize(command:, cwd: ".", request_timeout: 60.0)
36
+ require "ask/acp"
37
+
38
+ @command = command
39
+ @cwd = cwd
40
+ @request_timeout = request_timeout
41
+ @client = nil
42
+ @sessions = {}
43
+ end
44
+
45
+ def start
46
+ return if @client
47
+ @client = Ask::ACP::Client.new(command: @command, request_timeout: @request_timeout)
48
+ @client.start
49
+ @client.initialize!(client_name: "askoda", client_version: "0.1.0")
50
+ end
51
+
52
+ def stop
53
+ @client&.stop
54
+ @client = nil
55
+ @sessions = {}
56
+ end
57
+
58
+ def running?
59
+ @client&.running? || false
60
+ end
61
+
62
+ def create_session(workspace_path, mode: nil)
63
+ ensure_running
64
+ params = { cwd: workspace_path || @cwd }
65
+ session = @client.session_new(**params)
66
+ sid = session[:id]
67
+ @sessions[sid] = { workspace: workspace_path, mode: mode, created_at: Time.now }
68
+ sid
69
+ end
70
+
71
+ def resume_session(session_id)
72
+ ensure_running
73
+ @client.session_resume(session_id)
74
+ rescue => e
75
+ {}
76
+ end
77
+
78
+ def subscribe(session_id, after_seq: 0)
79
+ ensure_running
80
+ { "eventSeq" => 0 }
81
+ end
82
+
83
+ def send_message(session_id, content)
84
+ ensure_running
85
+ @client.session_prompt(session_id, content)
86
+ end
87
+
88
+ def send_and_stream(session_id, content, turn_timeout: 600.0, &block)
89
+ return enum_for(:send_and_stream, session_id, content, turn_timeout: turn_timeout) unless block
90
+ ensure_running
91
+
92
+ accumulated = ""
93
+ block.call({ type: "turn.started", seq: 1, payload: { "sessionId" => session_id } })
94
+
95
+ result = @client.session_prompt(session_id, content, timeout: turn_timeout) do |event|
96
+ case event[:method]
97
+ when "text"
98
+ delta = event.dig(:params, "content") || ""
99
+ accumulated += delta
100
+ block.call({
101
+ type: "model.streaming", seq: 2,
102
+ payload: { "delta" => delta, "sessionId" => session_id }
103
+ }) unless delta.empty?
104
+ when "turn_complete"
105
+ block.call({
106
+ type: "turn.completed", seq: 3,
107
+ payload: { "response" => accumulated, "sessionId" => session_id }
108
+ })
109
+ when "turn_failed"
110
+ err = event.dig(:params, "error") || "Unknown error"
111
+ block.call({
112
+ type: "turn.failed", seq: 3,
113
+ payload: { "error" => { "message" => err }, "sessionId" => session_id }
114
+ })
115
+ end
116
+ end
117
+
118
+ # If the prompt response contains the result and we haven't sent completed yet
119
+ if result.is_a?(Hash)
120
+ stop_reason = result["stopReason"] || result[:stopReason]
121
+ if stop_reason == "cancelled" || stop_reason == "refusal"
122
+ block.call({
123
+ type: "turn.failed", seq: 3,
124
+ payload: { "error" => { "message" => "Turn #{stop_reason}" }, "sessionId" => session_id }
125
+ })
126
+ end
127
+ end
128
+ rescue => e
129
+ block.call({
130
+ type: "turn.failed", seq: 3,
131
+ payload: { "error" => { "message" => e.message }, "sessionId" => session_id }
132
+ })
133
+ end
134
+
135
+ def get_events(session_id, after_seq:, limit: nil)
136
+ ensure_running
137
+ {}
138
+ end
139
+
140
+ def respond(request_id, result)
141
+ # ACP uses notification-based reverse requests, no direct respond
142
+ end
143
+
144
+ def get_workspace_state(workspace_path)
145
+ ensure_running
146
+ {}
147
+ rescue => e
148
+ {}
149
+ end
150
+
151
+ # Build an ACP adapter from config.
152
+ # Reads ACP_COMMAND env var (space-separated command + args).
153
+ def self.from_config(workspace_path: Dir.pwd, cli_path: nil, request_timeout: 600, **)
154
+ cmd = cli_path || ENV["ACP_COMMAND"] || raise(
155
+ Ask::CodingProviders::ConfigurationError,
156
+ "Missing ACP_COMMAND. Set ACP_COMMAND or pass cli_path, e.g. ACP_COMMAND='codex acp'"
157
+ )
158
+ new(
159
+ command: cmd.is_a?(String) ? cmd.split : cmd,
160
+ cwd: workspace_path,
161
+ request_timeout: request_timeout
162
+ )
163
+ end
164
+
165
+ private
166
+
167
+ def ensure_running
168
+ raise Error, "ACP adapter is not running. Call #start first." unless @client&.running?
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ Ask::CodingProviders.register_adapter(:acp, Ask::CodingProviders::ACP::Adapter)
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "acp/adapter"
4
+
5
+ module Ask
6
+ module CodingProviders
7
+ module ACP
8
+ class Error < Ask::CodingProviders::Error; end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module CodingProviders
5
+ # Abstract base class for coding agent adapters.
6
+ #
7
+ # A coding agent adapter wraps an AI coding agent (e.g., ZCode, Claude Code, Codex)
8
+ # and provides a uniform interface for the coder engine to interact with it.
9
+ #
10
+ # Subclasses must implement all methods that raise NotImplementedError.
11
+ class Adapter
12
+ # Create a new session on the agent.
13
+ #
14
+ # @param workspace_path [String] the working directory
15
+ # @param mode [String, nil] permission mode
16
+ # @return [String] the session ID
17
+ def create_session(workspace_path, mode: nil)
18
+ raise NotImplementedError, "#{self.class} must implement #create_session"
19
+ end
20
+
21
+ # Resume an existing session.
22
+ #
23
+ # @param session_id [String] the session ID
24
+ # @return [Hash] session snapshot
25
+ def resume_session(session_id)
26
+ raise NotImplementedError, "#{self.class} must implement #resume_session"
27
+ end
28
+
29
+ # List available sessions.
30
+ #
31
+ # @param workspace_path [String, nil] optional filter
32
+ # @param limit [Integer] max sessions
33
+ # @return [Array<Hash>] session summaries
34
+ def list_sessions(workspace_path: nil, limit: 20)
35
+ raise NotImplementedError, "#{self.class} must implement #list_sessions"
36
+ end
37
+
38
+ # Subscribe to session events.
39
+ #
40
+ # @param session_id [String] the session ID
41
+ # @param after_seq [Integer] sequence to start from
42
+ # @return [Hash] subscription result
43
+ def subscribe(session_id, after_seq: 0)
44
+ raise NotImplementedError, "#{self.class} must implement #subscribe"
45
+ end
46
+
47
+ # Send a message to a session (fire-and-forget).
48
+ def send_message(session_id, content)
49
+ raise NotImplementedError, "#{self.class} must implement #send_message"
50
+ end
51
+
52
+ # Send a message and stream the response events.
53
+ #
54
+ # @yield [Hash] stream events with :type, :payload, :seq
55
+ def send_and_stream(session_id, content, turn_timeout: 600.0, &block)
56
+ raise NotImplementedError, "#{self.class} must implement #send_and_stream"
57
+ end
58
+
59
+ # Get events after a sequence number (polling).
60
+ #
61
+ # @return [Hash] events list
62
+ def get_events(session_id, after_seq:, limit: nil)
63
+ raise NotImplementedError, "#{self.class} must implement #get_events"
64
+ end
65
+
66
+ # Respond to a reverse request (permission approval, etc.).
67
+ def respond(request_id, result)
68
+ raise NotImplementedError, "#{self.class} must implement #respond"
69
+ end
70
+
71
+ # Read the workspace state (model info, settings, etc.).
72
+ def get_workspace_state(workspace_path)
73
+ {}
74
+ end
75
+
76
+ # Called when a session operation fails. The adapter can signal how
77
+ # the Engine should recover.
78
+ #
79
+ # @param session_id [String] the session that failed
80
+ # @param error [StandardError] the error that occurred
81
+ # @return [Symbol, nil] :create_new to replace the session, nil to show error as-is
82
+ def handle_session_error(session_id, error)
83
+ nil # Unknown error — let the Engine show it to the user
84
+ end
85
+
86
+ # Optional: list available projects with session counts.
87
+ # Returns [{project_id:, directory:, session_count:}] or nil.
88
+ def list_projects
89
+ nil
90
+ end
91
+
92
+ # Optional: find sessions in a directory.
93
+ # Returns [{session_id:, title:, updated:}] or [].
94
+ def find_sessions(directory:, limit: 20)
95
+ []
96
+ end
97
+
98
+ # Optional: find the single most recent session.
99
+ # Returns {session_id:, directory:} or nil.
100
+ def find_recent_session
101
+ nil
102
+ end
103
+
104
+ # Optional: find the most recent TUI session in a workspace.
105
+ # Returns {session_id:, title:, directory:} or nil.
106
+ def find_recent_tui_session(workspace_path)
107
+ nil
108
+ end
109
+
110
+ # Optional: get a session's workspace directory by ID.
111
+ # Returns directory string or nil.
112
+ def session_directory(session_id)
113
+ nil
114
+ end
115
+
116
+ # Optional: get session message history.
117
+ # Returns [{text:, role:, origin:}] or [].
118
+ def session_history(session_id, limit: 100)
119
+ []
120
+ end
121
+
122
+ # Optional: list recent sessions across all projects.
123
+ # Returns [{session_id:, title:, updated:, msg_count:}] or [].
124
+ def recent_sessions
125
+ []
126
+ end
127
+
128
+ # Start the agent connection.
129
+ def start
130
+ end
131
+
132
+ # Stop the agent connection.
133
+ def stop
134
+ end
135
+
136
+ # Whether the agent is running.
137
+ def running?
138
+ true
139
+ end
140
+
141
+ # Build an adapter instance from a configuration hash.
142
+ # Override in subclasses to extract adapter-specific settings.
143
+ # The default passes the entire hash to +new+.
144
+ #
145
+ # @param config [Hash] configuration options (typically from ENV)
146
+ # @return [Adapter] a configured adapter instance
147
+ def self.from_config(**config)
148
+ new(**config)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Ask
6
+ module CodingProviders
7
+ module AskAgent
8
+ # Adapter that wraps Ask::Agent::Session directly (in-process).
9
+ #
10
+ # No app-server, no external binary — sessions run in the same process.
11
+ # Perfect for deployments where running a separate process is impractical.
12
+ #
13
+ # @example
14
+ # adapter = AskAgent::Adapter.new(model: "deepseek-v4-flash", provider: "opencode_go")
15
+ # adapter.start
16
+ # sid = adapter.create_session("/tmp")
17
+ # adapter.send_and_stream(sid, "Hello") { |ev| puts ev }
18
+ class Adapter < Ask::CodingProviders::Adapter
19
+ # @param model [String] model ID (e.g. "deepseek-v4-flash")
20
+ # @param provider [String] provider slug (e.g. "opencode_go")
21
+ # @param tools [Array] tool instances to make available
22
+ # @param max_turns [Integer] max conversation turns per session
23
+ def initialize(model:, provider:, tools: [], max_turns: 25, **session_opts)
24
+ # Lazy require — these gems are optional for the gem but required for this adapter
25
+ begin
26
+ require "ask-llm-providers"
27
+ require "ask/agent"
28
+ rescue LoadError => e
29
+ raise "Missing dependency for AskAgent adapter: #{e.message}. Add ask-agent and ask-llm-providers to your Gemfile."
30
+ end
31
+
32
+ @model_id = model
33
+ @provider_slug = provider
34
+ @tools = tools
35
+ @max_turns = max_turns
36
+ @session_opts = session_opts
37
+ @started = false
38
+ @provider = nil
39
+ end
40
+
41
+ def start
42
+ return if @started
43
+ klass = Ask::Provider.resolve(@provider_slug)
44
+ compat = klass.respond_to?(:compat_config) ? klass.compat_config : {}
45
+ api_key = ENV[compat[:alternate_env].to_s] || ENV[compat[:api_key_env].to_s] || ENV["#{@provider_slug.upcase}_API_KEY"]
46
+ @provider = klass.new(api_key: api_key)
47
+ @started = true
48
+ end
49
+
50
+ def stop
51
+ @started = false
52
+ @provider = nil
53
+ end
54
+
55
+ def running?
56
+ @started
57
+ end
58
+
59
+ # Create a new conversation session.
60
+ # The workspace_path is noted but not used by in-process agent.
61
+ # Returns a session ID (UUID).
62
+ def create_session(workspace_path, mode: nil)
63
+ ensure_started
64
+ sid = "sess_#{SecureRandom.uuid}"
65
+ @sessions ||= {}
66
+ @sessions[sid] = { workspace: workspace_path, mode: mode, created_at: Time.now }
67
+ sid
68
+ end
69
+
70
+ def resume_session(session_id)
71
+ ensure_started
72
+ @sessions&.dig(session_id) || {}
73
+ end
74
+
75
+ def subscribe(session_id, after_seq: 0)
76
+ ensure_started
77
+ { "eventSeq" => 0 }
78
+ end
79
+
80
+ def send_message(session_id, content)
81
+ ensure_started
82
+ # Run synchronously — returns when done
83
+ result = run_session(session_id, content)
84
+ { "response" => result }
85
+ end
86
+
87
+ def send_and_stream(session_id, content, turn_timeout: 600.0, &block)
88
+ return enum_for(:send_and_stream, session_id, content, turn_timeout: turn_timeout) unless block
89
+ ensure_started
90
+
91
+ # Build the ask-agent chat with our pre-configured provider
92
+ chat = build_chat
93
+ session = Ask::Agent::Session.new(
94
+ model: chat,
95
+ max_turns: @max_turns,
96
+ **@session_opts
97
+ )
98
+
99
+ # Emit turn.started
100
+ block.call({ type: "turn.started", seq: 1, payload: { "sessionId" => session_id } })
101
+
102
+ # Wire streaming events
103
+ session.on_event do |event|
104
+ case event
105
+ when Ask::Agent::Events::TextDelta
106
+ block.call({
107
+ type: "model.streaming", seq: 2,
108
+ payload: { "delta" => event.content, "sessionId" => session_id }
109
+ })
110
+ end
111
+ end
112
+
113
+ begin
114
+ result = session.run(content)
115
+ block.call({
116
+ type: "turn.completed", seq: 3,
117
+ payload: { "response" => result, "sessionId" => session_id,
118
+ "tokenCount" => session.total_input_tokens + session.total_output_tokens }
119
+ })
120
+ rescue => e
121
+ block.call({
122
+ type: "turn.failed", seq: 3,
123
+ payload: { "error" => { "message" => e.message }, "sessionId" => session_id }
124
+ })
125
+ end
126
+ end
127
+
128
+ def get_events(session_id, after_seq:, limit: nil)
129
+ { "events" => [] }
130
+ end
131
+
132
+ def respond(request_id, result)
133
+ # No reverse requests in basic mode
134
+ end
135
+
136
+ def get_workspace_state(workspace_path)
137
+ # No workspace state to report
138
+ {}
139
+ end
140
+
141
+ # Build an AskAgent adapter from config.
142
+ # Reads ASK_AGENT_MODEL, ASK_AGENT_LLM_PROVIDER, ASK_AGENT_MAX_TURNS from ENV.
143
+ def self.from_config(model: nil, llm_provider: nil, max_turns: nil, **)
144
+ new(
145
+ model: model || ENV.fetch("ASK_AGENT_MODEL", "deepseek-v4-flash"),
146
+ provider: llm_provider || ENV.fetch("ASK_AGENT_LLM_PROVIDER", "opencode_go"),
147
+ max_turns: (max_turns || ENV.fetch("ASK_AGENT_MAX_TURNS", "10")).to_i
148
+ )
149
+ end
150
+
151
+ private
152
+
153
+ def ensure_started
154
+ raise "Adapter not started. Call #start first." unless @started
155
+ end
156
+
157
+ def build_chat
158
+ chat = Ask::Agent::Chat.new(
159
+ model: @model_id,
160
+ provider: @provider_slug,
161
+ tools: @tools
162
+ )
163
+ # Inject pre-configured provider to bypass Ask::Auth.resolve
164
+ chat.instance_variable_set(:@provider, @provider)
165
+ chat
166
+ end
167
+
168
+ def run_session(session_id, content)
169
+ chat = build_chat
170
+ session = Ask::Agent::Session.new(
171
+ model: chat,
172
+ max_turns: @max_turns,
173
+ **@session_opts
174
+ )
175
+ session.run(content)
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ Ask::CodingProviders.register_adapter(:ask_agent, Ask::CodingProviders::AskAgent::Adapter)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ask_agent/adapter"
4
+
5
+ module Ask
6
+ module CodingProviders
7
+ # AskAgent coding provider — uses Ask::Agent::Session in-process.
8
+ #
9
+ # No app-server, no external binary. Sessions run in the same process.
10
+ #
11
+ # @example
12
+ # adapter = Ask::CodingProviders::AskAgent::Adapter.new(
13
+ # model: "deepseek-v4-flash",
14
+ # provider: "opencode_go"
15
+ # )
16
+ module AskAgent
17
+ end
18
+ end
19
+ end