mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
* 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>
383 lines
11 KiB
Go
383 lines
11 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers/acp"
|
|
)
|
|
|
|
// acpSessionEntry tracks a live ACP session for one goclaw conversation.
|
|
type acpSessionEntry struct {
|
|
id string // ACP session ID returned by session/new or session/load
|
|
proc *acp.ACPProcess // process that owns this session (for respawn detection)
|
|
lastUsed time.Time
|
|
}
|
|
|
|
// ACPProvider implements Provider by orchestrating ACP-compatible agent subprocesses.
|
|
// One shared Gemini process is used; each goclaw conversation gets its own ACP session.
|
|
type ACPProvider struct {
|
|
name string
|
|
pool *acp.ProcessPool
|
|
bridge *acp.ToolBridge
|
|
defaultModel string
|
|
permMode string
|
|
poolKey string // key for the shared process in the pool (binary + args)
|
|
|
|
acpSessions sync.Map // goclawSessionKey → *acpSessionEntry
|
|
sessionMu sync.Map // goclawSessionKey → *sync.Mutex (prevents concurrent session creation)
|
|
|
|
done chan struct{}
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
// ACPOption configures an ACPProvider.
|
|
type ACPOption func(*ACPProvider)
|
|
|
|
// WithACPName overrides the provider name (default: "acp").
|
|
func WithACPName(name string) ACPOption {
|
|
return func(p *ACPProvider) {
|
|
if name != "" {
|
|
p.name = name
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithACPModel sets the default model/agent name.
|
|
func WithACPModel(model string) ACPOption {
|
|
return func(p *ACPProvider) {
|
|
if model != "" {
|
|
p.defaultModel = model
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithACPPermMode sets the permission mode for the tool bridge.
|
|
func WithACPPermMode(mode string) ACPOption {
|
|
return func(p *ACPProvider) {
|
|
if mode != "" {
|
|
p.permMode = mode
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewACPProvider creates a provider that orchestrates ACP agents as subprocesses.
|
|
func NewACPProvider(binary string, args []string, workDir string, idleTTL time.Duration, denyPatterns []*regexp.Regexp, opts ...ACPOption) *ACPProvider {
|
|
// Pool key identifies the shared process: binary + args combination
|
|
poolKey := binary
|
|
if len(args) > 0 {
|
|
poolKey += "|" + strings.Join(args, " ")
|
|
}
|
|
|
|
p := &ACPProvider{
|
|
name: "acp",
|
|
defaultModel: "claude",
|
|
poolKey: poolKey,
|
|
done: make(chan struct{}),
|
|
}
|
|
for _, opt := range opts {
|
|
opt(p)
|
|
}
|
|
|
|
var bridgeOpts []acp.ToolBridgeOption
|
|
if len(denyPatterns) > 0 {
|
|
bridgeOpts = append(bridgeOpts, acp.WithDenyPatterns(denyPatterns))
|
|
}
|
|
if p.permMode != "" {
|
|
bridgeOpts = append(bridgeOpts, acp.WithPermMode(p.permMode))
|
|
}
|
|
p.bridge = acp.NewToolBridge(workDir, bridgeOpts...)
|
|
|
|
p.pool = acp.NewProcessPool(binary, args, workDir, idleTTL)
|
|
p.pool.SetToolHandler(p.bridge.Handle)
|
|
|
|
go p.sessionReaper()
|
|
return p
|
|
}
|
|
|
|
// sessionReaper removes ACP sessions idle for more than 30 minutes.
|
|
// Sends session/cancel to release resources on the agent side before purging locally.
|
|
func (p *ACPProvider) sessionReaper() {
|
|
const sessionIdleTTL = 30 * time.Minute
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
p.acpSessions.Range(func(key, value any) bool {
|
|
entry := value.(*acpSessionEntry)
|
|
if time.Since(entry.lastUsed) > sessionIdleTTL {
|
|
slog.Info("acp: expiring idle session", "goclaw_session", key, "sid", entry.id)
|
|
if entry.proc != nil {
|
|
_ = entry.proc.Cancel(entry.id)
|
|
}
|
|
p.acpSessions.Delete(key)
|
|
}
|
|
return true
|
|
})
|
|
case <-p.done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveSession returns the ACP session ID for a goclaw session key.
|
|
// It creates a new session if none exists, or reloads it after a process respawn.
|
|
// A per-key mutex prevents concurrent creation races for the same session.
|
|
func (p *ACPProvider) resolveSession(ctx context.Context, proc *acp.ACPProcess, goclawKey string) (string, error) {
|
|
actual, _ := p.sessionMu.LoadOrStore(goclawKey, &sync.Mutex{})
|
|
mu := actual.(*sync.Mutex)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
if val, ok := p.acpSessions.Load(goclawKey); ok {
|
|
entry := val.(*acpSessionEntry)
|
|
if entry.proc == proc {
|
|
// Same process instance: session is still live, just update last-used
|
|
entry.lastUsed = time.Now()
|
|
return entry.id, nil
|
|
}
|
|
// Process was respawned — try to restore the session
|
|
slog.Info("acp: process respawned, attempting session restore",
|
|
"goclaw_session", goclawKey, "old_sid", entry.id)
|
|
if proc.AgentCaps().LoadSession {
|
|
sid, err := proc.LoadSession(ctx, entry.id)
|
|
if err == nil {
|
|
p.acpSessions.Store(goclawKey, &acpSessionEntry{id: sid, proc: proc, lastUsed: time.Now()})
|
|
return sid, nil
|
|
}
|
|
slog.Warn("acp: session/load failed, creating new session", "old_sid", entry.id, "error", err)
|
|
}
|
|
// session/load not supported or failed — fall through to create new
|
|
}
|
|
|
|
slog.Info("acp: creating new session", "goclaw_session", goclawKey, "pool_key", p.poolKey)
|
|
sid, err := proc.NewSession(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
p.acpSessions.Store(goclawKey, &acpSessionEntry{id: sid, proc: proc, lastUsed: time.Now()})
|
|
return sid, nil
|
|
}
|
|
|
|
func (p *ACPProvider) Name() string { return p.name }
|
|
func (p *ACPProvider) DefaultModel() string { return p.defaultModel }
|
|
|
|
// Capabilities implements CapabilitiesAware for pipeline code-path selection.
|
|
func (p *ACPProvider) Capabilities() ProviderCapabilities {
|
|
return ProviderCapabilities{
|
|
Streaming: true,
|
|
ToolCalling: true,
|
|
StreamWithTools: true,
|
|
Thinking: true,
|
|
Vision: false,
|
|
CacheControl: false,
|
|
MaxContextWindow: 200_000,
|
|
TokenizerID: "cl100k_base",
|
|
}
|
|
}
|
|
|
|
// Chat sends a prompt and returns the complete response (non-streaming).
|
|
func (p *ACPProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
|
|
sessionKey := extractStringOpt(req.Options, OptSessionKey)
|
|
if sessionKey == "" {
|
|
sessionKey = fmt.Sprintf("temp-%d", time.Now().UnixNano())
|
|
}
|
|
|
|
proc, err := p.pool.GetOrSpawn(ctx, p.poolKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acp: spawn failed: %w", err)
|
|
}
|
|
|
|
acpSessionID, err := p.resolveSession(ctx, proc, sessionKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.HasPrefix(sessionKey, "temp-") {
|
|
defer p.purgeSession(sessionKey)
|
|
}
|
|
|
|
content := extractACPContent(req)
|
|
if len(content) == 0 {
|
|
return nil, fmt.Errorf("acp: no user message in request")
|
|
}
|
|
|
|
ctx = acp.WithGoclawSession(ctx, sessionKey)
|
|
|
|
var buf strings.Builder
|
|
var updateCount int
|
|
promptResp, err := proc.Prompt(ctx, acpSessionID, content, func(update acp.SessionUpdate) {
|
|
if update.Message != nil {
|
|
for _, block := range update.Message.Content {
|
|
if block.Type == "text" {
|
|
buf.WriteString(block.Text)
|
|
updateCount++
|
|
}
|
|
}
|
|
}
|
|
})
|
|
if err != nil {
|
|
slog.Error("acp: chat error", "session", sessionKey, "sid", acpSessionID, "error", err)
|
|
return &ChatResponse{
|
|
Content: fmt.Sprintf("[ACP Error] %v", err),
|
|
FinishReason: "error",
|
|
}, err
|
|
}
|
|
|
|
slog.Info("acp: chat completed", "session", sessionKey, "sid", acpSessionID,
|
|
"stopReason", mapStopReason(promptResp), "updates", updateCount, "contentLen", buf.Len())
|
|
return &ChatResponse{
|
|
Content: buf.String(),
|
|
FinishReason: mapStopReason(promptResp),
|
|
Usage: &Usage{},
|
|
}, nil
|
|
}
|
|
|
|
// ChatStream sends a prompt and streams response chunks via onChunk callback.
|
|
func (p *ACPProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
|
|
sessionKey := extractStringOpt(req.Options, OptSessionKey)
|
|
if sessionKey == "" {
|
|
sessionKey = fmt.Sprintf("temp-%d", time.Now().UnixNano())
|
|
}
|
|
|
|
proc, err := p.pool.GetOrSpawn(ctx, p.poolKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acp: spawn failed: %w", err)
|
|
}
|
|
|
|
acpSessionID, err := p.resolveSession(ctx, proc, sessionKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.HasPrefix(sessionKey, "temp-") {
|
|
defer p.purgeSession(sessionKey)
|
|
}
|
|
|
|
content := extractACPContent(req)
|
|
if len(content) == 0 {
|
|
return nil, fmt.Errorf("acp: no user message in request")
|
|
}
|
|
|
|
ctx = acp.WithGoclawSession(ctx, sessionKey)
|
|
|
|
// done channel ensures the cancel goroutine exits cleanly on normal completion,
|
|
// preventing it from sending a spurious session/cancel after the prompt finishes.
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
if errors.Is(ctx.Err(), context.Canceled) {
|
|
_ = proc.Cancel(acpSessionID)
|
|
}
|
|
case <-done:
|
|
}
|
|
}()
|
|
|
|
var buf strings.Builder
|
|
var updateCount int
|
|
promptResp, err := proc.Prompt(ctx, acpSessionID, content, func(update acp.SessionUpdate) {
|
|
if update.Message != nil {
|
|
for _, block := range update.Message.Content {
|
|
if block.Type == "text" {
|
|
onChunk(StreamChunk{Content: block.Text})
|
|
buf.WriteString(block.Text)
|
|
updateCount++
|
|
}
|
|
}
|
|
}
|
|
if update.ToolCall != nil && update.ToolCall.Status == "running" {
|
|
slog.Debug("acp: tool call", "name", update.ToolCall.Name)
|
|
}
|
|
})
|
|
if err != nil {
|
|
slog.Error("acp: chat error", "session", sessionKey, "sid", acpSessionID, "error", err)
|
|
return &ChatResponse{
|
|
Content: fmt.Sprintf("[ACP Error] %v", err),
|
|
FinishReason: "error",
|
|
}, err
|
|
}
|
|
|
|
onChunk(StreamChunk{Done: true})
|
|
slog.Info("acp: chat stream completed", "session", sessionKey, "sid", acpSessionID,
|
|
"stopReason", mapStopReason(promptResp), "updates", updateCount, "contentLen", buf.Len())
|
|
|
|
return &ChatResponse{
|
|
Content: buf.String(),
|
|
FinishReason: mapStopReason(promptResp),
|
|
Usage: &Usage{},
|
|
}, nil
|
|
}
|
|
|
|
// purgeSession removes a session entry from both tracking maps.
|
|
// Sends session/cancel to release resources on the agent side before purging locally.
|
|
// Used to immediately discard one-shot (temp-) sessions after completion.
|
|
func (p *ACPProvider) purgeSession(key string) {
|
|
if val, ok := p.acpSessions.Load(key); ok {
|
|
entry := val.(*acpSessionEntry)
|
|
if entry.proc != nil {
|
|
_ = entry.proc.Cancel(entry.id)
|
|
}
|
|
}
|
|
p.acpSessions.Delete(key)
|
|
p.sessionMu.Delete(key)
|
|
slog.Info("acp: purged temp session", "goclaw_session", key)
|
|
}
|
|
|
|
// Close shuts down all subprocesses and cleans up terminals.
|
|
func (p *ACPProvider) Close() error {
|
|
p.closeOnce.Do(func() {
|
|
close(p.done)
|
|
})
|
|
_ = p.bridge.Close()
|
|
return p.pool.Close()
|
|
}
|
|
|
|
// extractACPContent extracts user message + images from ChatRequest into ACP ContentBlocks.
|
|
func extractACPContent(req ChatRequest) []acp.ContentBlock {
|
|
systemPrompt, userMsg, images := extractFromMessages(req.Messages)
|
|
if userMsg == "" {
|
|
return nil
|
|
}
|
|
|
|
var blocks []acp.ContentBlock
|
|
|
|
// Prepend system prompt to user message (ACP agents have no separate system prompt API)
|
|
text := userMsg
|
|
if systemPrompt != "" {
|
|
text = systemPrompt + "\n\n" + userMsg
|
|
}
|
|
blocks = append(blocks, acp.ContentBlock{Type: "text", Text: text})
|
|
|
|
for _, img := range images {
|
|
blocks = append(blocks, acp.ContentBlock{
|
|
Type: "image",
|
|
Data: img.Data,
|
|
MimeType: img.MimeType,
|
|
})
|
|
}
|
|
|
|
return blocks
|
|
}
|
|
|
|
// mapStopReason converts ACP stopReason to GoClaw finish reason.
|
|
func mapStopReason(resp *acp.PromptResponse) string {
|
|
if resp == nil {
|
|
return "stop"
|
|
}
|
|
switch resp.StopReason {
|
|
case "max_tokens", "maxContextLength":
|
|
return "length"
|
|
case "cancelled":
|
|
return "stop"
|
|
default:
|
|
return "stop"
|
|
}
|
|
}
|