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

111 lines
3.3 KiB
Go

package acp
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"time"
)
// Initialize sends the ACP initialize request to establish capabilities.
func (p *ACPProcess) Initialize(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
req := InitializeRequest{
ProtocolVersion: 1,
ClientInfo: ClientInfo{Name: "GoClaw", Version: "1.0"},
Capabilities: ClientCaps{},
}
var resp InitializeResponse
if err := p.conn.Call(ctx, "initialize", req, &resp); err != nil {
return fmt.Errorf("acp initialize: %w", err)
}
p.agentCaps = resp.Capabilities
slog.Info("acp: initialized", "agent", resp.AgentInfo.Name, "version", resp.AgentInfo.Version, "loadSession", resp.Capabilities.LoadSession)
return nil
}
// NewSession creates a new ACP session and returns its session ID.
func (p *ACPProcess) NewSession(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
cwd := p.workDir
if cwd == "" {
cwd, _ = filepath.Abs(".")
}
req := NewSessionRequest{
Cwd: cwd,
McpServers: []string{},
}
var resp NewSessionResponse
if err := p.conn.Call(ctx, "session/new", req, &resp); err != nil {
return "", fmt.Errorf("acp session/new: %w", err)
}
slog.Info("acp: session/new", "sid", resp.SessionID, "cwd", cwd)
return resp.SessionID, nil
}
// LoadSession restores a previous ACP session by ID (used after process restart).
// Returns the session ID to use going forward (may equal the requested ID).
// Only call if AgentCaps().LoadSession is true.
func (p *ACPProcess) LoadSession(ctx context.Context, sessionID string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
cwd := p.workDir
if cwd == "" {
cwd, _ = filepath.Abs(".")
}
req := LoadSessionRequest{SessionID: sessionID, Cwd: cwd}
var resp LoadSessionResponse
if err := p.conn.Call(ctx, "session/load", req, &resp); err != nil {
return "", fmt.Errorf("acp session/load: %w", err)
}
slog.Info("acp: session/load", "sid", resp.SessionID)
return resp.SessionID, nil
}
// Prompt sends user content to sessionID and blocks until the agent completes,
// invoking onUpdate for each session/update notification received.
func (p *ACPProcess) Prompt(ctx context.Context, sessionID string, content []ContentBlock, onUpdate func(SessionUpdate)) (*PromptResponse, error) {
p.inUse.Add(1)
defer p.inUse.Add(-1)
p.mu.Lock()
p.lastActive = time.Now()
p.mu.Unlock()
p.registerUpdateFn(sessionID, onUpdate)
defer p.unregisterUpdateFn(sessionID)
goclawSession := goclawSessionFromCtx(ctx)
slog.Info("acp: session/prompt", "session", goclawSession, "sid", sessionID)
req := PromptRequest{
SessionID: sessionID,
Prompt: content,
}
var resp PromptResponse
if err := p.conn.Call(ctx, "session/prompt", req, &resp); err != nil {
return nil, fmt.Errorf("acp session/prompt: %w", err)
}
p.mu.Lock()
p.lastActive = time.Now()
p.mu.Unlock()
slog.Info("acp: session/prompt completed", "session", goclawSession, "sid", sessionID, "stopReason", resp.StopReason)
return &resp, nil
}
// Cancel sends a session/cancel notification for the given session.
func (p *ACPProcess) Cancel(sessionID string) error {
return p.conn.Notify("session/cancel", CancelNotification{
SessionID: sessionID,
})
}