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

296 lines
8.5 KiB
Go

package acp
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
)
// ACPProcess represents a running ACP agent subprocess.
// One process is shared across all sessions — each goclaw conversation
// creates its own ACP session (identified by session ID) on this process.
type ACPProcess struct {
cmd *exec.Cmd
conn *Conn
agentCaps AgentCaps
workDir string
lastActive time.Time
inUse atomic.Int32 // >0 means at least one prompt is active — reaper must skip
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
exited chan struct{} // closed when process exits
// updateFns routes session/update notifications to the correct active prompt.
updateFns map[string]func(SessionUpdate)
updateMu sync.Mutex
}
// AgentCaps returns the capability flags reported by the agent during Initialize.
func (p *ACPProcess) AgentCaps() AgentCaps {
return p.agentCaps
}
// registerUpdateFn registers a callback for session/update notifications on sessionID.
func (p *ACPProcess) registerUpdateFn(sid string, fn func(SessionUpdate)) {
p.updateMu.Lock()
defer p.updateMu.Unlock()
if p.updateFns == nil {
p.updateFns = make(map[string]func(SessionUpdate))
}
p.updateFns[sid] = fn
}
// unregisterUpdateFn removes the callback for sessionID after a Prompt completes.
func (p *ACPProcess) unregisterUpdateFn(sid string) {
p.updateMu.Lock()
defer p.updateMu.Unlock()
delete(p.updateFns, sid)
}
// dispatchUpdate routes a session/update notification to the registered callback.
// It also performs Gemini ACP protocol mapping: the "agent_message_chunk" update type
// carries content in Update.Content rather than the standard Message field; this is
// normalized here so all callers receive a consistent SessionUpdate.
func (p *ACPProcess) dispatchUpdate(update SessionUpdate) {
// Gemini protocol mapping: agent_message_chunk → Message
if update.Update.SessionUpdate == "agent_message_chunk" && len(update.Update.Content) > 0 {
if update.Message == nil {
update.Message = &MessageUpdate{Role: "assistant"}
}
// Content may arrive as a single object {"type":"text","text":"..."} or an array
var single struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(update.Update.Content, &single); err == nil && single.Type != "" {
update.Message.Content = append(update.Message.Content, ContentBlock{
Type: single.Type,
Text: single.Text,
})
} else {
var arr []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(update.Update.Content, &arr); err == nil {
for _, c := range arr {
update.Message.Content = append(update.Message.Content, ContentBlock{
Type: c.Type,
Text: c.Text,
})
}
}
}
}
p.updateMu.Lock()
fn, ok := p.updateFns[update.SessionID]
p.updateMu.Unlock()
if !ok {
slog.Debug("acp: session/update with no registered callback", "sid", update.SessionID)
return
}
if fn != nil {
fn(update)
}
}
// ProcessPool manages a pool of ACP agent subprocesses.
// Typically a single shared process is used (poolKey = binary identifier),
// and multiple ACP sessions are multiplexed over it.
type ProcessPool struct {
processes sync.Map // poolKey → *ACPProcess
spawnMu sync.Map // poolKey → *sync.Mutex — prevents concurrent spawn
agentBinary string
agentArgs []string
workDir string
idleTTL time.Duration
mu sync.RWMutex // protects toolHandler
toolHandler RequestHandler
done chan struct{}
closeOnce sync.Once
}
// NewProcessPool creates a pool that spawns ACP agents as subprocesses.
func NewProcessPool(binary string, args []string, workDir string, idleTTL time.Duration) *ProcessPool {
pp := &ProcessPool{
agentBinary: binary,
agentArgs: args,
workDir: workDir,
idleTTL: idleTTL,
done: make(chan struct{}),
}
go pp.reapLoop()
return pp
}
// SetToolHandler sets the agent→client request handler (tool bridge).
// Must be called before any GetOrSpawn calls.
func (pp *ProcessPool) SetToolHandler(h RequestHandler) {
pp.mu.Lock()
defer pp.mu.Unlock()
pp.toolHandler = h
}
// getToolHandler returns the current tool handler (thread-safe).
func (pp *ProcessPool) getToolHandler() RequestHandler {
pp.mu.RLock()
defer pp.mu.RUnlock()
return pp.toolHandler
}
// GetOrSpawn returns an existing process for the pool key or spawns a new one.
// Uses per-key mutex to prevent concurrent spawn for the same key.
func (pp *ProcessPool) GetOrSpawn(ctx context.Context, poolKey string) (*ACPProcess, error) {
actual, _ := pp.spawnMu.LoadOrStore(poolKey, &sync.Mutex{})
mu := actual.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
if val, ok := pp.processes.Load(poolKey); ok {
proc := val.(*ACPProcess)
select {
case <-proc.exited:
pp.processes.Delete(poolKey)
slog.Info("acp: respawning crashed process", "pool_key", poolKey)
default:
return proc, nil
}
}
return pp.spawn(ctx, poolKey)
}
// spawn creates a new ACP subprocess and performs the ACP initialize handshake.
// Session creation (session/new) is NOT done here — the provider handles that
// per-conversation via NewSession or LoadSession.
func (pp *ProcessPool) spawn(ctx context.Context, poolKey string) (*ACPProcess, error) {
procCtx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(procCtx, pp.agentBinary, pp.agentArgs...)
cmd.Dir = pp.workDir
cmd.Env = filterACPEnv(os.Environ())
cmd.SysProcAttr = sysProcAttr()
stdinPipe, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("acp: stdin pipe: %w", err)
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("acp: stdout pipe: %w", err)
}
cmd.Stderr = &limitedWriter{max: 4096}
slog.Info("acp: starting subprocess", "pool_key", poolKey, "binary", pp.agentBinary, "args", pp.agentArgs)
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("acp: start %s: %w", pp.agentBinary, err)
}
proc := &ACPProcess{
cmd: cmd,
lastActive: time.Now(),
ctx: procCtx,
cancel: cancel,
exited: make(chan struct{}),
workDir: pp.workDir,
}
// Notification handler: log all notifications and dispatch session/update to callers
notifyHandler := func(method string, params json.RawMessage) {
slog.Info("acp: notification received", "method", method)
slog.Debug("acp: notification params", "method", method, "params", string(params))
if method == "session/update" {
var update SessionUpdate
if err := json.Unmarshal(params, &update); err != nil {
slog.Warn("acp: session/update parse failed", "error", err)
return
}
proc.dispatchUpdate(update)
}
}
proc.conn = NewConn(stdinPipe, stdoutPipe, pp.getToolHandler(), notifyHandler)
proc.conn.Start()
stderrWriter := cmd.Stderr.(*limitedWriter)
go func() {
_ = cmd.Wait()
if s := stderrWriter.String(); s != "" {
slog.Debug("acp: process stderr", "pool_key", poolKey, "stderr", s)
}
close(proc.exited)
}()
slog.Info("acp: performing handshake (initialize)", "pool_key", poolKey)
if err := proc.Initialize(ctx); err != nil {
cancel()
return nil, err
}
pp.processes.Store(poolKey, proc)
slog.Info("acp: process spawned", "pool_key", poolKey, "binary", pp.agentBinary)
return proc, nil
}
// reapLoop periodically checks for idle processes and kills them.
func (pp *ProcessPool) reapLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
pp.processes.Range(func(key, value any) bool {
proc := value.(*ACPProcess)
if proc.inUse.Load() > 0 {
return true
}
proc.mu.Lock()
idle := time.Since(proc.lastActive) > pp.idleTTL
proc.mu.Unlock()
if idle {
slog.Info("acp: reaping idle process", "pool_key", key)
proc.cancel()
pp.processes.Delete(key)
}
return true
})
case <-pp.done:
return
}
}
}
// processCloseTimeout is the per-process max wait during ProcessPool.Close.
// Exposed as a package var so tests can shorten it.
var processCloseTimeout = 5 * time.Second
// Close shuts down all processes gracefully.
func (pp *ProcessPool) Close() error {
pp.closeOnce.Do(func() {
close(pp.done)
pp.processes.Range(func(key, value any) bool {
proc := value.(*ACPProcess)
proc.cancel()
select {
case <-proc.exited:
case <-time.After(processCloseTimeout):
slog.Warn("acp: process did not exit in time", "pool_key", key)
}
pp.processes.Delete(key)
return true
})
})
return nil
}