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 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: []