terret-acp 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/lib/terret/acp/bounded_queue.rb +55 -0
- data/lib/terret/acp/server.rb +432 -0
- data/lib/terret/acp/service.rb +45 -0
- data/lib/terret/acp/wire.rb +93 -0
- data/lib/terret/acp.rb +12 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6768e0a21e9c1e042f86d7d1383dd7e0f3a8175046627b3945b4686c751e1b72
|
|
4
|
+
data.tar.gz: 0d932c2745d9433ebe4d6c1f274b6a74353368b0ea56e85de36ae66e159d08bf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3b4622c40470085dbcef9924326d89f4914624e2107e91b4e252f57753ca50c1b72c959e67a68c503c59258541c7d72c87a9d4acd0cfbe265adaefde9a8c0f73
|
|
7
|
+
data.tar.gz: 391b0f75b3b887bdadc665c1e2c33eb7a599c96ab6b3ecedb659d805997ee0c321000e0a1b69b1d1d779777302ee9d74429fc2c409dac3b3393a048f8a1ae647
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/notification"
|
|
4
|
+
|
|
5
|
+
module Terret
|
|
6
|
+
module ACP
|
|
7
|
+
# Outbound frame queue: non-blocking producer, waiting consumer — the same
|
|
8
|
+
# shape terret-ws uses (ws/bounded_queue.rb), and for the same reason. The
|
|
9
|
+
# producer is `session/event` dispatch, which `Sessions#fan_out` runs
|
|
10
|
+
# SYNCHRONOUSLY inside its drainer: if the write to a slow editor's pipe
|
|
11
|
+
# parked there, every agent's event dispatch in the whole process would
|
|
12
|
+
# stall behind it (co-mounted ws clients, the titler, the compactor) and the
|
|
13
|
+
# emit queue would grow unbounded. So `push` returns false when full instead
|
|
14
|
+
# of blocking, and a dedicated writer fiber — never the drainer — is the one
|
|
15
|
+
# that may park on the pipe.
|
|
16
|
+
#
|
|
17
|
+
# Trimmed from ws's: no `wait_push`, because ACP has no replay path that
|
|
18
|
+
# needs a blocking producer. Every ACP producer is on the dispatch side and
|
|
19
|
+
# must stay non-blocking.
|
|
20
|
+
class BoundedQueue
|
|
21
|
+
def initialize(limit)
|
|
22
|
+
@limit = limit
|
|
23
|
+
@items = []
|
|
24
|
+
@closed = false
|
|
25
|
+
@waiting = Async::Notification.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def push(item)
|
|
29
|
+
return false if @closed || @items.size >= @limit
|
|
30
|
+
|
|
31
|
+
@items << item
|
|
32
|
+
@waiting.signal
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Blocks until an item arrives or the queue closes; nil means
|
|
37
|
+
# closed-and-drained.
|
|
38
|
+
def pop
|
|
39
|
+
loop do
|
|
40
|
+
return @items.shift unless @items.empty?
|
|
41
|
+
return nil if @closed
|
|
42
|
+
|
|
43
|
+
@waiting.wait
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def closed? = @closed
|
|
48
|
+
|
|
49
|
+
def close
|
|
50
|
+
@closed = true
|
|
51
|
+
@waiting.signal
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "wire"
|
|
5
|
+
require_relative "bounded_queue"
|
|
6
|
+
|
|
7
|
+
module Terret
|
|
8
|
+
module ACP
|
|
9
|
+
# The ACP v1 protocol engine (docs/acp.md). It is to terret-acp what
|
|
10
|
+
# Connection is to terret-ws: transport-facing, holding no session
|
|
11
|
+
# vocabulary of its own. It consumes `session/event` and projects it to
|
|
12
|
+
# `session/update` notifications, and it drives `ctx[:loop]` — the exact
|
|
13
|
+
# two-seam shape the socket uses, with JSON-RPC over stdio as the only new
|
|
14
|
+
# thing. Nothing reaches the editor that is not in the log first.
|
|
15
|
+
#
|
|
16
|
+
# One connection, many sessions: unlike the socket's one-connection-per-
|
|
17
|
+
# agent rule, an editor calls `session/new` repeatedly over a single
|
|
18
|
+
# stdio pair, so the server routes prompts and cancels by `sessionId` and
|
|
19
|
+
# keys agents `agent-#{sid}` (spawn_agent's default, same as ws).
|
|
20
|
+
#
|
|
21
|
+
# Concurrency (plan §8): the read loop PARKS the fiber on `input.gets`, it
|
|
22
|
+
# never blocks the thread — one reactor drives every agent in the process.
|
|
23
|
+
# A turn runs in a task rooted on `runner_task` (the server task, not the
|
|
24
|
+
# read loop), so a disconnect cannot cancel a turn in flight.
|
|
25
|
+
#
|
|
26
|
+
# Outbound is DECOUPLED from the event bus, exactly as terret-ws decouples
|
|
27
|
+
# it (ws/connection.rb): `project` runs inside `Sessions#fan_out`'s
|
|
28
|
+
# synchronous drainer, so if it wrote to a slow editor's pipe directly it
|
|
29
|
+
# would park that drainer and stall every agent's event dispatch in the
|
|
30
|
+
# process. Instead every producer ENQUEUES a frame (non-blocking) onto a
|
|
31
|
+
# bounded queue, and one dedicated writer fiber is the only thing that ever
|
|
32
|
+
# parks on the pipe. That writer is also the single serialization point, so
|
|
33
|
+
# a streaming turn's notifications and a request's response never interleave
|
|
34
|
+
# without a mutex. A queue that fills means the editor stopped reading:
|
|
35
|
+
# its output is dropped rather than blocking the bus (finding: a lagging
|
|
36
|
+
# editor must never wedge other sessions).
|
|
37
|
+
class Server
|
|
38
|
+
PROTOCOL_VERSION = 1
|
|
39
|
+
|
|
40
|
+
# Bounds the outbound queue so a stalled editor cannot grow it without
|
|
41
|
+
# limit. Not config: it is a safety bound, not a knob (the acp row has no
|
|
42
|
+
# config surface). Injectable so a test can force the lag path.
|
|
43
|
+
DEFAULT_QUEUE_LIMIT = 512
|
|
44
|
+
|
|
45
|
+
# Std-roster tool -> ACP ToolKind (docs/acp.md, "Resolved in Task 7").
|
|
46
|
+
# Everything not here — Task, every mcp__<server>__<tool> whose name
|
|
47
|
+
# arrives at runtime, any third-party tool — falls to the enum's own
|
|
48
|
+
# `other`.
|
|
49
|
+
TOOL_KINDS = {
|
|
50
|
+
"Read" => "read",
|
|
51
|
+
"Glob" => "search", "Grep" => "search",
|
|
52
|
+
"Write" => "edit", "Edit" => "edit",
|
|
53
|
+
"Bash" => "execute",
|
|
54
|
+
"WebFetch" => "fetch"
|
|
55
|
+
}.freeze
|
|
56
|
+
|
|
57
|
+
def initialize(ctx:, input:, output:, runner_task: Async::Task.current,
|
|
58
|
+
queue_limit: DEFAULT_QUEUE_LIMIT)
|
|
59
|
+
@ctx = ctx
|
|
60
|
+
@input = input
|
|
61
|
+
@output = output
|
|
62
|
+
@runner_task = runner_task
|
|
63
|
+
@queue = BoundedQueue.new(queue_limit)
|
|
64
|
+
@sessions = {} # sessionId => Agent, this connection's sessions
|
|
65
|
+
@pending = {} # sessionId => the request id of its pending session/prompt
|
|
66
|
+
@announced = {} # toolCallId => true, tool_calls this connection has sent
|
|
67
|
+
@tail = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Serve until the input closes. One listener projects every durable append
|
|
71
|
+
# for our sessions onto the queue; a writer fiber drains the queue to the
|
|
72
|
+
# output; the read loop dispatches inbound frames. The writer is rooted on
|
|
73
|
+
# this connection's own task so it dies with the connection — unlike turn
|
|
74
|
+
# tasks, which root on runner_task and outlive it.
|
|
75
|
+
def run
|
|
76
|
+
@tail = @ctx.on("session/event") { |ev| project(ev) }
|
|
77
|
+
Sync do |task|
|
|
78
|
+
writer = task.async { drain_writes }
|
|
79
|
+
read_loop
|
|
80
|
+
ensure
|
|
81
|
+
# EOF disposes the connection, NOT the agents (docs/acp.md): they stay
|
|
82
|
+
# in the loop registry, parked per the M6 lifecycle, and re-attach
|
|
83
|
+
# through session/prompt. Closing the queue lets the writer flush what
|
|
84
|
+
# is already queued, then end.
|
|
85
|
+
@tail&.call
|
|
86
|
+
@tail = nil
|
|
87
|
+
@queue.close
|
|
88
|
+
writer&.wait
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# -- transport -------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def read_loop
|
|
97
|
+
while (line = @input.gets)
|
|
98
|
+
text = line.chomp
|
|
99
|
+
next if text.empty? # a blank line between frames is noise, not a parse error
|
|
100
|
+
|
|
101
|
+
dispatch(text)
|
|
102
|
+
end
|
|
103
|
+
rescue IOError, SystemCallError
|
|
104
|
+
# The input was closed under us (the editor died mid-read) rather than
|
|
105
|
+
# reaching a clean EOF; either way there is nothing more to read.
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The only fiber that may park on the pipe. A slow editor parks it here,
|
|
110
|
+
# never the event-bus drainer.
|
|
111
|
+
def drain_writes
|
|
112
|
+
while (frame = @queue.pop)
|
|
113
|
+
@output.write("#{frame}\n")
|
|
114
|
+
@output.flush if @output.respond_to?(:flush)
|
|
115
|
+
end
|
|
116
|
+
rescue IOError, SystemCallError => e
|
|
117
|
+
# The editor is gone (a broken pipe). Stop writing and close the queue so
|
|
118
|
+
# producers lag immediately rather than filling the bound; the read loop
|
|
119
|
+
# winds down at EOF and the agents survive, their turns logged durably.
|
|
120
|
+
warn "terret-acp: writer stopped (client gone?): #{e.class}: #{e.message}"
|
|
121
|
+
@queue.close
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Non-blocking, always — this is called from the event-bus drainer, from
|
|
125
|
+
# the read loop, and from turn tasks, and none of them may block on a slow
|
|
126
|
+
# pipe. A full queue means the editor stopped reading.
|
|
127
|
+
def enqueue(frame)
|
|
128
|
+
return if @queue.push(frame)
|
|
129
|
+
|
|
130
|
+
lag!
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The editor fell too far behind. Drop its output rather than block the
|
|
134
|
+
# shared bus; the connection lingers reading until EOF, producing nothing.
|
|
135
|
+
def lag!
|
|
136
|
+
return if @queue.closed?
|
|
137
|
+
|
|
138
|
+
warn "terret-acp: client lagged; dropping output (agents survive, turns still log)"
|
|
139
|
+
@queue.close
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# -- inbound ---------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def dispatch(line)
|
|
145
|
+
msg = Wire.decode(line)
|
|
146
|
+
case msg.kind
|
|
147
|
+
when :parse_error then enqueue(Wire.error(id: nil, code: -32700, message: "parse error"))
|
|
148
|
+
when :invalid then enqueue(Wire.error(id: msg.id, code: -32600, message: "invalid request"))
|
|
149
|
+
when :notification then handle_notification(msg)
|
|
150
|
+
when :request then handle_request(msg)
|
|
151
|
+
# :response — this server sends no requests to the client, so an
|
|
152
|
+
# inbound response correlates with nothing; drop it rather than error.
|
|
153
|
+
end
|
|
154
|
+
rescue StandardError => e
|
|
155
|
+
# A bug handling one frame must never kill the read loop: an editor that
|
|
156
|
+
# sends something we mishandle gets an answer (if it was a request) and
|
|
157
|
+
# the loop reads on.
|
|
158
|
+
warn "terret-acp: dropping frame on dispatch error: #{e.class}: #{e.message}"
|
|
159
|
+
enqueue(Wire.error(id: msg&.id, code: -32603, message: "internal error")) if msg&.request?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def handle_request(msg)
|
|
163
|
+
params = msg.params.is_a?(Hash) ? msg.params : {}
|
|
164
|
+
case msg.method
|
|
165
|
+
when "initialize" then enqueue(Wire.response(id: msg.id, result: initialize_result))
|
|
166
|
+
when "session/new" then new_session(msg.id, params)
|
|
167
|
+
when "session/prompt" then prompt(msg.id, params)
|
|
168
|
+
else
|
|
169
|
+
# Every unimplemented method, including authenticate (authMethods is
|
|
170
|
+
# empty, so it is never reached) and every v2 name, answers here.
|
|
171
|
+
enqueue(Wire.error(id: msg.id, code: -32601, message: "method not found: #{msg.method}"))
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def handle_notification(msg)
|
|
176
|
+
params = msg.params.is_a?(Hash) ? msg.params : {}
|
|
177
|
+
case msg.method
|
|
178
|
+
when "session/cancel" then cancel(params[:sessionId])
|
|
179
|
+
when "$/cancel_request" then cancel(@pending.key(params[:requestId]))
|
|
180
|
+
# Any other notification is ignored — a notification may always be.
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# -- methods ---------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def initialize_result
|
|
187
|
+
# Reports what this boot mounts, not what the gems can do (docs/acp.md).
|
|
188
|
+
# v1 advertises no loadSession, no prompt capabilities beyond the
|
|
189
|
+
# text+resource_link baseline, and no mcp/session subgroups, so the
|
|
190
|
+
# capabilities object is empty rather than a wall of `false`.
|
|
191
|
+
{ protocolVersion: PROTOCOL_VERSION, agentCapabilities: {}, authMethods: [] }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def new_session(id, params)
|
|
195
|
+
cwd = params[:cwd]
|
|
196
|
+
mcp = params[:mcpServers]
|
|
197
|
+
unless cwd.is_a?(String) && !cwd.empty?
|
|
198
|
+
return enqueue(Wire.error(id: id, code: -32602,
|
|
199
|
+
message: "session/new requires a cwd (an absolute path)"))
|
|
200
|
+
end
|
|
201
|
+
unless mcp.is_a?(Array)
|
|
202
|
+
return enqueue(Wire.error(id: id, code: -32602,
|
|
203
|
+
message: "session/new requires mcpServers (a list, possibly empty)"))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Spawn BEFORE the durable create, so a registry refusal (the agent cap)
|
|
207
|
+
# cannot leave an orphaned session in the log — create only runs once the
|
|
208
|
+
# agent exists. cwd does not widen filesystem reach and mcpServers are
|
|
209
|
+
# not mounted in v1 (docs/acp.md): the profile's floor governs.
|
|
210
|
+
sid = SecureRandom.hex(6)
|
|
211
|
+
agent = @ctx[:loop].spawn_agent(session_id: sid)
|
|
212
|
+
@ctx[:sessions].create(id: sid)
|
|
213
|
+
@sessions[sid] = agent
|
|
214
|
+
enqueue(Wire.response(id: id, result: { sessionId: sid }))
|
|
215
|
+
rescue AgentExists, AgentCapExceeded => e
|
|
216
|
+
# A registry refusal is this process's business, not the client's fault;
|
|
217
|
+
# it still deserves an answer rather than a dropped connection.
|
|
218
|
+
enqueue(Wire.error(id: id, code: -32603, message: e.message))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def prompt(request_id, params)
|
|
222
|
+
sid = params[:sessionId]
|
|
223
|
+
agent = resolve_agent(sid)
|
|
224
|
+
unless agent
|
|
225
|
+
return enqueue(Wire.error(id: request_id, code: -32602, message: "unknown session #{sid.inspect}"))
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
blocks = params[:prompt]
|
|
229
|
+
unless blocks.is_a?(Array) && !blocks.empty?
|
|
230
|
+
return enqueue(Wire.error(id: request_id, code: -32602,
|
|
231
|
+
message: "session/prompt requires a non-empty prompt"))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
text = render_prompt(blocks)
|
|
235
|
+
if text.empty?
|
|
236
|
+
# A prompt whose blocks carry nothing we can act on (no text, only
|
|
237
|
+
# unhandled block types) is invalid params — the fresh and resume
|
|
238
|
+
# branches both refuse it rather than driving an empty turn.
|
|
239
|
+
return enqueue(Wire.error(id: request_id, code: -32602,
|
|
240
|
+
message: "session/prompt has no text content to act on"))
|
|
241
|
+
end
|
|
242
|
+
if @pending.key?(sid)
|
|
243
|
+
return enqueue(Wire.error(id: request_id, code: -32600,
|
|
244
|
+
message: "a prompt is already in flight for #{sid}"))
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
@pending[sid] = request_id
|
|
248
|
+
drive_turn(agent, sid, text)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# The pending request stays open for the whole turn (docs/acp.md). The
|
|
252
|
+
# turn task is rooted on runner_task so a disconnect cannot cancel it; on
|
|
253
|
+
# completion it answers the pending prompt with the mapped stop reason.
|
|
254
|
+
# A session whose log holds an open turn resumes it — an editor reopening
|
|
255
|
+
# a project is the canonical way to meet a turn a killed process left
|
|
256
|
+
# open — otherwise a fresh run_turn.
|
|
257
|
+
def drive_turn(agent, sid, text)
|
|
258
|
+
resuming = @ctx[:loop].resumable?(sid)
|
|
259
|
+
@runner_task.async do
|
|
260
|
+
status =
|
|
261
|
+
if resuming
|
|
262
|
+
agent.inject(text) # the prompt rides the resumed turn's next step
|
|
263
|
+
@ctx[:loop].resume_turn(agent)
|
|
264
|
+
else
|
|
265
|
+
@ctx[:loop].run_turn(agent, text)
|
|
266
|
+
end
|
|
267
|
+
respond_prompt(sid, status)
|
|
268
|
+
rescue StandardError => e
|
|
269
|
+
# A turn that raised (a runaway MAX_STEPS overflow, an LLM outage) is
|
|
270
|
+
# a request that could not complete, not a turn with a sad stop
|
|
271
|
+
# reason: answer -32603, not a result (docs/acp.md, "Stop reasons").
|
|
272
|
+
warn "terret-acp: turn failed for #{sid}: #{e.class}: #{e.message}"
|
|
273
|
+
answer = @pending.delete(sid)
|
|
274
|
+
enqueue(Wire.error(id: answer, code: -32603, message: "the turn failed: #{e.class}")) if answer
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def respond_prompt(sid, status)
|
|
279
|
+
request_id = @pending.delete(sid) or return
|
|
280
|
+
reason = stop_reason(status)
|
|
281
|
+
if reason
|
|
282
|
+
enqueue(Wire.response(id: request_id, result: { stopReason: reason }))
|
|
283
|
+
else
|
|
284
|
+
enqueue(Wire.error(id: request_id, code: -32603, message: "the turn failed"))
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Terret's five turn statuses onto ACP's five stop reasons (docs/acp.md).
|
|
289
|
+
# nil means "answer a -32603 error, not a result" — only `failed`, which
|
|
290
|
+
# run_turn raises rather than returns, so it is reached defensively.
|
|
291
|
+
def stop_reason(status)
|
|
292
|
+
case status
|
|
293
|
+
when :completed, :empty then "end_turn"
|
|
294
|
+
when :cancelled then "cancelled"
|
|
295
|
+
when :rejected then "refusal"
|
|
296
|
+
when :failed then nil
|
|
297
|
+
else "end_turn"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def cancel(sid)
|
|
302
|
+
agent = sid && (@sessions[sid] || @ctx[:loop].agent("agent-#{sid}"))
|
|
303
|
+
return unless agent # a cancel for a session we do not know is a no-op
|
|
304
|
+
|
|
305
|
+
# Lands on the same Agent#cancel the socket's cancel frame drives; the
|
|
306
|
+
# turn closes cancelled at its next step boundary and respond_prompt
|
|
307
|
+
# answers the pending prompt with stopReason "cancelled".
|
|
308
|
+
agent.cancel
|
|
309
|
+
# A parked agent cannot reach a boundary until its verdict lands, so a
|
|
310
|
+
# cancel on one denies the pending approval too, if that row is mounted.
|
|
311
|
+
if agent.status == :waiting_approval && @ctx.service?(:approvals)
|
|
312
|
+
@ctx[:approvals].deny_pending!(agent.session_id, reason: "cancelled")
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# -- session resolution ----------------------------------------------------
|
|
317
|
+
|
|
318
|
+
# This connection's session, or a re-attach: a live agent from a prior
|
|
319
|
+
# connection, or a durable session resumed and given a fresh agent. The
|
|
320
|
+
# last is the loadSession story done through session/prompt (docs/acp.md).
|
|
321
|
+
def resolve_agent(sid)
|
|
322
|
+
return nil unless sid.is_a?(String)
|
|
323
|
+
return @sessions[sid] if @sessions.key?(sid)
|
|
324
|
+
|
|
325
|
+
if (live = @ctx[:loop].agent("agent-#{sid}"))
|
|
326
|
+
return @sessions[sid] = live
|
|
327
|
+
end
|
|
328
|
+
return nil unless @ctx[:sessions].session_ids.include?(sid)
|
|
329
|
+
|
|
330
|
+
@ctx[:sessions].resume(sid)
|
|
331
|
+
@sessions[sid] = @ctx[:loop].spawn_agent(session_id: sid)
|
|
332
|
+
rescue AgentExists
|
|
333
|
+
@sessions[sid] = @ctx[:loop].agent("agent-#{sid}") # a racing spawn won; use it
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# -- outbound: session/event -> session/update -----------------------------
|
|
337
|
+
|
|
338
|
+
def project(ev)
|
|
339
|
+
return unless @sessions.key?(ev.session_id)
|
|
340
|
+
|
|
341
|
+
update =
|
|
342
|
+
case ev.type
|
|
343
|
+
when "assistant/chunk"
|
|
344
|
+
{ sessionUpdate: "agent_message_chunk",
|
|
345
|
+
content: text_block(ev.payload[:text]) }
|
|
346
|
+
when "tool/call"
|
|
347
|
+
announce(ev.payload[:id])
|
|
348
|
+
{ sessionUpdate: "tool_call", toolCallId: ev.payload[:id],
|
|
349
|
+
title: ev.payload[:name].to_s, kind: tool_kind(ev.payload[:name]),
|
|
350
|
+
status: "pending" }
|
|
351
|
+
when "tool/result"
|
|
352
|
+
id = ev.payload[:id]
|
|
353
|
+
# On the resume path complete_dangling can append a tool/result for a
|
|
354
|
+
# call whose tool/call was already durable before the crash, so this
|
|
355
|
+
# connection never announced it. A bare tool_call_update for an
|
|
356
|
+
# unannounced id is a malformed stream to the editor — synthesize the
|
|
357
|
+
# opening tool_call (pending) first, in order, ahead of the update.
|
|
358
|
+
synthesize_tool_call(ev.session_id, id) unless @announced[id]
|
|
359
|
+
failed = !ev.payload[:error].nil?
|
|
360
|
+
{ sessionUpdate: "tool_call_update", toolCallId: id,
|
|
361
|
+
status: failed ? "failed" : "completed",
|
|
362
|
+
content: tool_content(ev.payload[:error] || ev.payload[:content]) }
|
|
363
|
+
end
|
|
364
|
+
# There is no thinking part in Terret's LLM vocabulary — Text, ToolCall,
|
|
365
|
+
# ToolResult and nothing else — so agent_thought_chunk has no source and
|
|
366
|
+
# is never emitted. The other unmapped variants (plan, user_message_chunk,
|
|
367
|
+
# the *_update family) have no Terret consumer either. See docs/acp.md.
|
|
368
|
+
return unless update
|
|
369
|
+
|
|
370
|
+
notify(ev.session_id, update)
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
# The synthesize lookup reads the log and could raise; a projection fault
|
|
373
|
+
# must never surface into Sessions#append (emit isolates it too, but this
|
|
374
|
+
# names the session).
|
|
375
|
+
warn "terret-acp: projection failed for #{ev.session_id}: #{e.class}: #{e.message}"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def announce(id) = @announced[id] = true
|
|
379
|
+
|
|
380
|
+
# Emit the opening tool_call for a call whose id this connection has not
|
|
381
|
+
# announced, reading its name from the log. Ordered ahead of the caller's
|
|
382
|
+
# tool_call_update because both go through the one FIFO queue.
|
|
383
|
+
def synthesize_tool_call(sid, id)
|
|
384
|
+
name = tool_name_for(sid, id)
|
|
385
|
+
announce(id)
|
|
386
|
+
notify(sid, { sessionUpdate: "tool_call", toolCallId: id, title: name.to_s,
|
|
387
|
+
kind: tool_kind(name), status: "pending" })
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def tool_name_for(sid, id)
|
|
391
|
+
@ctx[:sessions].fetch(sid).events.reverse_each
|
|
392
|
+
.find { |e| e.type == "tool/call" && e.payload[:id] == id }
|
|
393
|
+
&.payload&.[](:name)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def tool_kind(name)
|
|
397
|
+
name = name.to_s
|
|
398
|
+
return TOOL_KINDS[name] if TOOL_KINDS.key?(name)
|
|
399
|
+
return "execute" if name.start_with?("job_", "terminal_")
|
|
400
|
+
|
|
401
|
+
"other"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def text_block(text) = { type: "text", text: text.to_s }
|
|
405
|
+
|
|
406
|
+
def tool_content(body)
|
|
407
|
+
return [] if body.nil?
|
|
408
|
+
|
|
409
|
+
[{ type: "content", content: text_block(body) }]
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Baseline blocks are text and resource_link (docs/acp.md). Terret's
|
|
413
|
+
# user/message is plain text, so a resource_link folds into the text as a
|
|
414
|
+
# named reference; unknown block types are skipped leniently.
|
|
415
|
+
def render_prompt(blocks)
|
|
416
|
+
blocks.filter_map do |block|
|
|
417
|
+
next unless block.is_a?(Hash)
|
|
418
|
+
|
|
419
|
+
case block[:type]
|
|
420
|
+
when "text" then block[:text]
|
|
421
|
+
when "resource_link" then "#{block[:name] || block[:uri]} (#{block[:uri]})"
|
|
422
|
+
end
|
|
423
|
+
end.join("\n")
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def notify(sid, update)
|
|
427
|
+
enqueue(Wire.notification(method: "session/update",
|
|
428
|
+
params: { sessionId: sid, update: update }))
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "server"
|
|
4
|
+
|
|
5
|
+
module Terret
|
|
6
|
+
module ACP
|
|
7
|
+
# ctx[:acp] — the ACP interface plugin (docs/acp.md). Like terret-ws it is
|
|
8
|
+
# an interface with no session vocabulary of its own: it mounts through the
|
|
9
|
+
# loader, injects the two seams it drives, and disposes clean. The second
|
|
10
|
+
# interface on a completely different transport, built out of the same
|
|
11
|
+
# seams with no change to core, is what turns "the interface is not
|
|
12
|
+
# privileged" (plan §9.1) from a claim into evidence.
|
|
13
|
+
class Service < Hames::Service
|
|
14
|
+
service_key :acp
|
|
15
|
+
inject :sessions, :loop
|
|
16
|
+
# ACP over stdio has no config surface — the transport is the client's
|
|
17
|
+
# own stdin/stdout, auth is the process boundary (authMethods is empty),
|
|
18
|
+
# and everything a turn may do is the profile's floor, not this row's.
|
|
19
|
+
# An empty schema is a declaration, so doctor calls it ok rather than
|
|
20
|
+
# unschema'd (docs/composition.md §9).
|
|
21
|
+
config_schema({})
|
|
22
|
+
|
|
23
|
+
def start(ctx)
|
|
24
|
+
@ctx = ctx
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Serve ACP over a duplex IO pair until the input closes. stdout carries
|
|
28
|
+
# ONLY ACP frames; logs go to stderr (docs/acp.md), which is where Ruby's
|
|
29
|
+
# warn already writes — so a stray `puts` in a mounted plugin is the one
|
|
30
|
+
# thing that corrupts this stream. The IO pair is injectable exactly like
|
|
31
|
+
# the openrouter adapter's transport: tests drive it over an in-memory
|
|
32
|
+
# pipe, `trt acp` passes $stdin/$stdout.
|
|
33
|
+
#
|
|
34
|
+
# One reactor: the read loop parks the fiber, and turn tasks root on the
|
|
35
|
+
# top-level task so a disconnect never cancels a turn in flight. Blocks
|
|
36
|
+
# until the input reaches EOF.
|
|
37
|
+
def serve(input: $stdin, output: $stdout)
|
|
38
|
+
require "async"
|
|
39
|
+
Async do |task|
|
|
40
|
+
Server.new(ctx: @ctx, input: input, output: output, runner_task: task).run
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Terret
|
|
6
|
+
module ACP
|
|
7
|
+
# JSON-RPC 2.0 framing for ACP v1 (docs/acp.md, "Framing"). The transport
|
|
8
|
+
# is newline-delimited — one JSON object per line, no LSP `Content-Length`
|
|
9
|
+
# headers — so the Server reads a line and hands it here, and every frame
|
|
10
|
+
# this builds is a single line: JSON.generate escapes an embedded newline
|
|
11
|
+
# to `\n`, which is what keeps one frame from splitting into two.
|
|
12
|
+
#
|
|
13
|
+
# A codec, not a socket: it holds no IO. The Server owns the duplex pair
|
|
14
|
+
# (input.gets / output.write) exactly as terret-ws's Connection owns its
|
|
15
|
+
# injectable io. stdlib json only — the interface gems carry their own
|
|
16
|
+
# transport deps, the kernel and this codec carry none.
|
|
17
|
+
module Wire
|
|
18
|
+
JSONRPC = "2.0"
|
|
19
|
+
|
|
20
|
+
# A decoded inbound frame, already classified. `kind` is one of
|
|
21
|
+
# :request, :notification, :response, :invalid, :parse_error — the
|
|
22
|
+
# Server switches on it before it ever looks at `method`, so a garbage
|
|
23
|
+
# line becomes an error frame instead of a crashed read loop.
|
|
24
|
+
Message = Data.define(:kind, :id, :method, :params, :result, :error) do
|
|
25
|
+
def request? = kind == :request
|
|
26
|
+
def notification? = kind == :notification
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def request(id:, method:, params: nil)
|
|
32
|
+
h = { jsonrpc: JSONRPC, id: id, method: method }
|
|
33
|
+
h[:params] = params unless params.nil?
|
|
34
|
+
frame(h)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def notification(method:, params: nil)
|
|
38
|
+
h = { jsonrpc: JSONRPC, method: method }
|
|
39
|
+
h[:params] = params unless params.nil?
|
|
40
|
+
frame(h)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def response(id:, result:)
|
|
44
|
+
frame(jsonrpc: JSONRPC, id: id, result: result)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def error(id:, code:, message:, data: nil)
|
|
48
|
+
err = { code: code, message: message }
|
|
49
|
+
err[:data] = data unless data.nil?
|
|
50
|
+
frame(jsonrpc: JSONRPC, id: id, error: err)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One line, guaranteed. JSON.generate never emits a raw newline (it
|
|
54
|
+
# escapes one inside a string), so the guard is belt-and-braces against a
|
|
55
|
+
# future generator swap rather than something reachable today.
|
|
56
|
+
def frame(hash)
|
|
57
|
+
line = JSON.generate(hash)
|
|
58
|
+
raise ArgumentError, "a frame must not contain an embedded newline" if line.include?("\n")
|
|
59
|
+
|
|
60
|
+
line
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Never raises: a malformed line from an editor gets an answer, not a
|
|
64
|
+
# dropped agent. The Server maps :parse_error to -32700 and :invalid to
|
|
65
|
+
# -32600. A response arriving here (we send no client requests, so we
|
|
66
|
+
# expect none) is classified rather than mistaken for a request.
|
|
67
|
+
def decode(line)
|
|
68
|
+
parsed = begin
|
|
69
|
+
JSON.parse(line, symbolize_names: true)
|
|
70
|
+
rescue JSON::ParserError
|
|
71
|
+
return blank(:parse_error)
|
|
72
|
+
end
|
|
73
|
+
return blank(:invalid) unless parsed.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
if parsed.key?(:method)
|
|
76
|
+
kind = parsed.key?(:id) ? :request : :notification
|
|
77
|
+
Message.new(kind: kind, id: parsed[:id], method: parsed[:method],
|
|
78
|
+
params: parsed[:params], result: nil, error: nil)
|
|
79
|
+
elsif parsed.key?(:result) || parsed.key?(:error)
|
|
80
|
+
Message.new(kind: :response, id: parsed[:id], method: nil, params: nil,
|
|
81
|
+
result: parsed[:result], error: parsed[:error])
|
|
82
|
+
else
|
|
83
|
+
Message.new(kind: :invalid, id: parsed[:id], method: nil, params: nil,
|
|
84
|
+
result: nil, error: nil)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def blank(kind)
|
|
89
|
+
Message.new(kind: kind, id: nil, method: nil, params: nil, result: nil, error: nil)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/terret/acp.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "terret"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
require_relative "../../../terret-core/lib/terret" # monorepo path source
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require_relative "acp/wire"
|
|
10
|
+
require_relative "acp/bounded_queue"
|
|
11
|
+
require_relative "acp/server"
|
|
12
|
+
require_relative "acp/service"
|
metadata
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: terret-acp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Obie Fernandez
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: terret-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
description: 'The second interface (plan §9.1): an ACP v1 server so an editor can
|
|
27
|
+
drive a Terret agent over JSON-RPC 2.0 on stdio. Newline-delimited framing, session/new
|
|
28
|
+
spawns a durable agent, session/prompt pends the whole turn, session/update notifications
|
|
29
|
+
projected from the session log. Consumes the same two seams the socket does with
|
|
30
|
+
no change to core, which is the standing proof that the interface is not privileged.
|
|
31
|
+
Zero runtime dependencies beyond stdlib json.'
|
|
32
|
+
email:
|
|
33
|
+
- obiefernandez@gmail.com
|
|
34
|
+
executables: []
|
|
35
|
+
extensions: []
|
|
36
|
+
extra_rdoc_files: []
|
|
37
|
+
files:
|
|
38
|
+
- lib/terret/acp.rb
|
|
39
|
+
- lib/terret/acp/bounded_queue.rb
|
|
40
|
+
- lib/terret/acp/server.rb
|
|
41
|
+
- lib/terret/acp/service.rb
|
|
42
|
+
- lib/terret/acp/wire.rb
|
|
43
|
+
homepage: https://terret.org
|
|
44
|
+
licenses:
|
|
45
|
+
- MIT
|
|
46
|
+
metadata:
|
|
47
|
+
homepage_uri: https://terret.org
|
|
48
|
+
source_code_uri: https://github.com/terret-org/terret
|
|
49
|
+
bug_tracker_uri: https://github.com/terret-org/terret/issues
|
|
50
|
+
rubygems_mfa_required: 'true'
|
|
51
|
+
rdoc_options: []
|
|
52
|
+
require_paths:
|
|
53
|
+
- lib
|
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '4.0'
|
|
59
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '0'
|
|
64
|
+
requirements: []
|
|
65
|
+
rubygems_version: 4.0.16
|
|
66
|
+
specification_version: 4
|
|
67
|
+
summary: Agent Client Protocol server for the Terret agent harness
|
|
68
|
+
test_files: []
|