nandi/oripublic Fork 0
3d1f70c3b1e0d8c9a0b27adcc1bd1a9f053c050e
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

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

forked from bots-garden/ori

agent.go · 201 lines · 6.4 KBGo Blame HistoryRaw
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday1// Package agent runs an ACP (Agent Client Protocol) code agent as a
2// subprocess and exposes its session lifecycle to the rest of ori.
3//
4// It is agent-agnostic: any executable speaking ACP on stdio works (the
5// Claude Code adapter, Gemini CLI, a test mock, ...). Events flowing from the
6// agent (streamed updates, permission requests) are delivered to a Handler
7// supplied by the caller.
8package agent
9
10import (
11 "context"
12 "errors"
13 "fmt"
14 "io"
15 "log/slog"
16 "os"
17 "os/exec"
18
19 acp "github.com/coder/acp-go-sdk"
20)
21
22// Handler receives the events an ACP agent sends during a session.
23//
24// HandleSessionUpdate is called for every streamed update (message chunks,
25// tool calls, plan changes, ...). HandlePermissionRequest must block until a
26// decision is available and return it; returning an error rejects the
27// request.
28type Handler interface {
29 HandleSessionUpdate(ctx context.Context, notification acp.SessionNotification)
30 HandlePermissionRequest(ctx context.Context, request acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error)
31}
32
33// Options configure Start and Connect.
34type Options struct {
35 // Command is the agent command line: executable first, then arguments.
36 // Only used by Start.
37 Command []string
38 // Cwd is the absolute working directory given to the ACP session.
39 Cwd string
40 // Handler receives session updates and permission requests. Required.
41 Handler Handler
42 // Logger receives connection diagnostics. Optional.
43 Logger *slog.Logger
44 // Stderr receives the agent process's stderr. Defaults to os.Stderr.
45 // Only used by Start.
46 Stderr io.Writer
47}
48
49// Session is a live conversation with a running ACP agent.
50type Session struct {
51 id acp.SessionId
52 conn *acp.ClientSideConnection
53 initResp acp.InitializeResponse
54 shutdown func() error
55}
56
57// ID returns the ACP session identifier.
58func (s *Session) ID() string { return string(s.id) }
59
60// InitializeResult returns the capabilities the agent advertised during the
61// ACP initialize handshake.
62func (s *Session) InitializeResult() acp.InitializeResponse { return s.initResp }
63
64// PromptText sends a plain-text user prompt and blocks until the turn ends,
65// while updates stream to the Handler. It returns the reason the turn
66// stopped.
67//
68// Example:
69//
70// stop, err := session.PromptText(ctx, "Explain this repository")
71// if err == nil && stop == acp.StopReasonEndTurn {
72// fmt.Println("turn finished")
73// }
74func (s *Session) PromptText(ctx context.Context, text string) (acp.StopReason, error) {
75 return s.Prompt(ctx, []acp.ContentBlock{acp.TextBlock(text)})
76}
77
78// Prompt sends a user prompt made of arbitrary content blocks and blocks
79// until the turn ends. Updates stream to the Handler while it runs.
80func (s *Session) Prompt(ctx context.Context, blocks []acp.ContentBlock) (acp.StopReason, error) {
81 resp, err := s.conn.Prompt(ctx, acp.PromptRequest{SessionId: s.id, Prompt: blocks})
82 if err != nil {
83 return "", fmt.Errorf("prompt: %w", err)
84 }
85 return resp.StopReason, nil
86}
87
88// Cancel asks the agent to stop the current turn. The pending Prompt call
89// then returns with acp.StopReasonCancelled.
90func (s *Session) Cancel(ctx context.Context) error {
91 if err := s.conn.Cancel(ctx, acp.CancelNotification{SessionId: s.id}); err != nil {
92 return fmt.Errorf("cancel: %w", err)
93 }
94 return nil
95}
96
97// Done closes when the agent disconnects, whatever the cause.
98func (s *Session) Done() <-chan struct{} { return s.conn.Done() }
99
100// Close terminates the agent. It is safe to call more than once.
101func (s *Session) Close() error {
102 if s.shutdown == nil {
103 return nil
104 }
105 shutdown := s.shutdown
106 s.shutdown = nil
107 return shutdown()
108}
109
110// Connect performs the ACP handshake (initialize + session/new) over an
111// existing transport: in writes to the agent, out reads from it. It is the
112// transport-independent core of Start and what tests use with in-memory
113// pipes.
114func Connect(ctx context.Context, in io.Writer, out io.Reader, opts Options) (*Session, error) {
115 if opts.Handler == nil {
116 return nil, errors.New("agent: Options.Handler is required")
117 }
118
119 conn := acp.NewClientSideConnection(&client{handler: opts.Handler}, in, out)
120 if opts.Logger != nil {
121 conn.SetLogger(opts.Logger)
122 }
123
124 initResp, err := conn.Initialize(ctx, acp.InitializeRequest{
125 ProtocolVersion: acp.ProtocolVersionNumber,
126 ClientCapabilities: acp.ClientCapabilities{
127 Fs: acp.FileSystemCapabilities{ReadTextFile: true, WriteTextFile: true},
128 },
129 })
130 if err != nil {
131 return nil, fmt.Errorf("initialize: %w", err)
132 }
133
134 newResp, err := conn.NewSession(ctx, acp.NewSessionRequest{Cwd: opts.Cwd, McpServers: []acp.McpServer{}})
135 if err != nil {
136 return nil, fmt.Errorf("new session: %w", err)
137 }
138
139 return &Session{id: newResp.SessionId, conn: conn, initResp: initResp}, nil
140}
141
142// Start spawns opts.Command as a subprocess speaking ACP on its stdio, then
143// performs the handshake. Closing the returned Session kills the process.
144//
145// Example:
146//
147// session, err := agent.Start(ctx, agent.Options{
✨ Switch the ACP adapter to @agentclientprotocol/claude-agent-acp (template 0.0.2) 5d476ff k33g yesterday148// Command: []string{"npx", "-y", "@agentclientprotocol/claude-agent-acp"},
✨ Introduce new feature(s): ACP web client — Go backend (agent, bridge, httpserver, mockagent) + React SPA (Zed-like agent panel), tests, quality gate PASS 2434cc7 k33g yesterday149// Cwd: "/work/my-project",
150// Handler: myHandler,
151// })
152func Start(ctx context.Context, opts Options) (*Session, error) {
153 if len(opts.Command) == 0 {
154 return nil, errors.New("agent: Options.Command is required")
155 }
156
157 cmd, stdin, stdout, err := spawnProcess(opts)
158 if err != nil {
159 return nil, err
160 }
161
162 session, err := Connect(ctx, stdin, stdout, opts)
163 if err != nil {
164 _ = killProcess(cmd)
165 return nil, err
166 }
167 session.shutdown = func() error { return killProcess(cmd) }
168 return session, nil
169}
170
171// killProcess terminates the agent process and reaps it; the "killed" error
172// from Wait is the expected outcome, not a failure.
173func killProcess(cmd *exec.Cmd) error {
174 if err := cmd.Process.Kill(); err != nil {
175 return err
176 }
177 _ = cmd.Wait()
178 return nil
179}
180
181// spawnProcess launches the agent command with its stdio wired for ACP.
182func spawnProcess(opts Options) (*exec.Cmd, io.WriteCloser, io.ReadCloser, error) {
183 cmd := exec.Command(opts.Command[0], opts.Command[1:]...)
184 cmd.Dir = opts.Cwd
185 cmd.Stderr = opts.Stderr
186 if cmd.Stderr == nil {
187 cmd.Stderr = os.Stderr
188 }
189 stdin, err := cmd.StdinPipe()
190 if err != nil {
191 return nil, nil, nil, fmt.Errorf("agent stdin: %w", err)
192 }
193 stdout, err := cmd.StdoutPipe()
194 if err != nil {
195 return nil, nil, nil, fmt.Errorf("agent stdout: %w", err)
196 }
197 if err := cmd.Start(); err != nil {
198 return nil, nil, nil, fmt.Errorf("start agent %q: %w", opts.Command[0], err)
199 }
200 return cmd, stdin, stdout, nil
201}