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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +42 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/bin/ach +7 -0
- data/bin/ask-coding-harness +7 -0
- data/lib/ask/coding_harness/agent_runner.rb +260 -0
- data/lib/ask/coding_harness/cli.rb +151 -0
- data/lib/ask/coding_harness/config.rb +119 -0
- data/lib/ask/coding_harness/demo_adapter.rb +253 -0
- data/lib/ask/coding_harness/event_translator.rb +115 -0
- data/lib/ask/coding_harness/runner.rb +109 -0
- data/lib/ask/coding_harness/server.rb +319 -0
- data/lib/ask/coding_harness/store.rb +126 -0
- data/lib/ask/coding_harness/version.rb +7 -0
- data/lib/ask-coding-harness.rb +62 -0
- data/public/assets/index-Cs2ZprmP.js +2113 -0
- data/public/assets/index-D2px4HJ9.css +1 -0
- data/public/icon.svg +5 -0
- data/public/index.html +25 -0
- data/public/manifest.json +17 -0
- data/public/sw.js +49 -0
- metadata +308 -0
|
@@ -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
|