bots-garden/mini-mepublic Fork 0
d72271127802973540c648bfb372176cdaaa8e4f
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

acp.go · 462 lines · 19.4 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 7h ago1// Package acp is bob's second front end: the same engine, the same tools and
2// the same history rules as the terminal REPL (internal/agent), exposed over
3// the Agent Client Protocol — JSON-RPC 2.0 on stdio, one message per line —
4// so that an editor (Zed, IntelliJ, Neovim…) can host the agent.
5//
6// The division of labour:
7//
8// - the SDK (github.com/coder/acp-go-sdk) does the transport: framing,
9// routing initialize/session/new/session/prompt to the methods below,
10// correlating the ids of our own requests to the client (permissions);
11// - this package translates: a prompt turn becomes one engine.Generate call,
12// and the events the tools emit through ui.Sink become session/update
13// notifications — text chunks, tool_call lifecycles, diffs;
14// - nothing else changes: internal/engine and internal/tools are shared with
15// the terminal front end, which is the point of the exercise.
16//
17// stdout carries JSON-RPC only. Everything human — the banner, the [acp] trail,
18// the SDK's own diagnostics — goes to stderr, which the spec leaves to logging
19// and which Zed shows in `dev: open acp logs`.
20package acp
21
22import (
23 "context"
24 "encoding/json"
25 "errors"
26 "fmt"
27 "io"
28 "log/slog"
29 "os"
30 "strings"
31 "sync"
32
33 "mm/internal/compact"
34 "mm/internal/config"
35 "mm/internal/engine"
36 "mm/internal/mention"
37 history "mm/internal/session"
38 "mm/internal/ui"
39
40 sdk "github.com/coder/acp-go-sdk"
41 "github.com/firebase/genkit/go/ai"
42)
43
44// version is what initialize reports in agentInfo — the part number, so a
45// trace says which chapter of the talk produced it.
46const version = "0.11.0"
47
48// session is one conversation with the client. Like the REPL, the history is
49// plain []*ai.Message with the system prompt first; unlike the REPL there can
50// be several of them, one per session/new.
51type session struct {
52 cwd string
53 messages []*ai.Message
54 cancel context.CancelFunc // cancels the turn in flight, if any
55 // allowAlways remembers the tools the user granted for the whole session
56 // ("allow always" in the permission dialog).
57 allowAlways map[string]bool
58}
59
60type front struct {
61 e *engine.Engine
62 system string
63 tools []ai.ToolRef
64 conn *sdk.AgentSideConnection
65 out *responseWriter // what the SDK writes to; see responseWriter
66
67 mu sync.Mutex // guards sessions and nextID
68 sessions map[sdk.SessionId]*session
69 nextID int
70
71 // turnMu serialises the turns. Two reasons, both structural: the active
72 // ui.Sink is process-global, and engine.Generate was never designed for two
73 // concurrent turns. The spec leaves this to the agent, and Zed does not
74 // send a second prompt while one is running — a queued turn just waits.
75 turnMu sync.Mutex
76}
77
78var _ sdk.Agent = (*front)(nil)
79
80// Run wires the agent side of an ACP connection on stdio and blocks until the
81// client closes it. Same arguments as agent.Run: main.go's wiring — engine,
82// system prompt, tool list — is identical for the two front ends.
83func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef) {
84 f := newFront(e, system, tools, os.Stdout, os.Stdin)
85 select {
86 case <-f.conn.Done(): // the editor closed our stdin: we are done
87 case <-ctx.Done():
88 }
89}
90
91// newFront wires a front end on an arbitrary pair of streams — stdio in Run,
92// pipes in the tests. w is where WE write (JSON-RPC to the client), r where
93// we read the client's messages.
94func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, r io.Reader) *front {
95 f := &front{e: e, system: system, tools: tools, sessions: map[sdk.SessionId]*session{}}
96 f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}}
97 conn := sdk.NewAgentSideConnection(f, f.out, r)
98 conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))
99 f.conn = conn
100 return f
101}
102
103// responseWriter is the io.Writer handed to the SDK: the real output plus one
104// hook per session, run once the session/new response carrying that session
105// id has been written. It exists for available_commands_update. The spec says
106// to announce the commands right after the session is created, and the client
107// (Zed, verified 2026-09-15) drops any session/update for a session whose
108// session/new response it has not received yet — "Available commands: none".
109// But the SDK writes the response only when NewSession returns, after anything
110// the method itself sent, and offers no "after the response" callback. Watching
111// the bytes is the one place where the order is certain.
112type responseWriter struct {
113 io.Writer
114 mu sync.Mutex
115 hooks map[sdk.SessionId]func()
116}
117
118// afterResponse registers hook to run — once, in its own goroutine — after the
119// response that carries sid has been written.
120func (w *responseWriter) afterResponse(sid sdk.SessionId, hook func()) {
121 w.mu.Lock()
122 defer w.mu.Unlock()
123 w.hooks[sid] = hook
124}
125
126// Write forwards to the real output, then fires the hook of the session whose
127// response this line is, if any. The hook runs in a goroutine: the SDK holds
128// its write lock here, and the hook is about to write a notification.
129func (w *responseWriter) Write(p []byte) (int, error) {
130 n, err := w.Writer.Write(p)
131 if err != nil {
132 return n, err
133 }
134 if sid, ok := responseSessionID(p); ok {
135 w.mu.Lock()
136 hook := w.hooks[sid]
137 delete(w.hooks, sid)
138 w.mu.Unlock()
139 if hook != nil {
140 go hook()
141 }
142 }
143 return n, nil
144}
145
146// responseSessionID recognises a JSON-RPC response whose result carries a
147// sessionId — session/new (and session/load, which this agent does not
148// offer). Notifications have no id and prompt responses no sessionId, so
149// neither matches.
150func responseSessionID(line []byte) (sdk.SessionId, bool) {
151 var m struct {
152 ID json.RawMessage `json:"id"`
153 Result struct {
154 SessionId sdk.SessionId `json:"sessionId"`
155 } `json:"result"`
156 }
157 if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" {
158 return "", false
159 }
160 return m.Result.SessionId, true
161}
162
163// --- initialize ---------------------------------------------------------------
164
165func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) {
166 fmt.Fprintf(os.Stderr, "[acp] initialize: client %q protocolVersion=%d\n",
167 clientName(p.ClientInfo), p.ProtocolVersion)
168 return sdk.InitializeResponse{
169 ProtocolVersion: sdk.ProtocolVersionNumber, // 1
170 // Everything absent is false: no loadSession, no image/audio prompts,
171 // no MCP over http. Honest capabilities are what lets a client adapt.
172 AgentCapabilities: sdk.AgentCapabilities{},
173 AgentInfo: &sdk.Implementation{Name: "bob", Title: sdk.Ptr("Bob (bash-first agent)"), Version: version},
174 AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into
175 }, nil
176}
177
178func clientName(impl *sdk.Implementation) string {
179 if impl == nil {
180 return "unknown"
181 }
182 return impl.Name
183}
184
185func (f *front) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) {
186 return sdk.AuthenticateResponse{}, nil
187}
188
189// --- session/new --------------------------------------------------------------
190
191func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
192 f.mu.Lock()
193 f.nextID++
194 sid := sdk.SessionId(fmt.Sprintf("bob-%d-%d", os.Getpid(), f.nextID))
195 f.sessions[sid] = &session{
196 cwd: p.Cwd, // absolute, per the spec; where bash runs for this session
197 messages: history.Fresh(f.system),
198 allowAlways: map[string]bool{},
199 }
200 f.mu.Unlock()
201 // mcpServers is where the client offers extra tools. bob's whole point is
202 // the opposite — one built-in tool — so they are acknowledged and ignored.
203 fmt.Fprintf(os.Stderr, "[acp] session %s cwd=%s mcpServers=%d (ignored)\n", sid, p.Cwd, len(p.McpServers))
204 // The slash commands, announced as the spec asks — right after the session
205 // is created — so the editor can list and complete them. "Right after"
206 // means after the response: Zed keys its sessions on the id that response
207 // carries and drops updates for an id it does not know yet. Hence the hook
208 // on the writer rather than a SessionUpdate call here (see responseWriter).
209 // The request context dies with this method; the hook needs one that lives.
210 f.out.afterResponse(sid, func() {
211 _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{
212 SessionId: sid,
213 Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
214 })
215 })
216 return sdk.NewSessionResponse{SessionId: sid}, nil
217}
218
219// compactCommand is the REPL's /compact, spelled here because ACP has no other
220// place for it; /new is shared through internal/session. /quit and /abort are
221// deliberately absent: the editor owns the process and ends it by closing
222// stdin, and it cancels a turn with its own stop key through session/cancel.
223const compactCommand = "/compact"
224
225// isCommand says whether input, trimmed, is exactly the given command — the
226// same rule as session.IsNewCommand: "/compact " is the command, "/compact
227// now" is a question for the model.
228func isCommand(input, command string) bool {
229 return strings.TrimSpace(input) == command
230}
231
232// availableCommands lists the slash commands this front end handles itself,
233// in the shape ACP advertises them: bare names, the client adds the slash.
234// Only what session/prompt actually intercepts belongs here — announcing
235// /quit or /abort, which the REPL has and this front end does not, would
236// promise the editor something the next prompt would hand to the model.
237func availableCommands() []sdk.AvailableCommand {
238 return []sdk.AvailableCommand{
239 {Name: history.NewCommandName, Description: "Clear the history and start a new session"},
240 {Name: strings.TrimPrefix(compactCommand, "/"), Description: "Compress the history now, keeping the last turns"},
241 }
242}
243
244// --- session/prompt -----------------------------------------------------------
245
246func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) {
247 f.mu.Lock()
248 s, ok := f.sessions[p.SessionId]
249 f.mu.Unlock()
250 if !ok {
251 return sdk.PromptResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId})
252 }
253
254 input := flatten(p.Prompt)
255
256 f.turnMu.Lock()
257 defer f.turnMu.Unlock()
258
259 // A slash command is answered here, without a turn: it is the REPL's "only
260 // between two generations" rule, met for free — turnMu guarantees no
261 // generation is running on this session's history while we replace it.
262 switch {
263 case history.IsNewCommand(input):
264 return f.startNewSession(ctx, p.SessionId, s), nil
265 case isCommand(input, compactCommand):
266 return f.compactSession(ctx, p.SessionId, s), nil
267 }
268
269 // One cancellable context per turn: session/cancel fires s.cancel, the
270 // generation and any pending permission request fall with it, and the spec's
271 // contract — still answer the prompt, with stopReason "cancelled" — is met
272 // at the bottom of this function.
273 turnCtx, cancel := context.WithCancel(ctx)
274 f.mu.Lock()
275 s.cancel = cancel
276 f.mu.Unlock()
277 defer func() {
278 f.mu.Lock()
279 s.cancel = nil
280 f.mu.Unlock()
281 cancel()
282 }()
283
284 // The sink is what turns the tools' events into session/update messages;
285 // installing it is what switches engine+tools to "ACP mode" for this turn.
286 ui.SetActive(&sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s})
287 defer ui.SetActive(nil)
288
289 // A typed "@path" — the editor's picker bypassed, or an editor without
290 // one — is understood like the resource_link the picker would have sent,
291 // against the session's cwd, which is the project the editor opened.
292 input, attached := mention.Expand(input, s.cwd)
293 for _, a := range attached {
294 fmt.Fprintf(os.Stderr, "[acp] session %s: attached %s\n", p.SessionId, a.Path)
295 }
296 s.messages = append(s.messages, ai.NewUserTextMessage(input))
297 before := len(s.messages)
298
299 resp, history, err := f.e.Generate(turnCtx, s.messages, f.tools)
300
301 // Same rule as the REPL, for the same reason: keep the FULL history even
302 // when the turn failed or was cancelled — the commands that already ran are
303 // part of the conversation, and forgetting them makes the model re-run or
304 // invent them on the next turn.
305 switch {
306 case len(history) > before:
307 s.messages = history
308 case resp != nil && resp.Message != nil:
309 s.messages = append(s.messages, resp.Message)
310 case err != nil:
311 s.messages = s.messages[:before-1] // question without an answer: forget it
312 }
313
314 // The ⚙ recap of the REPL, one line on stderr: the editor already renders
315 // every tool call, but the trail is what lets `dev: open acp logs` say at a
316 // glance whether the model worked or improvised.
317 turn := s.messages[min(before, len(s.messages)):]
318 fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n",
319 len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn))
320
321 switch {
322 case turnCtx.Err() != nil:
323 // The turn was cancelled — and both roads lead here: our Cancel method
324 // fires s.cancel (the child), AND the SDK cancels the request's own
325 // context when session/cancel arrives (the parent — measured: with the
326 // parent-must-be-intact test the REPL uses, a cancelled turn answered
327 // "Internal error: stream error: context canceled" instead of honouring
328 // the spec's contract). Either way the answer is the same: respond to
329 // session/prompt anyway, with stopReason "cancelled".
330 return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
331 case err != nil:
332 // One line, in the provider's words — the same text the REPL prints
333 // between brackets, delivered as a JSON-RPC error.
334 return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": f.e.Explain(err)})
335 }
336 return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
337}
338
339// startNewSession is the ACP side of /new: the session keeps its id, its cwd
340// and the "allow always" grants — the dialog promised them "for this session",
341// and the session, as the editor sees it, is the same one — but its history
342// goes back to the system prompt alone, and the server's token count of the
343// last context is dropped with it, as after a compression. The one line the
344// REPL prints travels as an agent message so the editor shows it in the
345// thread, and the turn ends right there: the model is not consulted.
346func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {
347 forgotten := resetSession(s, f.system)
348 f.e.ForgetInputTokens()
349 fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)
350 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
351 SessionId: sid,
352 Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)),
353 })
354 return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}
355}
356
357// compactSession is the ACP side of /compact: the REPL's compactHistory with
358// the report sent as an agent message instead of printed. Forced, like the
359// REPL's — the threshold is for the automatic path, which this front end does
360// not have — so "nothing to compact" is said rather than swallowed. Runs under
361// turnMu: Genkit is not holding this history.
362func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {
363 cfg := config.Cfg.Context
364 line, compressed := compactHistory(ctx, s, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
365 return f.e.Summarize(ctx, request, cfg.SummaryMaxTokens)
366 }, f.e.Explain)
367 if compressed {
368 f.e.ForgetInputTokens() // the server's measure described the history just replaced
369 }
370 fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line)
371 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
372 SessionId: sid,
373 Update: sdk.UpdateAgentMessageText(line),
374 })
375 return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}
376}
377
378// compactHistory compresses the session's history in place when there is
379// something to compress, and returns the one line to show — the REPL's words,
380// so the editor and the terminal agree — and whether the history changed.
381// Kept apart from compactSession, with the summariser and the error explainer
382// injected, so the three outcomes are testable without an engine or a
383// connection.
384func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, compressed bool) {
385 res, err := compact.Compact(ctx, s.messages, cfg, summarize)
386 switch {
387 case errors.Is(err, compact.ErrNothingToCompact):
388 return fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(s.messages), cfg.KeepLastTurns), false
389 case err != nil:
390 return fmt.Sprintf("[compact: failed, history kept: %s]", explain(err)), false
391 }
392 s.messages = res.Messages
393 return "🗜️ " + res.Report(), true
394}
395
396// resetSession replaces the session's history with a fresh one and returns how
397// many messages were forgotten. Kept apart from startNewSession so the rule
398// — history reset, permissions kept — is testable without a connection.
399func resetSession(s *session, system string) int {
400 forgotten := history.Forgotten(s.messages)
401 s.messages = history.Fresh(system)
402 return forgotten
403}
404
405// flatten turns the prompt's content blocks into the plain text bob reads.
406// Text is taken as-is; a resource_link (a file the user @-mentioned through
407// the editor's picker) becomes its path, in the same shape mention.Expand
408// gives a typed @path — the model has tools to read it, that is the whole
409// idea; anything else (image, audio, embedded resource) was not announced in
410// our capabilities, so a compliant client never sends it.
411func flatten(blocks []sdk.ContentBlock) string {
412 var b strings.Builder
413 for _, c := range blocks {
414 switch {
415 case c.Text != nil:
416 b.WriteString(c.Text.Text)
417 case c.ResourceLink != nil:
418 fmt.Fprintf(&b, "\n[attached file: %s]", strings.TrimPrefix(c.ResourceLink.Uri, "file://"))
419 }
420 }
421 return b.String()
422}
423
424// --- session/cancel -----------------------------------------------------------
425
426func (f *front) Cancel(_ context.Context, p sdk.CancelNotification) error {
427 f.mu.Lock()
428 defer f.mu.Unlock()
429 if s, ok := f.sessions[p.SessionId]; ok && s.cancel != nil {
430 s.cancel()
431 }
432 return nil
433}
434
435// --- the rest of sdk.Agent: not supported, and saying so -----------------------
436//
437// The SDK requires the full interface; answering "method not found" is the
438// protocol's way of saying a capability we never announced is indeed absent.
439
440func (f *front) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) {
441 return sdk.SetSessionModeResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetMode)
442}
443
444func (f *front) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) {
445 return sdk.SetSessionConfigOptionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetConfigOption)
446}
447
448func (f *front) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) {
449 return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionList)
450}
451
452func (f *front) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) {
453 return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionResume)
454}
455
456func (f *front) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) {
457 return sdk.CloseSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionClose)
458}
459
460func (f *front) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) {
461 return sdk.LogoutResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodLogout)
462}