Files
040b0f1944 fix(acp): Gemini ACP protocol fixes and multi-session architecture (#901)
* fix(cli): add missing X-GoClaw-User-Id header to gateway client

* feat(acp): comprehensive integration with Gemini ACP protocol

- Support nested JSON-RPC notification structures
- Add robust streaming text collection and mapping
- Increase handshake timeout to 60s for heavy model initialization
- Fix WebSocket user_id authentication and schema v47 compatibility
- Allow Google/GCP environment variables for ACP subprocesses

* refactor(acp): multi-session architecture with session tracing and temp session cleanup

- One shared Gemini process, multiple ACP sessions per process (one per goclaw conversation)
- resolveSession: per-key mutex prevents TOCTOU race on concurrent session creation
- Respawn detection via proc pointer comparison; session/load fallback after crash
- sessionReaper: purges ACP sessions idle >30min; temp- sessions purged immediately on completion
- WithGoclawSession context propagation: goclaw session key appears alongside ACP sid in all logs
- dispatchUpdate: Gemini agent_message_chunk protocol mapping normalized here
- session_test.go: full coverage of multi-session API (Initialize, NewSession, Prompt, Cancel, dispatch)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(acp): align with ACP protocol standard (SDK v0.16.1)

- InitializeRequest: "capabilities" → "clientCapabilities" (standard field name)
- LoadSessionRequest: add mcpServers field (required by standard)
- mapStopReason: add standard stop reasons (max_tokens, cancelled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(acp): address review findings from PR #901

- B1: Restore RequiredSchemaVersion to 55 (was incorrectly set to 47)
- S1: Move X-GoClaw-User-Id header outside token check
- S2: Add env gate (ACP_GEMINI_E2E) to Gemini E2E test
- S3: Fix tab indentation in jsonrpc.go writeMessage
- Build: Extract Pdeathsig to platform-specific files for cross-platform build

* fix(acp): address S4-S7 review findings

S4/S5: Send session/cancel before purging sessions locally
- purgeSession() now cancels ACP session before deleting map entry
- sessionReaper() sends cancel notification for idle sessions
- Updated comments to reflect actual behavior

S6: Tighten GOOGLE_/GCP_ env var filtering
- Add GOOGLE_, GCP_ back to sensitiveEnvPrefixes
- Add allowedEnvExact for safe vars: GOOGLE_API_KEY,
  GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_CLOUD_PROJECT, GCP_PROJECT

S7: Propagate ACP errors to callers
- Chat/ChatStream now return err alongside ChatResponse
- Enables upstream retry logic and error metrics

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: viettranx <viettranx@gmail.com>
2026-04-18 12:17:23 +07:00

220 lines
5.6 KiB
Go

package acp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"sync"
"sync/atomic"
)
// jsonrpcMessage is the wire format for JSON-RPC 2.0 messages.
type jsonrpcMessage struct {
JSONRPC string `json:"jsonrpc"`
ID *int64 `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *jsonrpcError `json:"error,omitempty"`
}
type jsonrpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *jsonrpcError) Error() string {
return fmt.Sprintf("jsonrpc error %d: %s", e.Code, e.Message)
}
// RequestHandler handles agent→client requests (fs, terminal, permission).
// Context is derived from the connection lifetime and cancelled when the read loop exits.
type RequestHandler func(ctx context.Context, method string, params json.RawMessage) (any, error)
// NotifyHandler handles agent→client notifications (session/update).
type NotifyHandler func(method string, params json.RawMessage)
// Conn manages bidirectional JSON-RPC 2.0 over stdio pipes.
type Conn struct {
writer io.Writer
reader io.Reader
nextID atomic.Int64
pending sync.Map // id → chan *jsonrpcMessage
handler RequestHandler
notify NotifyHandler
done chan struct{}
mu sync.Mutex // protects writes
}
// NewConn creates a JSON-RPC connection over the given reader/writer pair.
func NewConn(writer io.Writer, reader io.Reader, handler RequestHandler, notify NotifyHandler) *Conn {
return &Conn{
writer: writer,
reader: reader,
handler: handler,
notify: notify,
done: make(chan struct{}),
}
}
// Start begins the read loop goroutine that dispatches incoming messages.
func (c *Conn) Start() {
go c.readLoop()
}
// readLoop reads newline-delimited JSON messages from stdout and dispatches them.
func (c *Conn) readLoop() {
scanner := bufio.NewScanner(c.reader)
scanner.Buffer(make([]byte, 0, 256*1024), 10*1024*1024) // 256KB initial, 10MB max
for scanner.Scan() {
line := scanner.Bytes()
slog.Debug("acp.jsonrpc: < READ", "line", string(line))
if len(line) == 0 {
continue
}
var msg jsonrpcMessage
if err := json.Unmarshal(line, &msg); err != nil {
slog.Debug("acp.jsonrpc: skip malformed line", "error", err)
continue
}
switch {
case msg.ID != nil && msg.Method == "":
// Response to our request — dispatch to pending caller
if ch, ok := c.pending.LoadAndDelete(*msg.ID); ok {
ch.(chan *jsonrpcMessage) <- &msg
}
case msg.ID != nil && msg.Method != "":
// Agent→client request — handle and respond
go c.handleRequest(&msg)
case msg.ID == nil && msg.Method != "":
// Notification — dispatch to handler
if c.notify != nil {
c.notify(msg.Method, msg.Params)
}
}
}
if err := scanner.Err(); err != nil {
slog.Debug("acp.jsonrpc: read loop ended", "error", err)
}
close(c.done)
}
// handleRequest processes an agent→client request and sends back the response.
// Uses a context derived from the connection lifetime so long-running handlers
// (e.g. waitForExit) are cancelled when the connection closes.
func (c *Conn) handleRequest(msg *jsonrpcMessage) {
var resp jsonrpcMessage
resp.JSONRPC = "2.0"
resp.ID = msg.ID
// Create a context that cancels when the connection's read loop exits.
ctx, cancel := context.WithCancel(context.Background())
go func() {
select {
case <-c.done:
cancel()
case <-ctx.Done():
}
}()
defer cancel()
if c.handler == nil {
resp.Error = &jsonrpcError{Code: -32601, Message: "no handler registered"}
} else {
result, err := c.handler(ctx, msg.Method, msg.Params)
if err != nil {
resp.Error = &jsonrpcError{Code: -32000, Message: err.Error()}
} else {
data, marshalErr := json.Marshal(result)
if marshalErr != nil {
resp.Error = &jsonrpcError{Code: -32603, Message: "internal: " + marshalErr.Error()}
} else {
resp.Result = data
}
}
}
c.writeMessage(&resp)
}
// Call sends a JSON-RPC request and blocks until the response arrives.
func (c *Conn) Call(ctx context.Context, method string, params any, result any) error {
id := c.nextID.Add(1)
ch := make(chan *jsonrpcMessage, 1)
c.pending.Store(id, ch)
defer c.pending.Delete(id)
paramsData, err := json.Marshal(params)
if err != nil {
return fmt.Errorf("marshal params: %w", err)
}
msg := &jsonrpcMessage{
JSONRPC: "2.0",
ID: &id,
Method: method,
Params: paramsData,
}
if err := c.writeMessage(msg); err != nil {
return fmt.Errorf("write request: %w", err)
}
select {
case resp := <-ch:
if resp.Error != nil {
return resp.Error
}
if result != nil && len(resp.Result) > 0 {
return json.Unmarshal(resp.Result, result)
}
return nil
case <-ctx.Done():
return ctx.Err()
case <-c.done:
return fmt.Errorf("connection closed")
}
}
// Notify sends a JSON-RPC notification (no response expected).
func (c *Conn) Notify(method string, params any) error {
paramsData, err := json.Marshal(params)
if err != nil {
return fmt.Errorf("marshal params: %w", err)
}
msg := &jsonrpcMessage{
JSONRPC: "2.0",
Method: method,
Params: paramsData,
}
return c.writeMessage(msg)
}
// writeMessage marshals and writes a JSON-RPC message followed by a newline.
func (c *Conn) writeMessage(msg *jsonrpcMessage) error {
data, err := json.Marshal(msg)
if err != nil {
return err
}
data = append(data, '\n')
c.mu.Lock()
defer c.mu.Unlock()
slog.Debug("acp.jsonrpc: > WRITE", "data", string(data))
_, err = c.writer.Write(data)
return err
}
// Done returns a channel that is closed when the read loop exits.
func (c *Conn) Done() <-chan struct{} {
return c.done
}