Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions agent-go/acp/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ func (a *Agent) Prompt(ctx context.Context, threadID string, req discobotagent.P
})
defer stopCancel()

if !yield(message.StartChunk{}, nil) {
// The UI stream requires a messageId on the start chunk; without it
// conversation-stream.ts rejects the whole turn.
if !yield(message.StartChunk{MessageID: "acp-" + discobotagent.GenerateID()}, nil) {
return
}
parked := a.startPrompt(ctx, threadID, sessionID, prompt, req)
Expand Down Expand Up @@ -306,7 +308,7 @@ func (a *Agent) finishPrompt(threadID string, parked *parkedPrompt, result promp
yield(nil, result.err)
return
}
yield(message.ResponseFinishChunk{FinishReason: string(result.response.StopReason)}, nil)
yield(message.ResponseFinishChunk{FinishReason: mapStopReason(result.response.StopReason)}, nil)
}

func sendPromptEvent(ctx context.Context, events chan<- promptEvent, event promptEvent) bool {
Expand All @@ -318,6 +320,23 @@ func sendPromptEvent(ctx context.Context, events chan<- promptEvent, event promp
}
}

// mapStopReason translates an ACP stop reason into Discobot's finishReason enum
// (stop|length|content-filter|tool-calls|error|other). The UI stream schema
// validates this value strictly, so an unmapped ACP reason (e.g. "end_turn")
// fails validation and the turn never completes.
func mapStopReason(reason protocol.StopReason) string {
switch reason {
case protocol.StopReasonMaxTokens, protocol.StopReasonMaxTurnRequests:
return "length"
case protocol.StopReasonRefusal:
return "content-filter"
case protocol.StopReasonEndTurn, protocol.StopReasonCancelled:
return "stop"
default:
return "stop"
}
}

func (a *Agent) Cancel(threadID string) bool {
return a.sessionManager.Cancel(threadID)
}
Expand Down
7 changes: 6 additions & 1 deletion agent-go/acp/agent/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,12 @@ func (m *sessionManager) ensure(ctx context.Context, threadID string) (protocol.
}

func (m *sessionManager) createSession(ctx context.Context, threadID string) (protocol.SessionID, error) {
result, err := m.client.NewSession(ctx, protocol.NewSessionRequest{Cwd: m.cwd})
// MCPServers must serialize as an array, not null: strict ACP servers
// (e.g. claude-code-acp) reject `"mcpServers": null` with Invalid params.
result, err := m.client.NewSession(ctx, protocol.NewSessionRequest{
Cwd: m.cwd,
MCPServers: []protocol.MCPServer{},
})
if err != nil {
return "", err
}
Expand Down
62 changes: 62 additions & 0 deletions agent-go/cmd/agent-api/server/acp_backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package server

import (
"context"
"fmt"
"os"
"os/exec"
"time"

gosdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"

acpagent "github.com/obot-platform/discobot/agent-go/acp/agent"
)

// acpClaudeCodeBackend is the DISCOBOT_AGENT_BACKEND value that routes prompts
// through the official Claude Code CLI's ACP server adapter instead of the
// built-in API-key agent.
const acpClaudeCodeBackend = "claude-code-acp"

// claudeCodeACPCommand is the stdio ACP server installed in the sandbox image
// (see Dockerfile: npm install -g @zed-industries/claude-code-acp).
const claudeCodeACPCommand = "claude-code-acp"

// acpConnectTimeout bounds the spawn + ACP initialize handshake so a stalled
// handshake can't wedge agent-api startup. On timeout we fall back to the
// default agent instead of blocking initialization forever.
const acpConnectTimeout = 20 * time.Second

// connectClaudeCodeACP spawns claude-code-acp as a stdio subprocess and wraps it
// as a Discobot conversation agent. The child inherits this process's
// environment, so the Claude subscription token (CLAUDE_CODE_OAUTH_TOKEN) and the
// sandbox proxy settings (HTTPS_PROXY, NODE_EXTRA_CA_CERTS) reach the CLI and it
// authenticates against the user's subscription itself.
func connectClaudeCodeACP(cwd string, store acpagent.ThreadStore) (*acpagent.Agent, error) {
cmd := exec.Command(claudeCodeACPCommand)
// Surface claude-code-acp's own startup diagnostics in the agent-api journal.
cmd.Stderr = os.Stderr
transport := &gosdkmcp.CommandTransport{Command: cmd}

type connectResult struct {
agent *acpagent.Agent
err error
}
done := make(chan connectResult, 1)
go func() {
agent, err := acpagent.Connect(context.Background(), transport, cwd, store)
done <- connectResult{agent: agent, err: err}
}()

select {
case r := <-done:
if r.err != nil {
return nil, fmt.Errorf("connect %s: %w", claudeCodeACPCommand, r.err)
}
return r.agent, nil
case <-time.After(acpConnectTimeout):
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
return nil, fmt.Errorf("connect %s: timed out after %s (handshake stalled)", claudeCodeACPCommand, acpConnectTimeout)
}
}
22 changes: 21 additions & 1 deletion agent-go/cmd/agent-api/server/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import (
"log"
"maps"
"net/http"
"os"
"strings"

"github.com/go-chi/chi/v5"

acpagent "github.com/obot-platform/discobot/agent-go/acp/agent"
"github.com/obot-platform/discobot/agent-go/agent"
"github.com/obot-platform/discobot/agent-go/agentimpl"
"github.com/obot-platform/discobot/agent-go/assets"
Expand Down Expand Up @@ -57,6 +59,7 @@ type agentRuntime struct {
sudoAuthorizer *credentials.SudoAuthorizer

defaultAgent *agentimpl.DefaultAgent
acpAgent *acpagent.Agent
conversations *agent.ConversationManager
processManager *processes.Manager
promptQueue *promptqueue.Manager
Expand Down Expand Up @@ -203,7 +206,24 @@ func (r *agentRuntime) initAgent() {
r.cfg.AgentCwd,
mcpConfig,
)
r.conversations = agent.NewConversationManager(r.defaultAgent)

// Spike: optionally route prompt execution through the official Claude Code
// CLI over ACP so prompts run on a Claude subscription instead of the
// built-in API-key agent. Thread management, MCP, and hooks stay on the
// default agent (they share the same thread store); only the conversation
// agent moves to ACP. Fall back to the default agent if the CLI can't start.
var convAgent agent.Agent = r.defaultAgent
if strings.TrimSpace(os.Getenv("DISCOBOT_AGENT_BACKEND")) == acpClaudeCodeBackend {
if acpAgent, err := connectClaudeCodeACP(r.cfg.AgentCwd, r.threadStore); err != nil {
log.Printf("discobot-agent-api: %s backend unavailable, using default agent: %v", acpClaudeCodeBackend, err)
} else {
r.acpAgent = acpAgent
convAgent = acpAgent
log.Printf("discobot-agent-api: routing prompts through the %s ACP backend", acpClaudeCodeBackend)
}
}

r.conversations = agent.NewConversationManager(convAgent)
r.processManager = processes.NewManager(r.cfg.AgentCwd)
}

Expand Down
2 changes: 2 additions & 0 deletions server/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ type Config struct {
SandboxImage string // Default sandbox image for local runtimes
SandboxImageRemote string // Default remotely-pullable sandbox image for remote runtimes
SandboxProvider string // Default sandbox provider override
SandboxDNS []string // Optional DNS resolvers for sandbox containers (SANDBOX_DNS, comma-separated)
SandboxIdleTimeout time.Duration // Auto-stop sandboxes after idle period
IdleCheckInterval time.Duration // How often to check for idle sessions
ThreadStatusSyncInterval time.Duration // How often to poll non-terminal session thread summaries
Expand Down Expand Up @@ -238,6 +239,7 @@ func Load() (*Config, error) {
cfg.SandboxImage = getEnv("SANDBOX_IMAGE", DefaultSandboxImage())
cfg.SandboxImageRemote = getEnv("SANDBOX_IMAGE_REMOTE", "")
cfg.SandboxProvider = getEnv("SANDBOX_PROVIDER", "")
cfg.SandboxDNS = getEnvList("SANDBOX_DNS", nil)
cfg.SandboxIdleTimeout = getEnvDuration("SANDBOX_IDLE_TIMEOUT", 1*time.Hour)
cfg.IdleCheckInterval = getEnvDuration("IDLE_CHECK_INTERVAL", 5*time.Minute)
cfg.ThreadStatusSyncInterval = getEnvDuration("THREAD_STATUS_SYNC_INTERVAL", 10*time.Second)
Expand Down
9 changes: 9 additions & 0 deletions server/internal/sandbox/docker/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,15 @@ func (p *Provider) Create(ctx context.Context, state []byte, sessionID string, o
hostConfig.NetworkMode = containerTypes.NetworkMode(p.cfg.DockerNetwork)
}

// Override the container's DNS resolvers when configured (SANDBOX_DNS).
// Needed when the VM's default resolver (the vmnet gateway) can't forward to
// the host's resolver — e.g. a host running DNS on 127.0.0.1 (VPN/local
// resolver), which the gateway can't reach. A public resolver routes out via
// NAT and avoids the broken gateway DNS proxy.
if len(p.cfg.SandboxDNS) > 0 {
hostConfig.DNS = append([]string(nil), p.cfg.SandboxDNS...)
}

// Raise the open file limit so processes inside the container don't hit
// the default 1024 soft limit (tools like Claude Code can easily exhaust it).
hostConfig.Ulimits = []*containerTypes.Ulimit{{
Expand Down
12 changes: 12 additions & 0 deletions server/internal/service/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"io"
"log"
"net/http"
"os"
"sync"
"time"

Expand Down Expand Up @@ -848,6 +849,17 @@ func sandboxCreateEnv(sessionID, sharedSecret, trustKey string) map[string]strin
if trustKey != "" {
env["DISCOBOT_TRUST_KEY"] = trustKey
}
// Spike: opt-in path to drive the official Claude Code CLI over ACP using a
// Claude subscription. Both vars are passed through from the server's own
// environment, flow to systemd PID 1, and reach discobot-agent-api (and the
// claude-code-acp child it spawns) via /run/discobot/agent-env. Leaving the
// Anthropic credential unset keeps the proxy from injecting an x-api-key
// header, so Claude Code authenticates with its own subscription token.
for _, key := range []string{"DISCOBOT_AGENT_BACKEND", "CLAUDE_CODE_OAUTH_TOKEN"} {
if value := os.Getenv(key); value != "" {
env[key] = value
}
}
return env
}

Expand Down