nandi/oripublic Fork 0
2434cc7fb724f02b34c0189dfd5fb30c1a8a68e3
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

bridge.go · 282 lines · 8.8 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 bridge relays events between the ACP agent session and the
2// browsers connected over WebSocket.
3//
4// It is the meeting point of two asynchronous worlds: the agent streams
5// updates and blocks on permission requests; browsers connect, disconnect and
6// answer at human speed. The bridge broadcasts agent events to every client,
7// replays the session history to late joiners, and routes each permission
8// decision back to the exact request waiting for it.
9package bridge
10
11import (
12 "context"
13 "encoding/json"
14 "fmt"
15 "log/slog"
16 "strconv"
17 "sync"
18
19 acp "github.com/coder/acp-go-sdk"
20)
21
22// Prompter is the slice of the agent session the bridge drives. It is
23// satisfied by *agent.Session and by test fakes.
24type Prompter interface {
25 ID() string
26 PromptText(ctx context.Context, text string) (acp.StopReason, error)
27 Cancel(ctx context.Context) error
28}
29
30// maxHistory bounds the number of events replayed to late-joining clients;
31// beyond it the oldest events are dropped (the live broadcast is unaffected).
32const maxHistory = 4096
33
34// clientBuffer is each subscriber's outgoing queue; a client too slow to
35// drain it is disconnected rather than allowed to stall the whole bridge.
36const clientBuffer = 256
37
38type permissionDecision struct {
39 optionId string
40 cancelled bool
41}
42
43// Bridge broadcasts agent events to WebSocket clients and routes their
44// responses back to the agent. The zero value is not usable; call New.
45//
46// Example:
47//
48// b := bridge.New(slog.Default())
49// session, _ := agent.Start(ctx, agent.Options{Handler: b, ...})
50// b.SetSession(session)
51// http.Handle("GET /ws", b.WebSocketHandler())
52type Bridge struct {
53 logger *slog.Logger
54
55 mu sync.Mutex
56 session Prompter
57 subscribers map[chan []byte]struct{}
58 history [][]byte
59 turnActive bool
60 nextRequest int
61 pendingPerms map[string]chan permissionDecision
62 // pendingRequests keeps the encoded permission_request events so they
63 // can be replayed to clients that connect while a request is open.
64 pendingRequests map[string][]byte
65}
66
67// New creates an empty bridge; attach the agent session with SetSession.
68func New(logger *slog.Logger) *Bridge {
69 if logger == nil {
70 logger = slog.Default()
71 }
72 return &Bridge{
73 logger: logger,
74 subscribers: make(map[chan []byte]struct{}),
75 pendingPerms: make(map[string]chan permissionDecision),
76 pendingRequests: make(map[string][]byte),
77 }
78}
79
80// SetSession attaches the agent session the bridge drives. It must be called
81// before the first client message arrives.
82func (b *Bridge) SetSession(session Prompter) {
83 b.mu.Lock()
84 defer b.mu.Unlock()
85 b.session = session
86}
87
88// Subscribe registers a new client. It returns the client's event channel,
89// the events to replay so the client catches up (history, then any pending
90// permission requests), and an unsubscribe function.
91func (b *Bridge) Subscribe() (events <-chan []byte, replay [][]byte, unsubscribe func()) {
92 ch := make(chan []byte, clientBuffer)
93 b.mu.Lock()
94 defer b.mu.Unlock()
95
96 b.subscribers[ch] = struct{}{}
97
98 sessionId := ""
99 if b.session != nil {
100 sessionId = b.session.ID()
101 }
102 replay = append(replay, encode(Outgoing{Type: OutgoingHello, SessionId: sessionId, TurnActive: b.turnActive}))
103 replay = append(replay, b.history...)
104 for _, raw := range b.pendingRequests {
105 replay = append(replay, raw)
106 }
107
108 return ch, replay, func() {
109 b.mu.Lock()
110 defer b.mu.Unlock()
111 if _, ok := b.subscribers[ch]; ok {
112 delete(b.subscribers, ch)
113 close(ch)
114 }
115 }
116}
117
118// HandleIncoming processes one message from a client. Errors are reported to
119// the clients as OutgoingError events, never returned: a malformed message
120// from one browser must not tear down the connection handling.
121func (b *Bridge) HandleIncoming(ctx context.Context, raw []byte) {
122 var msg Incoming
123 if err := json.Unmarshal(raw, &msg); err != nil {
124 b.broadcast(encode(Outgoing{Type: OutgoingError, Message: "malformed message: " + err.Error()}), false)
125 return
126 }
127
128 switch msg.Type {
129 case IncomingPrompt:
130 b.startTurn(msg.Text)
131 case IncomingCancel:
132 b.cancelTurn(ctx)
133 case IncomingPermissionResponse:
134 b.resolvePermission(msg)
135 default:
136 b.broadcast(encode(Outgoing{Type: OutgoingError, Message: fmt.Sprintf("unknown message type %q", msg.Type)}), false)
137 }
138}
139
140// startTurn launches a prompt turn in the background; updates stream through
141// HandleSessionUpdate while it runs.
142func (b *Bridge) startTurn(text string) {
143 b.mu.Lock()
144 session := b.session
145 if session == nil {
146 b.mu.Unlock()
147 b.broadcast(encode(Outgoing{Type: OutgoingError, Message: "no agent session"}), false)
148 return
149 }
150 if b.turnActive {
151 b.mu.Unlock()
152 b.broadcast(encode(Outgoing{Type: OutgoingError, Message: "a turn is already running"}), false)
153 return
154 }
155 b.turnActive = true
156 b.mu.Unlock()
157
158 b.broadcast(encode(Outgoing{Type: OutgoingUserMessage, Text: text}), true)
159 b.broadcast(encode(Outgoing{Type: OutgoingTurnStarted}), true)
160
161 go func() {
162 // The turn outlives the WebSocket message that started it, so it
163 // runs under its own context, ended by session/cancel only.
164 stop, err := session.PromptText(context.Background(), text)
165
166 b.mu.Lock()
167 b.turnActive = false
168 b.mu.Unlock()
169
170 if err != nil {
171 b.logger.Error("prompt failed", "error", err)
172 b.broadcast(encode(Outgoing{Type: OutgoingError, Message: "prompt failed: " + err.Error()}), true)
173 b.broadcast(encode(Outgoing{Type: OutgoingTurnEnded, StopReason: string(acp.StopReasonRefusal)}), true)
174 return
175 }
176 b.broadcast(encode(Outgoing{Type: OutgoingTurnEnded, StopReason: string(stop)}), true)
177 }()
178}
179
180func (b *Bridge) cancelTurn(ctx context.Context) {
181 b.mu.Lock()
182 session := b.session
183 b.mu.Unlock()
184 if session == nil {
185 return
186 }
187 if err := session.Cancel(ctx); err != nil {
188 b.logger.Error("cancel failed", "error", err)
189 b.broadcast(encode(Outgoing{Type: OutgoingError, Message: "cancel failed: " + err.Error()}), false)
190 }
191}
192
193func (b *Bridge) resolvePermission(msg Incoming) {
194 b.mu.Lock()
195 waiting, ok := b.pendingPerms[msg.RequestId]
196 if ok {
197 delete(b.pendingPerms, msg.RequestId)
198 delete(b.pendingRequests, msg.RequestId)
199 }
200 b.mu.Unlock()
201 if !ok {
202 // Either already answered by another client or unknown: ignore.
203 return
204 }
205 waiting <- permissionDecision{optionId: msg.OptionId, cancelled: msg.Cancelled}
206 b.broadcast(encode(Outgoing{Type: OutgoingPermissionResolved, RequestId: msg.RequestId}), true)
207}
208
209// HandleSessionUpdate implements agent.Handler: every ACP update is relayed
210// verbatim to the clients and recorded for replay.
211func (b *Bridge) HandleSessionUpdate(_ context.Context, notification acp.SessionNotification) {
212 update, err := json.Marshal(notification.Update)
213 if err != nil {
214 b.logger.Error("marshal session update", "error", err)
215 return
216 }
217 b.broadcast(encode(Outgoing{Type: OutgoingSessionUpdate, Update: update}), true)
218}
219
220// HandlePermissionRequest implements agent.Handler: the request is broadcast
221// to the clients and the call blocks until one of them answers or the agent
222// cancels the turn.
223func (b *Bridge) HandlePermissionRequest(ctx context.Context, request acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) {
224 rawRequest, err := json.Marshal(request)
225 if err != nil {
226 return acp.RequestPermissionResponse{}, fmt.Errorf("marshal permission request: %w", err)
227 }
228
229 decisionCh := make(chan permissionDecision, 1)
230 b.mu.Lock()
231 b.nextRequest++
232 requestId := "perm-" + strconv.Itoa(b.nextRequest)
233 event := encode(Outgoing{Type: OutgoingPermissionRequest, RequestId: requestId, Request: rawRequest})
234 b.pendingPerms[requestId] = decisionCh
235 b.pendingRequests[requestId] = event
236 b.mu.Unlock()
237
238 b.broadcast(event, false)
239
240 select {
241 case decision := <-decisionCh:
242 if decision.cancelled {
243 return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeCancelled()}, nil
244 }
245 return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeSelected(acp.PermissionOptionId(decision.optionId))}, nil
246 case <-ctx.Done():
247 // The agent cancelled the turn (or disconnected) while waiting.
248 b.mu.Lock()
249 delete(b.pendingPerms, requestId)
250 delete(b.pendingRequests, requestId)
251 b.mu.Unlock()
252 b.broadcast(encode(Outgoing{Type: OutgoingPermissionResolved, RequestId: requestId}), true)
253 return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeCancelled()}, nil
254 }
255}
256
257// broadcast sends an event to every connected client, disconnecting any
258// client whose buffer is full. With record=true the event also joins the
259// replay history.
260func (b *Bridge) broadcast(event []byte, record bool) {
261 b.mu.Lock()
262 defer b.mu.Unlock()
263
264 if record {
265 b.history = append(b.history, event)
266 if len(b.history) > maxHistory {
267 b.history = b.history[len(b.history)-maxHistory:]
268 }
269 }
270
271 for ch := range b.subscribers {
272 select {
273 case ch <- event:
274 default:
275 // The client stopped draining; drop it instead of blocking
276 // the agent's event stream.
277 delete(b.subscribers, ch)
278 close(ch)
279 b.logger.Warn("dropped a slow websocket client")
280 }
281 }
282}