ask-coding-harness 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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module CodingHarness
5
+ # Configuration for the coding harness.
6
+ #
7
+ # Global instance: Ask::CodingHarness.config. Settings are read from
8
+ # environment variables when not set explicitly, so a zero-config
9
+ # `ach serve` works out of the box.
10
+ #
11
+ # Ask::CodingHarness.configure do |c|
12
+ # c.workspace = "/path/to/project"
13
+ # c.model = "claude-sonnet-4"
14
+ # c.approval = :require
15
+ # end
16
+ class Config
17
+ # Default tools made available to the agent. All come from
18
+ # ask-tools-shell and run through Ask::Sandbox.provider.
19
+ DEFAULT_TOOLS = %w[bash read write edit glob grep code apply_patch].freeze
20
+
21
+ # Tools that queue for human approval when approval mode is :require.
22
+ DEFAULT_APPROVAL_REQUIRED = %w[bash write edit apply_patch code repl].freeze
23
+
24
+ # Approval modes:
25
+ # :off — never prompt; everything executes immediately
26
+ # :require — mutating/dangerous tools queue for human approval
27
+ # :auto — auto-approve everything (same as :off, but the queue
28
+ # exists and can be inspected)
29
+ APPROVAL_MODES = %i[off require auto].freeze
30
+
31
+ attr_accessor :host, :workspace, :db_path, :model,
32
+ :max_turns, :turn_timeout, :approval_required,
33
+ :plan_mode, :todos, :tools, :adapter, :adapter_opts
34
+
35
+ def initialize
36
+ @host = ENV.fetch("ACH_HOST", "0.0.0.0")
37
+ @port = (ENV["ACH_PORT"] || "8080").to_i
38
+ @workspace = ENV["ACH_WORKSPACE"] || ENV["ASKODA_WORKSPACE"] || Dir.pwd
39
+ @db_path = ENV["ACH_DB_PATH"] || ENV["ASKODA_DB_PATH"] || default_db_path
40
+ @model = ENV["ACH_MODEL"] || ENV["ASK_AGENT_MODEL"] || "deepseek-v4-flash"
41
+ @max_turns = (ENV["ACH_MAX_TURNS"] || ENV["ASK_AGENT_MAX_TURNS"] || "25").to_i
42
+ @turn_timeout = (ENV["ACH_TURN_TIMEOUT"] || "600").to_i
43
+ @approval = :require
44
+ self.approval = (ENV["ACH_APPROVAL"] || "require").to_sym
45
+ @approval_required = DEFAULT_APPROVAL_REQUIRED.dup
46
+ @plan_mode = env_flag("ACH_PLAN_MODE", default: false)
47
+ @todos = env_flag("ACH_TODOS", default: true)
48
+ @tools = DEFAULT_TOOLS.dup
49
+ @adapter = ENV["ACH_ADAPTER"] || ENV["CODING_PROVIDER"] || "ask_agent"
50
+ @adapter_opts = {}
51
+ validate!
52
+ end
53
+
54
+ def port
55
+ @port
56
+ end
57
+
58
+ def port=(value)
59
+ @port = value.to_i
60
+ raise ArgumentError, "invalid port: #{@port.inspect}" if @port <= 0 || @port > 65_535
61
+ @port
62
+ end
63
+
64
+ def approval
65
+ @approval
66
+ end
67
+
68
+ def approval=(value)
69
+ mode = value.to_sym
70
+ unless APPROVAL_MODES.include?(mode)
71
+ raise ArgumentError, "approval must be one of #{APPROVAL_MODES.inspect}, got #{value.inspect}"
72
+ end
73
+ @approval = mode
74
+ end
75
+
76
+ # True when the approval queue is active (mode :require or :auto).
77
+ def approvals?
78
+ %i[require auto].include?(@approval)
79
+ end
80
+
81
+ # True when mutating tools queue for human approval.
82
+ def approval_required?
83
+ @approval == :require
84
+ end
85
+
86
+ # Names of the tools the approval policy gates behind human approval.
87
+ def approval_policy_tools
88
+ return [] unless approval_required?
89
+ Array(@approval_required).map(&:to_s)
90
+ end
91
+
92
+ private
93
+
94
+ def env_flag(name, default:)
95
+ case ENV[name]
96
+ when "1", "true", "yes" then true
97
+ when "0", "false", "no" then false
98
+ else default
99
+ end
100
+ end
101
+
102
+ def default_db_path
103
+ File.join(Dir.pwd, "data", "ask-coding-harness.db")
104
+ end
105
+
106
+ def validate!
107
+ if @port.to_i <= 0 || @port.to_i > 65_535
108
+ raise ArgumentError, "invalid port: #{@port.inspect}"
109
+ end
110
+ if @workspace.to_s.empty?
111
+ raise ArgumentError, "workspace must not be empty"
112
+ end
113
+ if @max_turns.to_i <= 0
114
+ raise ArgumentError, "max_turns must be positive"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+ require "json"
5
+ require "thread"
6
+
7
+ require "ask-coding-providers"
8
+
9
+ module Ask
10
+ module CodingHarness
11
+ # A scripted coding agent for trying the harness without API keys.
12
+ #
13
+ # Walks through a realistic turn — todos, tool calls with output, a
14
+ # diff, and a tool call queued for human approval — waiting on the
15
+ # approval queue exactly like the real ask_agent adapter. The text and
16
+ # file contents are generated from the workspace it is pointed at, so
17
+ # every run feels like the real thing.
18
+ #
19
+ # ach demo # serve the web UI against the demo agent
20
+ class DemoAdapter < Ask::CodingProviders::Adapter
21
+ # @param approval [Symbol] :require waits for human approval on the
22
+ # queued tool call; :off/:auto approves it immediately (headless).
23
+ def initialize(approval: :require, **)
24
+ @approval = approval.to_sym
25
+ @pending = {}
26
+ @next_action_id = 1
27
+ @mutex = Mutex.new
28
+ @cv = ConditionVariable.new
29
+ @aborted = false
30
+ @sessions = {}
31
+ end
32
+
33
+ def self.from_config(**config)
34
+ new(**config)
35
+ end
36
+
37
+ def start; end
38
+ def stop
39
+ @mutex.synchronize { @aborted = true; @cv.broadcast }
40
+ end
41
+
42
+ def create_session(workspace_path, mode: nil, model: nil)
43
+ sid = "demo_#{@sessions.size + 1}"
44
+ @sessions[sid] = workspace_path
45
+ sid
46
+ end
47
+
48
+ def send_and_stream(session_id, content, turn_timeout: 600.0, &block)
49
+ workspace = @sessions[session_id] || Dir.pwd
50
+ emit(block, "turn.started", {})
51
+
52
+ emit(block, "model.streaming", { "delta" => "Alright, let me take a look at #{File.basename(workspace)}. " })
53
+ emit(block, "model.streaming", { "delta" => "I'll start by mapping the project structure.\n\n" })
54
+
55
+ emit(block, "todos.updated", {
56
+ "todos" => [
57
+ { "id" => "1", "title" => "Inspect the project structure", "status" => "in_progress" },
58
+ { "id" => "2", "title" => "Find the main entry point", "status" => "pending" },
59
+ { "id" => "3", "title" => "Propose improvements", "status" => "pending" }
60
+ ]
61
+ })
62
+
63
+ files = list_files(workspace)
64
+ emit(block, "tool.use", {
65
+ "toolName" => "bash", "toolCallId" => "call_1",
66
+ "input" => { "command" => "find . -type f | head -30" }
67
+ })
68
+ emit(block, "tool.result", {
69
+ "toolName" => "bash", "toolCallId" => "call_1",
70
+ "output" => files.join("\n"),
71
+ "isError" => false, "durationMs" => 84
72
+ })
73
+
74
+ readme = read_first(workspace, %w[README.md readme.md])
75
+ if readme
76
+ emit(block, "tool.use", {
77
+ "toolName" => "read", "toolCallId" => "call_2",
78
+ "input" => { "path" => readme[0] }
79
+ })
80
+ emit(block, "tool.result", {
81
+ "toolName" => "read", "toolCallId" => "call_2",
82
+ "output" => readme[1],
83
+ "isError" => false, "durationMs" => 12
84
+ })
85
+ end
86
+
87
+ emit(block, "todos.updated", {
88
+ "todos" => [
89
+ { "id" => "1", "title" => "Inspect the project structure", "status" => "completed" },
90
+ { "id" => "2", "title" => "Find the main entry point", "status" => "completed" },
91
+ { "id" => "3", "title" => "Propose improvements", "status" => "in_progress" }
92
+ ]
93
+ })
94
+
95
+ emit(block, "model.streaming", {
96
+ "delta" => "I can see the project has #{files.size} files. Here's a diff I'd like to apply — it needs your approval first:\n\n"
97
+ })
98
+
99
+ emit(block, "tool.use", {
100
+ "toolName" => "apply_patch", "toolCallId" => "call_3",
101
+ "input" => { "patch" => "diff --git a/README.md b/README.md\nindex 8f1a2b3..c9d4e5f 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,3 +1,4 @@\n # #{File.basename(workspace)}\n \n+Generated by the ask-coding-harness demo agent.\n" }
102
+ })
103
+
104
+ # Queue an approval — the demo pauses here until the user decides
105
+ # (or auto-approves in headless mode).
106
+ action_id = queue_action(block, "apply_patch", { "patch" => "…" }, "Applying a patch to README.md requires approval")
107
+ outcome =
108
+ if %i[off auto].include?(@approval)
109
+ @mutex.synchronize do
110
+ @pending[action_id] = @pending[action_id].merge("status" => "approved")
111
+ end
112
+ :approved
113
+ else
114
+ wait_for_decision(action_id)
115
+ end
116
+
117
+ case outcome
118
+ when :approved
119
+ emit(block, "approval.updated", { "actionId" => action_id, "status" => "approved" })
120
+ emit(block, "tool.result", {
121
+ "toolName" => "apply_patch", "toolCallId" => "call_3",
122
+ "output" => "Patch applied to README.md",
123
+ "isError" => false, "durationMs" => 5
124
+ })
125
+ emit(block, "model.streaming", {
126
+ "delta" => "Done — README.md now documents that this workspace was demoed by the harness. " \
127
+ "Try asking me to do something real: point the harness at a project, add an API key, " \
128
+ "and I'll work through it with tools, approvals, and diffs just like this.\n\n" \
129
+ "Things to try: run tests, refactor a file, explain an error.\n"
130
+ })
131
+ emit(block, "turn.completed", { "response" => "Demo turn completed." })
132
+ when :rejected
133
+ emit(block, "approval.updated", { "actionId" => action_id, "status" => "rejected" })
134
+ emit(block, "model.streaming", {
135
+ "delta" => "Understood — I won't touch anything. That's the approval flow working: " \
136
+ "nothing mutates without your say-so.\n"
137
+ })
138
+ emit(block, "turn.completed", { "response" => "Demo turn completed (rejected)." })
139
+ when :aborted
140
+ emit(block, "turn.aborted", {})
141
+ end
142
+ end
143
+
144
+ # ── Approval controls (same duck-typed surface as ask_agent) ──
145
+
146
+ def approve_action(_session_id, action_id)
147
+ @mutex.synchronize do
148
+ action = @pending[action_id]
149
+ next [] unless action
150
+ @pending[action_id] = action.merge("status" => "approved")
151
+ @cv.broadcast
152
+ end
153
+ end
154
+
155
+ def reject_action(_session_id, action_id)
156
+ @mutex.synchronize do
157
+ action = @pending[action_id]
158
+ next [] unless action
159
+ @pending[action_id] = action.merge("status" => "rejected")
160
+ @cv.broadcast
161
+ end
162
+ end
163
+
164
+ def approve_all(_session_id)
165
+ @mutex.synchronize do
166
+ @pending.each_value { |a| a["status"] = "approved" if a["status"] == "pending" }
167
+ @cv.broadcast
168
+ end
169
+ end
170
+
171
+ def pending_approvals(_session_id)
172
+ @mutex.synchronize do
173
+ @pending.values.select { |a| a["status"] == "pending" }
174
+ end
175
+ end
176
+
177
+ def abort(_session_id)
178
+ @mutex.synchronize { @aborted = true; @cv.broadcast }
179
+ end
180
+
181
+ # ── Registration ──
182
+
183
+ def self.install!
184
+ existing = begin
185
+ Ask::CodingProviders.resolve_adapter(:demo)
186
+ rescue Ask::CodingProviders::ConfigurationError
187
+ nil
188
+ end
189
+ Ask::CodingProviders.register_adapter(:demo, self) unless existing
190
+ end
191
+
192
+ private
193
+
194
+ def emit(block, type, payload)
195
+ block.call({ type: type, seq: 0, payload: payload })
196
+ end
197
+
198
+ def queue_action(block, tool_name, args, message)
199
+ @mutex.synchronize do
200
+ id = @next_action_id
201
+ @next_action_id += 1
202
+ @pending[id] = {
203
+ "id" => id, "tool_name" => tool_name, "args" => args,
204
+ "message" => message, "status" => "pending"
205
+ }
206
+ emit(block, "approval.required", {
207
+ "actionId" => id, "toolName" => tool_name, "args" => args,
208
+ "message" => message, "autoApprovable" => false, "status" => "pending"
209
+ })
210
+ id
211
+ end
212
+ end
213
+
214
+ # Block until the action is approved, rejected, or the turn aborts.
215
+ def wait_for_decision(action_id)
216
+ @mutex.synchronize do
217
+ loop do
218
+ return :aborted if @aborted
219
+ action = @pending[action_id]
220
+ return action["status"].to_sym if action && action["status"] != "pending"
221
+ @cv.wait(@mutex, 0.5)
222
+ end
223
+ end
224
+ end
225
+
226
+ def list_files(workspace)
227
+ ignore = %w[.git node_modules vendor tmp log coverage data]
228
+ files = []
229
+ Find.find(workspace) do |path|
230
+ rel = path.sub("#{workspace}/", "")
231
+ next if rel.empty?
232
+ parts = rel.split("/")
233
+ next if parts.any? { |p| ignore.include?(p) || p.start_with?(".") }
234
+ next unless File.file?(path)
235
+ files << rel
236
+ end
237
+ files.sort.first(30)
238
+ end
239
+
240
+ def read_first(workspace, names)
241
+ names.each do |name|
242
+ path = File.join(workspace, name)
243
+ next unless File.file?(path)
244
+ content = File.read(path, encoding: "UTF-8")
245
+ return [name, content.lines.first(40).join]
246
+ end
247
+ nil
248
+ end
249
+ end
250
+ end
251
+ end
252
+
253
+ Ask::CodingHarness::DemoAdapter.install!
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module CodingHarness
5
+ # Normalizes coding-agent adapter events (ask-coding-providers) into the
6
+ # harness SSE event schema.
7
+ #
8
+ # Two adapter vocabularies exist today:
9
+ #
10
+ # ask_agent (rich): turn.started, model.streaming, model.thinking,
11
+ # tool.use, tool.delta, tool.result,
12
+ # approval.required, approval.updated,
13
+ # plan.proposed, plan.approved, plan.rejected,
14
+ # todos.updated, turn.completed, turn.failed,
15
+ # turn.aborted, error
16
+ #
17
+ # acp (basic): turn.started, model.streaming, tool.use,
18
+ # tool.result, turn.completed, turn.failed
19
+ #
20
+ # Both translate into one browser-friendly schema (camelCase, flat
21
+ # payloads, no seq):
22
+ #
23
+ # turn.started, message.delta, message.thinking,
24
+ # tool.start {id, name, args}, tool.delta {id, name, partial},
25
+ # tool.end {id, name, output, isError, durationMs},
26
+ # approval.required {id, toolName, args, message, autoApprovable},
27
+ # approval.updated {id, status}, plan.proposed/approved/rejected {plan},
28
+ # todos.updated {todos}, turn.completed {response},
29
+ # turn.failed {error}, turn.aborted, error {error}
30
+ class EventTranslator
31
+ # Translate one adapter event into a harness event Hash, or nil when
32
+ # the event has no browser representation.
33
+ #
34
+ # @param event [Hash] adapter event with :type and :payload
35
+ # @return [Hash, nil] { type:, data: }
36
+ def translate(event)
37
+ payload = event[:payload] || {}
38
+ case event[:type]
39
+ when "turn.started" then event("turn.started", {})
40
+ when "model.streaming" then event("message.delta", { delta: payload["delta"] })
41
+ when "model.thinking" then event("message.thinking", { delta: payload["delta"] })
42
+ when "tool.use" then tool_start(payload)
43
+ when "tool.delta" then event("tool.delta", tool_delta(payload))
44
+ when "tool.result" then tool_end(payload)
45
+ when "approval.required" then approval_required(payload)
46
+ when "approval.updated" then event("approval.updated", { id: payload["actionId"], status: payload["status"] })
47
+ when "plan.proposed" then event("plan.proposed", { plan: payload["plan"] })
48
+ when "plan.approved" then event("plan.approved", { plan: payload["plan"] })
49
+ when "plan.rejected" then event("plan.rejected", { plan: payload["plan"] })
50
+ when "todos.updated" then event("todos.updated", { todos: payload["todos"] })
51
+ when "turn.completed" then event("turn.completed", { response: payload["response"] })
52
+ when "turn.failed" then event("turn.failed", { error: error_message(payload) })
53
+ when "turn.aborted" then event("turn.aborted", {})
54
+ when "error" then event("error", { error: error_message(payload) })
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def event(type, data)
61
+ { type: type, data: data }
62
+ end
63
+
64
+ def tool_start(payload)
65
+ event("tool.start", {
66
+ id: payload["toolCallId"] || payload["id"],
67
+ name: payload["toolName"],
68
+ args: normalize_args(payload["input"] || payload["args"])
69
+ })
70
+ end
71
+
72
+ # Tool arguments arrive as hashes (ask_agent) or JSON strings
73
+ # (repair pipeline); the browser wants a plain object.
74
+ def normalize_args(args)
75
+ return args unless args.is_a?(String)
76
+ JSON.parse(args)
77
+ rescue JSON::ParserError
78
+ args
79
+ end
80
+
81
+ def tool_delta(payload)
82
+ {
83
+ id: payload["toolCallId"] || payload["id"],
84
+ name: payload["toolName"],
85
+ partial: payload["partial"]
86
+ }
87
+ end
88
+
89
+ def tool_end(payload)
90
+ event("tool.end", {
91
+ id: payload["toolCallId"] || payload["id"],
92
+ name: payload["toolName"],
93
+ output: payload["output"],
94
+ isError: !!payload["isError"],
95
+ durationMs: payload["durationMs"]
96
+ })
97
+ end
98
+
99
+ def approval_required(payload)
100
+ event("approval.required", {
101
+ id: payload["actionId"],
102
+ toolName: payload["toolName"],
103
+ args: payload["args"],
104
+ message: payload["message"],
105
+ autoApprovable: !!payload["autoApprovable"]
106
+ })
107
+ end
108
+
109
+ def error_message(payload)
110
+ err = payload["error"]
111
+ err.is_a?(Hash) ? err["message"] : err.to_s
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module CodingHarness
7
+ # Headless run of a prompt against the workspace: drives the same
8
+ # AgentRunner as the web server, prints a readable transcript, and
9
+ # returns a Result. This is the dogfooding path — the harness builds
10
+ # itself with `ach run`.
11
+ class Runner
12
+ # Outcome of a headless run.
13
+ Result = Data.define(:success, :response, :events, :conversation_id, :error) do
14
+ def success? = success
15
+ end
16
+
17
+ # @param config [Config] harness configuration (duped; approval mode
18
+ # is overridden for headless operation)
19
+ # @param store [Store, nil] conversation store (defaults to a fresh
20
+ # Store on config.db_path)
21
+ # @param runner [AgentRunner, nil] agent runner (defaults to a fresh
22
+ # AgentRunner on the store)
23
+ # @param approval [Symbol] approval mode for headless runs; :off by
24
+ # default so nothing blocks on human review
25
+ def initialize(config:, store: nil, runner: nil, approval: :off)
26
+ @config = config.dup
27
+ @config.approval = approval
28
+ @store = store || Store.new(db_path: @config.db_path)
29
+ @runner = runner || AgentRunner.new(config: @config, store: @store)
30
+ end
31
+
32
+ # Run a prompt and wait for the turn to finish.
33
+ #
34
+ # @param prompt [String] the task
35
+ # @param workspace [String, nil] override config.workspace
36
+ # @param model [String, nil] model override
37
+ # @param quiet [Boolean] suppress the transcript output
38
+ # @return [Result]
39
+ def run(prompt, workspace: nil, model: nil, quiet: false)
40
+ @config.workspace = workspace if workspace
41
+ conversation = @store.build(directory: @config.workspace)
42
+ conversation = @store.save(conversation)
43
+
44
+ events = []
45
+ response = +""
46
+ error = nil
47
+
48
+ thread = @runner.start_turn(conversation, prompt, model: model) do |ev|
49
+ events << ev
50
+ case ev[:type]
51
+ when "message.delta" then response << ev[:data][:delta].to_s
52
+ when "turn.failed" then error = ev[:data][:error]
53
+ end
54
+ print_transcript(ev) unless quiet
55
+ end
56
+ thread.join
57
+
58
+ Result.new(
59
+ success: error.nil?,
60
+ response: response,
61
+ events: events,
62
+ conversation_id: conversation["id"],
63
+ error: error
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ def print_transcript(event)
70
+ case event[:type]
71
+ when "turn.started"
72
+ puts "\e[90m── turn started ──\e[0m"
73
+ when "message.delta"
74
+ print event[:data][:delta].to_s
75
+ $stdout.flush
76
+ when "message.thinking"
77
+ print "\e[2m#{event[:data][:delta]}\e[0m"
78
+ $stdout.flush
79
+ when "tool.start"
80
+ args = event[:data][:args]
81
+ args = JSON.generate(args) if args.is_a?(Hash) || args.is_a?(Array)
82
+ puts "\n\e[36m› #{event[:data][:name]}\e[0m #{args.to_s[0, 120]}"
83
+ when "tool.delta"
84
+ print event[:data][:partial].to_s
85
+ $stdout.flush
86
+ when "tool.end"
87
+ output = event[:data][:output].to_s
88
+ puts "" unless output.empty?
89
+ output.lines.first(20).each { |l| puts " \e[90m#{l.chomp}\e[0m" } unless output.empty?
90
+ puts " \e[90m… #{output.lines.size - 20} more lines\e[0m" if output.lines.size > 20
91
+ when "approval.required"
92
+ puts "\n\e[33m✋ #{event[:data][:toolName]} requires approval\e[0m"
93
+ when "approval.updated"
94
+ puts "\n\e[33m✋ #{event[:data][:toolName]} → #{event[:data][:status]}\e[0m"
95
+ when "plan.proposed"
96
+ puts "\n\e[35m📋 Plan:\e[0m #{event[:data][:plan]}"
97
+ when "todos.updated"
98
+ # Not printed — the response carries the outcome.
99
+ when "turn.completed"
100
+ puts "\n\e[90m── turn completed ──\e[0m"
101
+ when "turn.failed"
102
+ puts "\n\e[31m✗ #{event[:data][:error]}\e[0m"
103
+ when "turn.aborted"
104
+ puts "\n\e[31m✗ Turn aborted\e[0m"
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end