feat: complete acp-go-sdk integration with task state updates

- Use acp-go-sdk for all ACP communication in agentctl
- Mark agent as completed when Prompt() returns successfully
- Update task state to COMPLETED when agent completes
- Update task state to FAILED when agent fails and retries exhausted
- Enhanced E2E test to verify agent actually creates files
- E2E test now validates full flow: task creation -> agent work -> file creation -> task completion
This commit is contained in:
jcfs
2026-01-10 16:46:22 +00:00
parent 214b6ed2d8
commit 20f5f43fb3
143 changed files with 2750 additions and 296 deletions
+172 -50
View File
@@ -7,22 +7,66 @@
## Overview
Kandev agents run in Docker containers and communicate via **ACP (Agent Communication Protocol)** - a JSON-RPC 2.0 protocol over stdin/stdout. This enables bidirectional communication for task execution with real-time progress updates.
Kandev agents run in Docker containers with **agentctl** - a sidecar process that manages the AI agent and provides HTTP-based control. The agent communicates via **ACP (Agent Communication Protocol)** - a JSON-RPC 2.0 protocol over stdin/stdout.
```
Backend Agent Container
│ │
│ initialize │
├──────────────────────────────────► │
│◄──────────────────────────────────┤
│ session/prompt │
├──────────────────────────────────► │
│ session/update (progress) │
│◄──────────────────────────────────┤
│ session/update (complete) │
│◄──────────────────────────────────┤
Backend agentctl (HTTP) Agent Process
│ │ │
│ POST /api/v1/start │ │
├────────────────────────────►│ spawn auggie --acp │
│ ├───────────────────────────►│
│ POST /api/v1/acp/call │ initialize │
├────────────────────────────►├───────────────────────────►│
│ │◄───────────────────────────┤
│◄────────────────────────────┤ │
│ WS /api/v1/acp/stream │ session/update │
│◄────────────────────────────┤◄───────────────────────────┤
│ │ │
```
## Architecture
### agentctl
`agentctl` is an HTTP server that runs inside each agent container as a sidecar process. It:
1. **Manages the agent subprocess** (e.g., `auggie --acp`)
2. **Provides HTTP API** for control operations (start, stop, status)
3. **Relays ACP messages** between HTTP clients and the agent's stdin/stdout
4. **Streams output** via WebSocket for real-time monitoring
### Communication Flow
1. Backend creates container with agentctl as entrypoint
2. agentctl starts HTTP server on port 9999
3. Backend waits for agentctl to be ready (`GET /health`)
4. Backend starts agent process (`POST /api/v1/start`)
5. Backend sends ACP messages via HTTP (`POST /api/v1/acp/call`)
6. Backend receives ACP updates via WebSocket (`GET /api/v1/acp/stream`)
### agentctl HTTP API
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
| `/api/v1/status` | GET | Get agent status |
| `/api/v1/start` | POST | Start agent process |
| `/api/v1/stop` | POST | Stop agent process |
| `/api/v1/acp/send` | POST | Send ACP message (fire and forget) |
| `/api/v1/acp/call` | POST | Send ACP request, wait for response |
| `/api/v1/acp/stream` | GET | WebSocket for ACP message streaming |
| `/api/v1/output` | GET | Get buffered output |
| `/api/v1/output/stream` | GET | WebSocket for real-time output |
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `AGENTCTL_PORT` | 9999 | HTTP server port |
| `AGENTCTL_AGENT_COMMAND` | `auggie --acp` | Command to run agent |
| `AGENTCTL_AUTO_START` | false | Auto-start agent on startup |
| `AGENTCTL_LOG_LEVEL` | info | Logging level |
---
## Agent Communication Protocol (ACP)
@@ -248,15 +292,24 @@ Backend Agent Container
### Augment Agent (`augment-agent`)
Primary AI coding agent powered by Auggie CLI.
Primary AI coding agent powered by Auggie CLI with agentctl sidecar.
**Docker Image**: `kandev/augment-agent:latest`
**Architecture:**
```
Container
├── agentctl (HTTP server on port 9999)
│ └── manages → auggie --acp (subprocess)
└── /workspace (mounted project directory)
```
**Specification:**
- **Working Directory**: `/workspace`
- **Memory**: 4096 MB
- **CPU**: 2.0 cores
- **Timeout**: 1 hour
- **Exposed Port**: 9999 (agentctl HTTP API)
- **Capabilities**: code_generation, code_review, refactoring, testing, shell_execution
**Required Environment Variables:**
@@ -268,42 +321,80 @@ TASK_DESCRIPTION # The task to execute
**Optional Environment Variables:**
```bash
AUGGIE_SESSION_ID # Resume previous session
AGENTCTL_AUTO_START # Auto-start agent on container start
```
**Mounted Volumes:**
- `{workspace}` → `/workspace` - Project directory
- `{augment_sessions}` → `/root/.augment/sessions` - Session persistence
**Dockerfile**: `backend/dockerfiles/augment-agent/Dockerfile`
**Dockerfile**: `apps/backend/dockerfiles/augment-agent/Dockerfile`
**Building the Image:**
```bash
cd apps/backend
docker build -t kandev/augment-agent:latest -f dockerfiles/augment-agent/Dockerfile .
```
---
## Creating Custom Agents
### Step 1: Create Dockerfile
Custom agents can be created in two ways:
Create `backend/dockerfiles/your-agent/Dockerfile`:
### Option A: With agentctl (Recommended)
Use the multi-stage Dockerfile pattern to include agentctl:
```dockerfile
# Stage 1: Build agentctl
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o agentctl ./cmd/agentctl
# Stage 2: Final image
FROM ubuntu:22.04
# Install dependencies
RUN apt-get update && apt-get install -y \
curl jq git \
&& rm -rf /var/lib/apt/lists/*
# Install your agent dependencies
RUN apt-get update && apt-get install -y curl jq git && rm -rf /var/lib/apt/lists/*
# Copy agent script
# Copy agentctl from builder
COPY --from=builder /build/agentctl /usr/local/bin/agentctl
# Copy your agent script
COPY agent.sh /usr/local/bin/agent
RUN chmod +x /usr/local/bin/agent
WORKDIR /workspace
EXPOSE 9999
ENTRYPOINT ["/usr/local/bin/agent"]
# Set agentctl as entrypoint
ENV AGENTCTL_AGENT_COMMAND="/usr/local/bin/agent --acp"
ENTRYPOINT ["/usr/local/bin/agentctl"]
```
### Step 2: Implement ACP Protocol
### Option B: Standalone ACP Agent
Create `backend/dockerfiles/your-agent/agent.sh`:
For simpler agents that don't need HTTP control:
```dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y curl jq git && rm -rf /var/lib/apt/lists/*
COPY agent.sh /usr/local/bin/agent
RUN chmod +x /usr/local/bin/agent
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/agent", "--acp"]
```
### Implementing ACP Protocol
Create `agent.sh` that implements JSON-RPC 2.0:
```bash
#!/bin/bash
@@ -311,15 +402,16 @@ set -e
# ACP mode - JSON-RPC 2.0
if [ "$1" = "--acp" ]; then
# Initialize response
echo '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.0","serverInfo":{"name":"your-agent","version":"1.0.0"},"capabilities":{"streaming":true}}}'
# Read requests from stdin
while IFS= read -r line; do
METHOD=$(echo "$line" | jq -r '.method')
ID=$(echo "$line" | jq -r '.id')
case "$METHOD" in
"initialize")
echo '{"jsonrpc":"2.0","id":'$ID',"result":{"protocolVersion":"1.0","serverInfo":{"name":"your-agent","version":"1.0.0"},"capabilities":{"streaming":true}}}'
;;
"session/new")
SESSION_ID=$(uuidgen)
echo '{"jsonrpc":"2.0","id":'$ID',"result":{"sessionId":"'$SESSION_ID'"}}'
@@ -342,7 +434,6 @@ if [ "$1" = "--acp" ]; then
;;
"session/cancel")
# Handle cancellation
exit 0
;;
esac
@@ -350,9 +441,9 @@ if [ "$1" = "--acp" ]; then
fi
```
### Step 3: Register Agent Type
### Register Agent Type
Add to `backend/internal/agent/registry/defaults.go`:
Add to `apps/backend/internal/agent/registry/defaults.go`:
```go
{
@@ -373,7 +464,7 @@ Add to `backend/internal/agent/registry/defaults.go`:
```bash
# Build image
cd backend/dockerfiles/your-agent
cd apps/backend/dockerfiles/your-agent
docker build -t kandev/your-agent:latest .
# Test locally
@@ -613,45 +704,75 @@ Prior versions used a simpler JSON format (not JSON-RPC). For compatibility refe
6. **Validate messages** - Check for required fields before processing
7. **Set exit codes** - 0 for success, non-zero for errors
### Message Flow
### Message Flow (with agentctl)
```
1. Backend starts container
2. Backend sends initialize
3. Agent responds with capabilities
4. Backend sends session/new
5. Agent responds with sessionId
6. Backend sends session/prompt
7. Agent responds immediately (accepted: true)
8. Agent sends multiple session/update notifications
9. Agent sends final session/update (type: complete)
10. Container exits
1. Backend creates container with agentctl
2. agentctl starts HTTP server on port 9999
3. Backend waits for agentctl health check
4. Backend calls POST /api/v1/start
5. agentctl spawns agent subprocess
6. Backend calls POST /api/v1/acp/call with initialize
7. Agent responds with capabilities
8. Backend calls POST /api/v1/acp/call with session/new
9. Agent responds with sessionId
10. Backend connects to WS /api/v1/acp/stream
11. Backend calls POST /api/v1/acp/call with session/prompt
12. Agent sends session/update notifications (streamed via WebSocket)
13. Agent sends final session/update (type: complete)
14. Backend calls POST /api/v1/stop or container exits
```
### Testing Your Agent
**With agentctl:**
```bash
# Build and run container
docker run -d -p 9999:9999 kandev/your-agent:latest
# Check health
curl http://localhost:9999/health
# Start agent
curl -X POST http://localhost:9999/api/v1/start
# Send ACP message
curl -X POST http://localhost:9999/api/v1/acp/call \
-H "Content-Type: application/json" \
-d '{"method":"initialize","params":{"protocolVersion":"1.0"}}'
```
**Direct ACP testing:**
```bash
# Test initialize
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1.0"}}' | \
docker run -i kandev/your-agent:latest --acp
docker run -i kandev/your-agent:latest /usr/local/bin/agent --acp
# Test full flow
(
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1.0"}}'
echo '{"jsonrpc":"2.0","id":2,"method":"session/new","params":{}}'
echo '{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"message":"test"}}'
) | docker run -i kandev/your-agent:latest --acp
) | docker run -i kandev/your-agent:latest /usr/local/bin/agent --acp
```
---
## Reference Implementation
See `backend/dockerfiles/augment-agent/` for a complete working example:
See `apps/backend/` for complete working examples:
- **Dockerfile** - Container setup
- **agent.sh** - Entry point and ACP handler
- **README.md** - Usage instructions
**agentctl:**
- `cmd/agentctl/main.go` - Entry point
- `internal/agentctl/` - HTTP server, process manager, ACP relay
**Augment Agent:**
- `dockerfiles/augment-agent/Dockerfile` - Multi-stage build with agentctl
- `dockerfiles/augment-agent/entrypoint.sh` - Container entrypoint
**Backend Integration:**
- `internal/agent/agentctl/client.go` - HTTP client for agentctl
- `internal/agent/lifecycle/manager.go` - Container lifecycle management
---
@@ -659,8 +780,9 @@ See `backend/dockerfiles/augment-agent/` for a complete working example:
- **System Architecture**: [ARCHITECTURE.md](ARCHITECTURE.md)
- **REST API Specification**: [docs/openapi.yaml](docs/openapi.yaml)
- **Agent Registry**: `backend/internal/agent/registry/defaults.go`
- **ACP Implementation**: `backend/internal/agent/acp/`
- **Agent Registry**: `apps/backend/internal/agent/registry/defaults.go`
- **agentctl Implementation**: `apps/backend/internal/agentctl/`
- **ACP Protocol Types**: `apps/backend/pkg/acp/jsonrpc/`
- **JSON-RPC 2.0 Spec**: https://www.jsonrpc.org/specification
---
+15 -15
View File
@@ -53,7 +53,7 @@ make test
- **Language**: TypeScript
- **Port**: 3000
### Backend (`backend/`)
### Backend (`apps/backend/`)
- **Language**: Go 1.21+
- **Framework**: Gin (HTTP), Gorilla WebSocket
- **Database**: SQLite (WAL mode)
@@ -74,16 +74,16 @@ make test
├── ARCHITECTURE.md # This file
├── AGENTS.md # Agent protocol details
├── apps/
│ └── web/ # Next.js web application
│ ├── app/ # Pages (App Router)
│ ├── components/ # React components
│ └── lib/ # Utilities
├── backend/
│ ├── cmd/kandev/ # Main entry point
│ ├── internal/
│ │ ├── agent/ # Agent manager (Docker, ACP, lifecycle)
│ │ ├── task/ # Task service (CRUD, SQLite)
│ │ ├── orchestrator/ # Task execution coordinator
│ ├── web/ # Next.js web application
│ │ ├── app/ # Pages (App Router)
│ │ ├── components/ # React components
│ │ └── lib/ # Utilities
│ └── backend/ # Go backend services
│ ├── cmd/kandev/ # Main entry point
│ ├── internal/
│ │ ├── agent/ # Agent manager (Docker, ACP, lifecycle)
│ │ ├── task/ # Task service (CRUD, SQLite)
│ │ ├── orchestrator/ # Task execution coordinator
│ │ ├── events/ # Event bus (in-memory/NATS)
│ │ ├── gateway/ # WebSocket gateway
│ │ └── common/ # Shared utilities (config, logger, db)
@@ -251,7 +251,7 @@ curl -X POST http://localhost:8080/api/v1/agents/launch \
**Location**: `./kandev.db` (SQLite with WAL mode)
**Schema** (`backend/internal/task/repository/sqlite.go`):
**Schema** (`apps/backend/internal/task/repository/sqlite.go`):
- `boards` - Board records
- `columns` - Column definitions with state mapping
- `tasks` - Task records with JSON metadata
@@ -307,9 +307,9 @@ make test-web
| `ARCHITECTURE.md` | This file |
| `AGENTS.md` | Agent protocol details |
| `docs/openapi.yaml` | REST API spec |
| `backend/cmd/kandev/main.go` | Entry point |
| `backend/internal/agent/registry/defaults.go` | Agent type registry |
| `backend/internal/task/repository/sqlite.go` | Database schema |
| `apps/backend/cmd/kandev/main.go` | Entry point |
| `apps/backend/internal/agent/registry/defaults.go` | Agent type registry |
| `apps/backend/internal/task/repository/sqlite.go` | Database schema |
| `apps/web/app/page.tsx` | Web app home |
## Contributing
+1 -1
View File
@@ -2,7 +2,7 @@
# Orchestrates both backend (Go) and web app (Next.js)
# Directories
BACKEND_DIR := backend
BACKEND_DIR := apps/backend
WEB_DIR := apps/web
# Tools
+18 -16
View File
@@ -66,7 +66,7 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed architecture docum
### Build and Run
```bash
cd backend
cd apps/backend
# Build the binary
make build
@@ -113,27 +113,29 @@ curl -X POST http://localhost:8080/api/v1/agents/launch \
curl http://localhost:8080/api/v1/agents/{agent_id}/status
```
See [backend/NEXT_STEPS.md](backend/NEXT_STEPS.md) for detailed API reference and next steps.
See [apps/backend/NEXT_STEPS.md](apps/backend/NEXT_STEPS.md) for detailed API reference and next steps.
## Project Structure
```
kandev/
├── backend/
│ ├── cmd/kandev/ # Unified binary entry point
│ ├── internal/
│ │ ├── agent/ # Agent manager (docker, lifecycle, registry, api)
│ │ ├── task/ # Task service (models, repository, service, api)
│ │ ├── orchestrator/ # Orchestrator service
│ │ ├── events/ # In-memory event bus
│ │ └── common/ # Shared utilities (logger, config)
│ ├── dockerfiles/ # Agent container Dockerfiles
│ ├── Makefile
│ └── NEXT_STEPS.md # Development roadmap
├── apps/
│ ├── backend/ # Go backend services
│ │ ├── cmd/kandev/ # Unified binary entry point
│ │ ├── internal/
│ │ │ ├── agent/ # Agent manager (docker, lifecycle, registry, api)
│ │ │ ├── task/ # Task service (models, repository, service, api)
│ │ │ ├── orchestrator/ # Orchestrator service
│ │ │ ├── events/ # In-memory event bus
│ │ │ └── common/ # Shared utilities (logger, config)
│ │ ├── pkg/acp/ # ACP protocol types
│ │ ├── dockerfiles/ # Agent container Dockerfiles
│ │ └── Makefile
│ └── web/ # Frontend application
├── docs/ # Architecture and planning docs
├── migrations/ # Database migrations (future)
├── deployments/ # Deployment configs
└── scripts/ # Utility scripts
└── scripts/ # Utility scripts (including e2e-test.sh)
```
## Configuration
@@ -146,7 +148,7 @@ kandev/
## Development
```bash
cd backend
cd apps/backend
# Build
make build
@@ -177,7 +179,7 @@ go vet ./...
- [ ] Authentication and authorization
- [ ] Frontend UI
See [backend/NEXT_STEPS.md](backend/NEXT_STEPS.md) for the complete roadmap.
See [apps/backend/NEXT_STEPS.md](apps/backend/NEXT_STEPS.md) for the complete roadmap.
## License
+41
View File
@@ -0,0 +1,41 @@
module github.com/kandev/agentctl
go 1.21
require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
go.uber.org/zap v1.27.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+103
View File
@@ -0,0 +1,103 @@
// Package main is the entry point for agentctl - a control process that runs
// inside Docker containers to manage AI agent processes.
//
// agentctl provides:
// - HTTP REST API for control operations (start, stop, pause, resume)
// - WebSocket streaming of agent output
// - Process management for the underlying agent (e.g., auggie --acp)
// - ACP message relay between HTTP API and agent subprocess
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/kandev/kandev/internal/agentctl/api"
"github.com/kandev/kandev/internal/agentctl/config"
"github.com/kandev/kandev/internal/agentctl/process"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
func main() {
// Load configuration
cfg := config.Load()
// Initialize logger
log, err := logger.NewLogger(logger.LoggingConfig{
Level: cfg.LogLevel,
Format: cfg.LogFormat,
OutputPath: "stdout",
})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to initialize logger: %v\n", err)
os.Exit(1)
}
defer log.Sync()
log.Info("starting agentctl",
zap.String("version", "0.1.0"),
zap.Int("port", cfg.Port),
zap.String("agent_command", cfg.AgentCommand),
)
// Create process manager
procMgr := process.NewManager(cfg, log)
// Create API server
apiServer := api.NewServer(cfg, procMgr, log)
// Create HTTP server
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: apiServer.Router(),
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
// Start server in goroutine
go func() {
log.Info("HTTP server listening", zap.String("addr", server.Addr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("HTTP server error", zap.Error(err))
}
}()
// Auto-start agent if configured
if cfg.AutoStart {
log.Info("auto-starting agent process")
ctx := context.Background()
if err := procMgr.Start(ctx); err != nil {
log.Error("failed to auto-start agent", zap.Error(err))
}
}
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("shutting down agentctl")
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Stop the agent process
if err := procMgr.Stop(ctx); err != nil {
log.Error("failed to stop agent process", zap.Error(err))
}
// Shutdown HTTP server
if err := server.Shutdown(ctx); err != nil {
log.Error("HTTP server shutdown error", zap.Error(err))
}
log.Info("agentctl stopped")
}
@@ -28,9 +28,6 @@ import (
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
// Agent ACP
agentacp "github.com/kandev/kandev/internal/agent/acp"
// Orchestrator packages
"github.com/kandev/kandev/internal/orchestrator"
orchestratorapi "github.com/kandev/kandev/internal/orchestrator/api"
@@ -132,7 +129,6 @@ func main() {
// ============================================
var lifecycleMgr *lifecycle.Manager
var agentRegistry *registry.Registry
var acpSessionMgr *agentacp.SessionManager
if dockerClient != nil {
log.Info("Initializing Agent Manager...")
@@ -149,12 +145,8 @@ func main() {
credsMgr.AddProvider(credentials.NewFileProvider(credsFile))
}
// ACP Session Manager for bidirectional communication
acpSessionMgr = agentacp.NewSessionManager(eventBus, log)
// Lifecycle Manager
// Lifecycle Manager (uses agentctl for agent communication)
lifecycleMgr = lifecycle.NewManager(dockerClient, agentRegistry, eventBus, log)
lifecycleMgr.SetACPManager(acpSessionMgr)
lifecycleMgr.SetCredentialsManager(credsMgr)
if err := lifecycleMgr.Start(ctx); err != nil {
@@ -253,7 +245,8 @@ func main() {
// Agent Manager routes (if available)
if lifecycleMgr != nil && agentRegistry != nil && dockerClient != nil {
agentapi.SetupRoutes(v1Group, lifecycleMgr, agentRegistry, dockerClient, acpSessionMgr, log)
// Note: ACP is now handled via agentctl inside containers, so we pass nil for the ACP manager
agentapi.SetupRoutes(v1Group, lifecycleMgr, agentRegistry, dockerClient, nil, log)
}
// Health check
@@ -0,0 +1,72 @@
# Augment Agent Dockerfile
# A coding agent powered by Auggie CLI (Augment Code) with agentctl sidecar
# Stage 1: Build agentctl from Go source
FROM golang:1.24-alpine AS builder
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build agentctl binary
RUN CGO_ENABLED=0 GOOS=linux go build -o /agentctl ./cmd/agentctl
# Stage 2: Final image with Node.js and agentctl
FROM node:20-slim
# Install dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
jq \
python3 \
python3-pip \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install Auggie CLI from npm
RUN npm install -g @augmentcode/auggie
# Copy agentctl binary from builder
COPY --from=builder /agentctl /usr/local/bin/agentctl
RUN chmod +x /usr/local/bin/agentctl
# Create agent directory
WORKDIR /agent
# Copy agent script (legacy, for backward compatibility)
COPY dockerfiles/augment-agent/agent.sh /agent/agent.sh
RUN chmod +x /agent/agent.sh
# Copy entrypoint script
COPY dockerfiles/augment-agent/entrypoint.sh /agent/entrypoint.sh
RUN chmod +x /agent/entrypoint.sh
# Workspace will be mounted here
VOLUME /workspace
WORKDIR /workspace
# Environment variables (passed at runtime)
# TASK_ID - The task identifier
# TASK_TITLE - Task title/name
# TASK_DESCRIPTION - Full task description
# AUGMENT_SESSION_AUTH - Session auth JSON for Augment (from ~/.augment/session.json)
# AUGGIE_SESSION_ID - (Optional) Session ID to resume a previous conversation
#
# agentctl configuration:
# AGENTCTL_PORT - HTTP API port (default: 9999)
# AGENTCTL_AGENT_COMMAND - Command to run (default: "auggie --acp")
# AGENTCTL_AUTO_START - Auto-start agent on startup (default: false)
# AGENTCTL_MODE - "agentctl" for HTTP mode, "direct" for legacy stdin/stdout mode
# Expose agentctl HTTP API port
EXPOSE 9999
# Default entrypoint that decides between agentctl and direct mode
ENTRYPOINT ["/agent/entrypoint.sh"]
@@ -0,0 +1,41 @@
#!/bin/bash
# Entrypoint script for Augment Agent
# Supports two modes:
# - "agentctl": Runs agentctl HTTP server that manages auggie subprocess
# - "direct": Legacy mode that runs auggie directly (stdin/stdout)
set -e
MODE="${AGENTCTL_MODE:-agentctl}"
echo "Starting Augment Agent in ${MODE} mode..."
# Common validation
if ! command -v auggie &> /dev/null; then
echo "ERROR: auggie CLI not found. Is @augmentcode/auggie installed?"
exit 1
fi
if [ -z "$AUGMENT_SESSION_AUTH" ]; then
echo "ERROR: AUGMENT_SESSION_AUTH environment variable not set"
exit 1
fi
case "$MODE" in
"agentctl")
# Run agentctl HTTP server
echo "Starting agentctl on port ${AGENTCTL_PORT:-9999}..."
exec /usr/local/bin/agentctl
;;
"direct")
# Legacy mode: run auggie directly
echo "Running auggie directly in ACP mode..."
exec auggie --acp --workspace-root /workspace
;;
*)
echo "ERROR: Unknown AGENTCTL_MODE: $MODE"
echo "Valid modes: agentctl, direct"
exit 1
;;
esac
+1
View File
@@ -20,6 +20,7 @@ require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/coder/acp-go-sdk v0.6.3 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
+2
View File
@@ -60,6 +60,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ=
github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+275
View File
@@ -0,0 +1,275 @@
package agentctl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/coder/acp-go-sdk"
"github.com/gorilla/websocket"
"go.uber.org/zap"
)
// InitializeResponse from agentctl
type InitializeResponse struct {
Success bool `json:"success"`
Response *acp.InitializeResponse `json:"response,omitempty"`
Error string `json:"error,omitempty"`
}
// Initialize sends the ACP initialize request
func (c *Client) Initialize(ctx context.Context, clientName, clientVersion string) (*acp.InitializeResponse, error) {
reqBody := struct {
ClientName string `json:"client_name"`
ClientVersion string `json:"client_version"`
}{
ClientName: clientName,
ClientVersion: clientVersion,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/acp/initialize", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result InitializeResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if !result.Success {
return nil, fmt.Errorf("initialize failed: %s", result.Error)
}
return result.Response, nil
}
// NewSessionResponse from agentctl
type NewSessionResponse struct {
Success bool `json:"success"`
SessionID string `json:"session_id,omitempty"`
Error string `json:"error,omitempty"`
}
// NewSession creates a new ACP session
func (c *Client) NewSession(ctx context.Context, cwd string) (string, error) {
reqBody := struct {
Cwd string `json:"cwd"`
}{Cwd: cwd}
body, err := json.Marshal(reqBody)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/acp/session/new", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result NewSessionResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if !result.Success {
return "", fmt.Errorf("new session failed: %s", result.Error)
}
return result.SessionID, nil
}
// Prompt sends a prompt to the agent (non-blocking - returns immediately, use StreamUpdates for responses)
func (c *Client) Prompt(ctx context.Context, text string) error {
reqBody := struct {
Text string `json:"text"`
}{Text: text}
body, err := json.Marshal(reqBody)
if err != nil {
return err
}
// Use a longer timeout for prompt - agent may take time
client := &http.Client{
Timeout: 10 * time.Minute,
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/acp/prompt", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var result struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if !result.Success {
return fmt.Errorf("prompt failed: %s", result.Error)
}
return nil
}
// SessionNotification represents a session update from the agent
type SessionNotification = acp.SessionNotification
// StreamUpdates opens a WebSocket connection for streaming session updates
func (c *Client) StreamUpdates(ctx context.Context, handler func(acp.SessionNotification)) error {
wsURL := "ws" + c.baseURL[4:] + "/api/v1/acp/stream"
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
if err != nil {
return fmt.Errorf("failed to connect to updates stream: %w", err)
}
c.mu.Lock()
c.acpConn = conn
c.mu.Unlock()
c.logger.Info("connected to updates stream", zap.String("url", wsURL))
// Read messages in a goroutine
go func() {
defer func() {
c.mu.Lock()
c.acpConn = nil
c.mu.Unlock()
conn.Close()
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
c.logger.Info("updates stream closed normally")
} else {
c.logger.Debug("updates stream error", zap.Error(err))
}
return
}
var notification acp.SessionNotification
if err := json.Unmarshal(message, &notification); err != nil {
c.logger.Warn("failed to parse session notification", zap.Error(err))
continue
}
handler(notification)
}
}()
return nil
}
// CloseUpdatesStream closes the updates stream connection
func (c *Client) CloseUpdatesStream() {
c.CloseACPStream()
}
// CloseACPStream closes the ACP stream connection
func (c *Client) CloseACPStream() {
c.mu.Lock()
defer c.mu.Unlock()
if c.acpConn != nil {
c.acpConn.Close()
c.acpConn = nil
}
}
// StreamOutput opens a WebSocket connection for streaming output
func (c *Client) StreamOutput(ctx context.Context, includeHistory bool, handler func(*OutputLine)) error {
wsURL := "ws" + c.baseURL[4:] + "/api/v1/output/stream"
if includeHistory {
wsURL += "?history=true"
}
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
if err != nil {
return fmt.Errorf("failed to connect to output stream: %w", err)
}
c.mu.Lock()
c.outputConn = conn
c.mu.Unlock()
c.logger.Info("connected to output stream", zap.String("url", wsURL))
// Read messages in a goroutine
go func() {
defer func() {
c.mu.Lock()
c.outputConn = nil
c.mu.Unlock()
conn.Close()
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
c.logger.Info("output stream closed normally")
} else {
c.logger.Debug("output stream error", zap.Error(err))
}
return
}
var line OutputLine
if err := json.Unmarshal(message, &line); err != nil {
c.logger.Warn("failed to parse output line", zap.Error(err))
continue
}
handler(&line)
}
}()
return nil
}
// CloseOutputStream closes the output stream connection
func (c *Client) CloseOutputStream() {
c.mu.Lock()
defer c.mu.Unlock()
if c.outputConn != nil {
c.outputConn.Close()
c.outputConn = nil
}
}
// Close closes all connections
func (c *Client) Close() {
c.CloseACPStream()
c.CloseOutputStream()
}
@@ -0,0 +1,170 @@
// Package agentctl provides a client for communicating with agentctl running inside containers
package agentctl
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Client communicates with agentctl via HTTP
type Client struct {
baseURL string
httpClient *http.Client
logger *logger.Logger
// WebSocket connections for streaming
outputConn *websocket.Conn
acpConn *websocket.Conn
mu sync.RWMutex
}
// StatusResponse from agentctl
type StatusResponse struct {
AgentStatus string `json:"agent_status"`
ProcessInfo map[string]interface{} `json:"process_info"`
}
// OutputLine from agentctl
type OutputLine struct {
Timestamp time.Time `json:"timestamp"`
Stream string `json:"stream"`
Content string `json:"content"`
}
// NewClient creates a new agentctl client
func NewClient(host string, port int, log *logger.Logger) *Client {
return &Client{
baseURL: fmt.Sprintf("http://%s:%d", host, port),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
logger: log.WithFields(zap.String("component", "agentctl-client")),
}
}
// Health checks if agentctl is healthy
func (c *Client) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil)
if err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("health check failed: %d", resp.StatusCode)
}
return nil
}
// GetStatus returns the agent status
func (c *Client) GetStatus(ctx context.Context) (*StatusResponse, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/status", nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var status StatusResponse
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
return nil, err
}
return &status, nil
}
// Start starts the agent process
func (c *Client) Start(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/start", nil)
if err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var result struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if !result.Success {
return fmt.Errorf("start failed: %s", result.Error)
}
return nil
}
// Stop stops the agent process
func (c *Client) Stop(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/stop", nil)
if err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var result struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if !result.Success {
return fmt.Errorf("stop failed: %s", result.Error)
}
return nil
}
// WaitForReady waits until agentctl is ready to accept requests
func (c *Client) WaitForReady(ctx context.Context, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if time.Now().After(deadline) {
return fmt.Errorf("timeout waiting for agentctl to be ready")
}
if err := c.Health(ctx); err == nil {
c.logger.Info("agentctl is ready")
return nil
}
}
}
}
// BaseURL returns the base URL of the agentctl client
func (c *Client) BaseURL() string {
return c.baseURL
}
@@ -284,6 +284,38 @@ func (c *Client) GetContainerInfo(ctx context.Context, containerID string) (*Con
return info, nil
}
// GetContainerIP returns the IP address of a container
func (c *Client) GetContainerIP(ctx context.Context, containerID string) (string, error) {
c.logger.Debug("Getting container IP", zap.String("container_id", containerID))
inspect, err := c.cli.ContainerInspect(ctx, containerID)
if err != nil {
c.logger.Error("Failed to inspect container for IP", zap.String("container_id", containerID), zap.Error(err))
return "", err
}
// Try to get IP from default bridge network first
if inspect.NetworkSettings != nil {
// Check bridge network
if inspect.NetworkSettings.IPAddress != "" {
return inspect.NetworkSettings.IPAddress, nil
}
// Check other networks
for netName, netSettings := range inspect.NetworkSettings.Networks {
if netSettings.IPAddress != "" {
c.logger.Debug("Found container IP",
zap.String("container_id", containerID),
zap.String("network", netName),
zap.String("ip", netSettings.IPAddress))
return netSettings.IPAddress, nil
}
}
}
return "", fmt.Errorf("no IP address found for container %s", containerID)
}
// GetContainerLogs returns logs from a container.
func (c *Client) GetContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error) {
c.logger.Debug("Getting container logs",
@@ -5,7 +5,6 @@ package lifecycle
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -15,8 +14,7 @@ import (
"github.com/google/uuid"
"go.uber.org/zap"
// ACP session management is used via the ACPManager interface
_ "github.com/kandev/kandev/internal/agent/acp"
"github.com/kandev/kandev/internal/agent/agentctl"
"github.com/kandev/kandev/internal/agent/docker"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/common/logger"
@@ -25,12 +23,16 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// Default agentctl port
const AgentCtlPort = 9999
// AgentInstance represents a running agent instance
type AgentInstance struct {
ID string
TaskID string
AgentType string
ContainerID string
ContainerIP string // IP address of the container for agentctl communication
Status v1.AgentStatus
StartedAt time.Time
FinishedAt *time.Time
@@ -38,6 +40,9 @@ type AgentInstance struct {
ErrorMessage string
Progress int
Metadata map[string]interface{}
// agentctl client for this instance
agentctl *agentctl.Client
}
// LaunchRequest contains parameters for launching an agent
@@ -50,18 +55,6 @@ type LaunchRequest struct {
Metadata map[string]interface{}
}
// ACPManager interface for ACP session management
type ACPManager interface {
CreateSession(ctx context.Context, instanceID, taskID string, stdin io.WriteCloser, stdout io.Reader) error
Initialize(ctx context.Context, instanceID string) error
NewSession(ctx context.Context, instanceID, cwd string) (string, error)
LoadSession(ctx context.Context, instanceID, sessionID string) error
Prompt(ctx context.Context, instanceID, message string) error
Cancel(ctx context.Context, instanceID, reason string) error
CloseSession(instanceID string) error
GetSessionID(instanceID string) (string, bool)
}
// CredentialsManager interface for credential retrieval
type CredentialsManager interface {
GetCredentialValue(ctx context.Context, key string) (value string, err error)
@@ -72,7 +65,6 @@ type Manager struct {
docker *docker.Client
registry *registry.Registry
eventBus bus.EventBus
acpMgr ACPManager
credsMgr CredentialsManager
logger *logger.Logger
@@ -108,11 +100,6 @@ func NewManager(
}
}
// SetACPManager sets the ACP session manager
func (m *Manager) SetACPManager(acpMgr ACPManager) {
m.acpMgr = acpMgr
}
// SetCredentialsManager sets the credentials manager for injecting secrets
func (m *Manager) SetCredentialsManager(credsMgr CredentialsManager) {
m.credsMgr = credsMgr
@@ -169,29 +156,42 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc
// 4. Build container config from registry config
containerConfig := m.buildContainerConfig(instanceID, req, agentConfig)
// 5. Create and start the container WITH stdin attached for ACP
containerID, err := m.docker.CreateContainerInteractive(ctx, containerConfig)
// 5. Create and start the container
containerID, err := m.docker.CreateContainer(ctx, containerConfig)
if err != nil {
return nil, fmt.Errorf("failed to create container: %w", err)
}
if err := m.docker.StartContainer(ctx, containerID); err != nil {
// Cleanup the created container on failure
_ = m.docker.RemoveContainer(ctx, containerID, true)
return nil, fmt.Errorf("failed to start container: %w", err)
}
// 6. Track the instance
// 6. Get container IP for agentctl communication
containerIP, err := m.docker.GetContainerIP(ctx, containerID)
if err != nil {
m.logger.Warn("failed to get container IP, trying localhost",
zap.String("container_id", containerID),
zap.Error(err))
containerIP = "127.0.0.1"
}
// 7. Create agentctl client
agentctlClient := agentctl.NewClient(containerIP, AgentCtlPort, m.logger)
// 8. Track the instance
now := time.Now()
instance := &AgentInstance{
ID: instanceID,
TaskID: req.TaskID,
AgentType: req.AgentType,
ContainerID: containerID,
ContainerIP: containerIP,
Status: v1.AgentStatusRunning,
StartedAt: now,
Progress: 0,
Metadata: req.Metadata,
agentctl: agentctlClient,
}
m.mu.Lock()
@@ -200,82 +200,262 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc
m.byContainer[containerID] = instanceID
m.mu.Unlock()
// 7. Publish agent.started event
// 9. Publish agent.started event
m.publishEvent(ctx, events.AgentStarted, instance)
// 8. Setup ACP session for bidirectional communication
if m.acpMgr != nil {
// Attach to container stdin/stdout
attachResult, err := m.docker.AttachContainer(context.Background(), containerID)
if err != nil {
m.logger.Warn("failed to attach to container for ACP",
zap.String("container_id", containerID),
zap.Error(err))
} else {
// Create ACP session
if err := m.acpMgr.CreateSession(context.Background(), instanceID, req.TaskID, attachResult.Stdin, attachResult.Stdout); err != nil {
m.logger.Warn("failed to create ACP session",
zap.String("instance_id", instanceID),
zap.Error(err))
} else {
// Initialize the ACP session
if err := m.acpMgr.Initialize(context.Background(), instanceID); err != nil {
m.logger.Warn("failed to initialize ACP session",
zap.String("instance_id", instanceID),
zap.Error(err))
} else {
// Create or load session based on metadata
// The workspace is mounted at /workspace inside the container
containerCwd := "/workspace"
var sessionID string
if req.Metadata != nil {
if existingSessionID, ok := req.Metadata["auggie_session_id"].(string); ok && existingSessionID != "" {
// Resume existing session
if err := m.acpMgr.LoadSession(context.Background(), instanceID, existingSessionID); err != nil {
m.logger.Warn("failed to load existing ACP session, creating new one",
zap.String("instance_id", instanceID),
zap.String("existing_session_id", existingSessionID),
zap.Error(err))
sessionID, _ = m.acpMgr.NewSession(context.Background(), instanceID, containerCwd)
} else {
sessionID = existingSessionID
}
} else {
sessionID, _ = m.acpMgr.NewSession(context.Background(), instanceID, containerCwd)
}
} else {
sessionID, _ = m.acpMgr.NewSession(context.Background(), instanceID, containerCwd)
}
m.logger.Info("ACP session ready",
zap.String("instance_id", instanceID),
zap.String("session_id", sessionID))
// Send the initial prompt with the task description
if req.TaskDescription != "" {
if err := m.acpMgr.Prompt(context.Background(), instanceID, req.TaskDescription); err != nil {
m.logger.Warn("failed to send initial prompt",
zap.String("instance_id", instanceID),
zap.Error(err))
} else {
m.logger.Info("sent initial prompt to agent",
zap.String("instance_id", instanceID),
zap.String("task_description", req.TaskDescription))
}
}
}
}
}
}
// 10. Wait for agentctl to be ready and start the agent process
go m.initializeAgent(instance, req.TaskDescription)
m.logger.Info("agent launched successfully",
zap.String("instance_id", instanceID),
zap.String("container_id", containerID),
zap.String("container_ip", containerIP),
zap.String("task_id", req.TaskID))
return instance, nil
}
// initializeAgent waits for agentctl to be ready and initializes the agent
func (m *Manager) initializeAgent(instance *AgentInstance, taskDescription string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
m.logger.Info("waiting for agentctl to be ready",
zap.String("instance_id", instance.ID),
zap.String("url", instance.agentctl.BaseURL()))
// Wait for agentctl HTTP server to be ready
if err := instance.agentctl.WaitForReady(ctx, 60*time.Second); err != nil {
m.logger.Error("agentctl not ready",
zap.String("instance_id", instance.ID),
zap.Error(err))
m.updateInstanceError(instance.ID, "agentctl not ready: "+err.Error())
return
}
// Start the agent process via agentctl
if err := instance.agentctl.Start(ctx); err != nil {
m.logger.Error("failed to start agent via agentctl",
zap.String("instance_id", instance.ID),
zap.Error(err))
m.updateInstanceError(instance.ID, "failed to start agent: "+err.Error())
return
}
m.logger.Info("agent process started via agentctl",
zap.String("instance_id", instance.ID))
// Give the agent process a moment to initialize
time.Sleep(500 * time.Millisecond)
// Initialize ACP session
if err := m.initializeACPSession(ctx, instance, taskDescription); err != nil {
m.logger.Error("failed to initialize ACP session",
zap.String("instance_id", instance.ID),
zap.Error(err))
m.updateInstanceError(instance.ID, "failed to initialize ACP: "+err.Error())
return
}
}
// initializeACPSession sends the ACP initialization messages using the acp-go-sdk
func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInstance, taskDescription string) error {
m.logger.Info("initializing ACP session",
zap.String("instance_id", instance.ID),
zap.String("agentctl_url", instance.agentctl.BaseURL()))
// 1. Send initialize request
m.logger.Info("sending ACP initialize request",
zap.String("instance_id", instance.ID))
initResp, err := instance.agentctl.Initialize(ctx, "kandev", "1.0.0")
if err != nil {
m.logger.Error("ACP initialize failed",
zap.String("instance_id", instance.ID),
zap.Error(err))
return fmt.Errorf("initialize failed: %w", err)
}
m.logger.Info("ACP initialize response received",
zap.String("instance_id", instance.ID),
zap.String("agent_name", initResp.AgentInfo.Name),
zap.String("agent_version", initResp.AgentInfo.Version))
// 2. Create new session
m.logger.Info("sending ACP session/new request",
zap.String("instance_id", instance.ID))
sessionID, err := instance.agentctl.NewSession(ctx, "/workspace")
if err != nil {
m.logger.Error("ACP session/new failed",
zap.String("instance_id", instance.ID),
zap.Error(err))
return fmt.Errorf("session/new failed: %w", err)
}
m.logger.Info("ACP session created",
zap.String("instance_id", instance.ID),
zap.String("session_id", sessionID))
// Store session ID in instance
m.mu.Lock()
if inst, exists := m.instances[instance.ID]; exists {
if inst.Metadata == nil {
inst.Metadata = make(map[string]interface{})
}
inst.Metadata["acp_session_id"] = sessionID
}
m.mu.Unlock()
// 3. Set up updates stream to receive session notifications
go m.handleUpdatesStream(instance)
// 4. Send the task prompt if provided
if taskDescription != "" {
m.logger.Info("sending ACP prompt",
zap.String("instance_id", instance.ID),
zap.String("session_id", sessionID),
zap.String("task_description", taskDescription))
// Prompt is SYNCHRONOUS - it blocks until the agent completes the task
// Use a long timeout context for this
promptCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
err := instance.agentctl.Prompt(promptCtx, taskDescription)
if err != nil {
m.logger.Error("ACP prompt failed",
zap.String("instance_id", instance.ID),
zap.Error(err))
return fmt.Errorf("prompt failed: %w", err)
}
// Prompt completed successfully - mark agent as completed
m.logger.Info("ACP prompt completed successfully",
zap.String("instance_id", instance.ID))
if err := m.MarkCompleted(instance.ID, 0, ""); err != nil {
m.logger.Error("failed to mark instance as completed",
zap.String("instance_id", instance.ID),
zap.Error(err))
}
} else {
m.logger.Warn("no task description provided, skipping prompt",
zap.String("instance_id", instance.ID))
}
return nil
}
// handleUpdatesStream handles streaming session notifications from the agent
func (m *Manager) handleUpdatesStream(instance *AgentInstance) {
ctx := context.Background()
err := instance.agentctl.StreamUpdates(ctx, func(notification agentctl.SessionNotification) {
m.handleSessionNotification(instance, notification)
})
if err != nil {
m.logger.Error("failed to connect to updates stream",
zap.String("instance_id", instance.ID),
zap.Error(err))
}
}
// handleSessionNotification processes incoming session notifications from the agent
func (m *Manager) handleSessionNotification(instance *AgentInstance, notification agentctl.SessionNotification) {
m.logger.Debug("received session notification",
zap.String("instance_id", instance.ID),
zap.String("session_id", string(notification.SessionId)))
update := notification.Update
// Handle different update types
if update.AgentMessageChunk != nil {
m.logger.Debug("agent message chunk",
zap.String("instance_id", instance.ID))
m.updateInstanceProgress(instance.ID, 50)
}
if update.AgentThoughtChunk != nil {
m.logger.Debug("agent thought chunk",
zap.String("instance_id", instance.ID))
}
if update.ToolCall != nil {
m.logger.Info("tool call started",
zap.String("instance_id", instance.ID),
zap.String("tool_call_id", string(update.ToolCall.ToolCallId)))
m.updateInstanceProgress(instance.ID, 60)
}
if update.ToolCallUpdate != nil {
m.logger.Debug("tool call update",
zap.String("instance_id", instance.ID),
zap.String("tool_call_id", string(update.ToolCallUpdate.ToolCallId)))
// Check if tool call completed
if update.ToolCallUpdate.Status != nil {
switch *update.ToolCallUpdate.Status {
case "complete":
m.logger.Info("tool call completed",
zap.String("instance_id", instance.ID))
m.updateInstanceProgress(instance.ID, 80)
case "error":
m.logger.Error("tool call error",
zap.String("instance_id", instance.ID))
}
}
}
if update.Plan != nil {
m.logger.Info("agent plan update",
zap.String("instance_id", instance.ID),
zap.Int("num_entries", len(update.Plan.Entries)))
}
}
// updateInstanceProgress updates an instance's progress
func (m *Manager) updateInstanceProgress(instanceID string, progress int) {
m.mu.Lock()
defer m.mu.Unlock()
instance, exists := m.instances[instanceID]
if !exists {
return
}
instance.Progress = progress
}
// updateInstanceComplete marks an instance as completed
func (m *Manager) updateInstanceComplete(instanceID string) {
m.mu.Lock()
defer m.mu.Unlock()
instance, exists := m.instances[instanceID]
if !exists {
return
}
instance.Status = v1.AgentStatusCompleted
instance.Progress = 100
now := time.Now()
instance.FinishedAt = &now
}
// updateInstanceError updates an instance with an error
func (m *Manager) updateInstanceError(instanceID, errorMsg string) {
m.mu.Lock()
defer m.mu.Unlock()
instance, exists := m.instances[instanceID]
if !exists {
return
}
instance.Status = v1.AgentStatusFailed
instance.ErrorMessage = errorMsg
now := time.Now()
instance.FinishedAt = &now
}
// buildContainerConfig builds a Docker container config from agent registry config
func (m *Manager) buildContainerConfig(instanceID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig) docker.ContainerConfig {
// Build image name with tag
@@ -393,6 +573,17 @@ func (m *Manager) StopAgent(ctx context.Context, instanceID string, force bool)
zap.String("instance_id", instanceID),
zap.Bool("force", force))
// Try to gracefully stop via agentctl first
if instance.agentctl != nil && !force {
if err := instance.agentctl.Stop(ctx); err != nil {
m.logger.Warn("failed to stop agent via agentctl, will stop container",
zap.String("instance_id", instanceID),
zap.Error(err))
}
instance.agentctl.Close()
}
// Stop the container
var err error
if force {
err = m.docker.KillContainer(ctx, instance.ContainerID, "SIGKILL")
@@ -404,11 +595,6 @@ func (m *Manager) StopAgent(ctx context.Context, instanceID string, force bool)
return fmt.Errorf("failed to stop container: %w", err)
}
// Close ACP session if exists
if m.acpMgr != nil {
_ = m.acpMgr.CloseSession(instanceID)
}
// Update instance status
m.mu.Lock()
instance.Status = v1.AgentStatusStopped
@@ -0,0 +1,247 @@
// Package acp implements the ACP client interface for agentctl
package acp
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/coder/acp-go-sdk"
"go.uber.org/zap"
)
// UpdateHandler is called when session updates are received from the agent
type UpdateHandler func(notification acp.SessionNotification)
// Client implements acp.Client interface and handles all agent requests
type Client struct {
logger *zap.Logger
workspaceRoot string
mu sync.RWMutex
updateHandler UpdateHandler
}
// ClientOption configures a Client
type ClientOption func(*Client)
// WithLogger sets the logger
func WithLogger(l *zap.Logger) ClientOption {
return func(c *Client) {
c.logger = l
}
}
// WithWorkspaceRoot sets the workspace root for file operations
func WithWorkspaceRoot(root string) ClientOption {
return func(c *Client) {
c.workspaceRoot = root
}
}
// WithUpdateHandler sets the handler for session updates
func WithUpdateHandler(h UpdateHandler) ClientOption {
return func(c *Client) {
c.updateHandler = h
}
}
// NewClient creates a new ACP client implementation
func NewClient(opts ...ClientOption) *Client {
c := &Client{
logger: zap.NewNop(),
workspaceRoot: "/workspace",
}
for _, opt := range opts {
opt(c)
}
return c
}
// SetUpdateHandler sets the update handler (thread-safe)
func (c *Client) SetUpdateHandler(h UpdateHandler) {
c.mu.Lock()
defer c.mu.Unlock()
c.updateHandler = h
}
// RequestPermission handles permission requests from the agent
// Auto-approves by selecting the first "allow" option
func (c *Client) RequestPermission(ctx context.Context, p acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) {
title := ""
if p.ToolCall.Title != nil {
title = *p.ToolCall.Title
}
c.logger.Info("received permission request",
zap.String("session_id", string(p.SessionId)),
zap.String("tool_call_id", string(p.ToolCall.ToolCallId)),
zap.String("title", title),
zap.Int("num_options", len(p.Options)))
// No options - cancel
if len(p.Options) == 0 {
c.logger.Warn("no options available, cancelling permission request")
return acp.RequestPermissionResponse{
Outcome: acp.RequestPermissionOutcome{
Cancelled: &acp.RequestPermissionOutcomeCancelled{},
},
}, nil
}
// Find the first "allow" option
var selectedOption *acp.PermissionOption
for i := range p.Options {
opt := &p.Options[i]
if opt.Kind == acp.PermissionOptionKindAllowOnce || opt.Kind == acp.PermissionOptionKindAllowAlways {
selectedOption = opt
break
}
}
// If no allow option, use the first option
if selectedOption == nil {
selectedOption = &p.Options[0]
}
c.logger.Info("auto-approving permission request",
zap.String("option_id", string(selectedOption.OptionId)),
zap.String("option_name", selectedOption.Name),
zap.String("kind", string(selectedOption.Kind)))
return acp.RequestPermissionResponse{
Outcome: acp.RequestPermissionOutcome{
Selected: &acp.RequestPermissionOutcomeSelected{
OptionId: selectedOption.OptionId,
},
},
}, nil
}
// SessionUpdate handles session update notifications from the agent
func (c *Client) SessionUpdate(ctx context.Context, n acp.SessionNotification) error {
c.mu.RLock()
handler := c.updateHandler
c.mu.RUnlock()
// Log the update type
u := n.Update
switch {
case u.AgentMessageChunk != nil:
if u.AgentMessageChunk.Content.Text != nil {
c.logger.Debug("agent message chunk",
zap.String("text", u.AgentMessageChunk.Content.Text.Text[:min(50, len(u.AgentMessageChunk.Content.Text.Text))]))
}
case u.ToolCall != nil:
c.logger.Info("tool call",
zap.String("tool_call_id", string(u.ToolCall.ToolCallId)),
zap.String("title", u.ToolCall.Title),
zap.String("status", string(u.ToolCall.Status)))
case u.ToolCallUpdate != nil:
c.logger.Debug("tool call update",
zap.String("tool_call_id", string(u.ToolCallUpdate.ToolCallId)))
case u.Plan != nil:
c.logger.Info("plan update", zap.Int("entries", len(u.Plan.Entries)))
}
// Forward to handler if set
if handler != nil {
handler(n)
}
return nil
}
// ReadTextFile reads a text file from the workspace
func (c *Client) ReadTextFile(ctx context.Context, p acp.ReadTextFileRequest) (acp.ReadTextFileResponse, error) {
c.logger.Debug("reading file", zap.String("path", p.Path))
// Validate path is absolute
if !filepath.IsAbs(p.Path) {
return acp.ReadTextFileResponse{}, fmt.Errorf("path must be absolute: %s", p.Path)
}
// Read the file
b, err := os.ReadFile(p.Path)
if err != nil {
return acp.ReadTextFileResponse{}, err
}
content := string(b)
// Handle line/limit parameters
if p.Line != nil || p.Limit != nil {
lines := strings.Split(content, "\n")
start := 0
if p.Line != nil && *p.Line > 0 {
start = *p.Line - 1
if start > len(lines) {
start = len(lines)
}
}
end := len(lines)
if p.Limit != nil && *p.Limit > 0 && start+*p.Limit < end {
end = start + *p.Limit
}
content = strings.Join(lines[start:end], "\n")
}
return acp.ReadTextFileResponse{Content: content}, nil
}
// WriteTextFile writes a text file to the workspace
func (c *Client) WriteTextFile(ctx context.Context, p acp.WriteTextFileRequest) (acp.WriteTextFileResponse, error) {
c.logger.Debug("writing file", zap.String("path", p.Path))
// Validate path is absolute
if !filepath.IsAbs(p.Path) {
return acp.WriteTextFileResponse{}, fmt.Errorf("path must be absolute: %s", p.Path)
}
// Create directory if needed
if dir := filepath.Dir(p.Path); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return acp.WriteTextFileResponse{}, err
}
}
return acp.WriteTextFileResponse{}, os.WriteFile(p.Path, []byte(p.Content), 0o644)
}
// CreateTerminal creates a new terminal
func (c *Client) CreateTerminal(ctx context.Context, p acp.CreateTerminalRequest) (acp.CreateTerminalResponse, error) {
c.logger.Debug("create terminal request", zap.String("command", p.Command))
// For now, return a dummy terminal ID - we can implement real terminal support later
return acp.CreateTerminalResponse{TerminalId: "t-1"}, nil
}
// KillTerminalCommand kills a terminal command
func (c *Client) KillTerminalCommand(ctx context.Context, p acp.KillTerminalCommandRequest) (acp.KillTerminalCommandResponse, error) {
c.logger.Debug("kill terminal request", zap.String("terminal_id", p.TerminalId))
return acp.KillTerminalCommandResponse{}, nil
}
// TerminalOutput gets terminal output
func (c *Client) TerminalOutput(ctx context.Context, p acp.TerminalOutputRequest) (acp.TerminalOutputResponse, error) {
c.logger.Debug("terminal output request", zap.String("terminal_id", p.TerminalId))
return acp.TerminalOutputResponse{Output: "ok", Truncated: false}, nil
}
// ReleaseTerminal releases a terminal
func (c *Client) ReleaseTerminal(ctx context.Context, p acp.ReleaseTerminalRequest) (acp.ReleaseTerminalResponse, error) {
c.logger.Debug("release terminal request", zap.String("terminal_id", p.TerminalId))
return acp.ReleaseTerminalResponse{}, nil
}
// WaitForTerminalExit waits for terminal to exit
func (c *Client) WaitForTerminalExit(ctx context.Context, p acp.WaitForTerminalExitRequest) (acp.WaitForTerminalExitResponse, error) {
c.logger.Debug("wait for terminal exit request", zap.String("terminal_id", p.TerminalId))
exitCode := 0
return acp.WaitForTerminalExitResponse{ExitCode: &exitCode}, nil
}
// Verify interface implementation
var _ acp.Client = (*Client)(nil)
+228
View File
@@ -0,0 +1,228 @@
package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/coder/acp-go-sdk"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"go.uber.org/zap"
)
// InitializeRequest is a request to initialize the ACP session
type InitializeRequest struct {
ClientName string `json:"client_name"`
ClientVersion string `json:"client_version"`
}
// InitializeResponse is the response to an initialize call
type InitializeResponse struct {
Success bool `json:"success"`
Response *acp.InitializeResponse `json:"response,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleACPInitialize(c *gin.Context) {
var req InitializeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, InitializeResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
conn := s.procMgr.GetConnection()
if conn == nil {
c.JSON(http.StatusServiceUnavailable, InitializeResponse{
Success: false,
Error: "agent not running",
})
return
}
resp, err := conn.Initialize(ctx, acp.InitializeRequest{
ProtocolVersion: acp.ProtocolVersionNumber,
ClientInfo: &acp.Implementation{
Name: req.ClientName,
Version: req.ClientVersion,
},
})
if err != nil {
s.logger.Error("initialize failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, InitializeResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, InitializeResponse{
Success: true,
Response: &resp,
})
}
// NewSessionRequest is a request to create a new ACP session
type NewSessionRequest struct {
Cwd string `json:"cwd"` // Working directory for the session
}
// NewSessionResponse is the response to a new session call
type NewSessionResponse struct {
Success bool `json:"success"`
SessionID string `json:"session_id,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleACPNewSession(c *gin.Context) {
var req NewSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, NewSessionResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
conn := s.procMgr.GetConnection()
if conn == nil {
c.JSON(http.StatusServiceUnavailable, NewSessionResponse{
Success: false,
Error: "agent not running",
})
return
}
cwd := req.Cwd
if cwd == "" {
cwd = "/workspace"
}
resp, err := conn.NewSession(ctx, acp.NewSessionRequest{
Cwd: cwd,
McpServers: []acp.McpServer{}, // Required: must be an empty array, not nil
})
if err != nil {
s.logger.Error("new session failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, NewSessionResponse{
Success: false,
Error: err.Error(),
})
return
}
// Store session ID
s.procMgr.SetSessionID(resp.SessionId)
c.JSON(http.StatusOK, NewSessionResponse{
Success: true,
SessionID: string(resp.SessionId),
})
}
// PromptRequest is a request to send a prompt to the agent
type PromptRequest struct {
Text string `json:"text"` // Simple text prompt
}
// PromptResponse is the response to a prompt call
type PromptResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleACPPrompt(c *gin.Context) {
var req PromptRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, PromptResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
conn := s.procMgr.GetConnection()
if conn == nil {
c.JSON(http.StatusServiceUnavailable, PromptResponse{
Success: false,
Error: "agent not running",
})
return
}
sessionID := s.procMgr.GetSessionID()
if sessionID == "" {
c.JSON(http.StatusBadRequest, PromptResponse{
Success: false,
Error: "no active session - call new_session first",
})
return
}
// Use a long timeout for prompt - agent may take time to complete
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
defer cancel()
// Build prompt content - simple text block
_, err := conn.Prompt(ctx, acp.PromptRequest{
SessionId: sessionID,
Prompt: []acp.ContentBlock{acp.TextBlock(req.Text)},
})
if err != nil {
s.logger.Error("prompt failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, PromptResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, PromptResponse{Success: true})
}
// handleACPStreamWS streams ACP session notifications via WebSocket
func (s *Server) handleACPStreamWS(c *gin.Context) {
conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
s.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
defer conn.Close()
s.logger.Info("ACP stream WebSocket connected")
// Get the session updates channel
updatesCh := s.procMgr.GetUpdates()
// Stream session notifications to WebSocket
for {
select {
case notification, ok := <-updatesCh:
if !ok {
return
}
data, err := json.Marshal(notification)
if err != nil {
s.logger.Error("failed to marshal notification", zap.Error(err))
continue
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
s.logger.Debug("WebSocket write error", zap.Error(err))
return
}
}
}
}
@@ -0,0 +1,102 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/process"
"go.uber.org/zap"
)
// OutputResponse is the response for getting output
type OutputResponse struct {
Lines []process.OutputLine `json:"lines"`
Total int `json:"total"`
}
func (s *Server) handleGetOutput(c *gin.Context) {
// Parse optional 'last' query param
lastStr := c.DefaultQuery("last", "100")
last, err := strconv.Atoi(lastStr)
if err != nil || last < 1 {
last = 100
}
lines := s.procMgr.GetOutputBuffer().GetLast(last)
c.JSON(http.StatusOK, OutputResponse{
Lines: lines,
Total: s.procMgr.GetOutputBuffer().Count(),
})
}
// handleOutputStreamWS streams output via WebSocket
func (s *Server) handleOutputStreamWS(c *gin.Context) {
conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
s.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
defer conn.Close()
s.logger.Info("Output stream WebSocket connected")
// Check if client wants history first
sendHistory := c.DefaultQuery("history", "false") == "true"
historyCount, _ := strconv.Atoi(c.DefaultQuery("history_count", "100"))
if sendHistory {
lines := s.procMgr.GetOutputBuffer().GetLast(historyCount)
for _, line := range lines {
data, err := json.Marshal(line)
if err != nil {
continue
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
s.logger.Debug("WebSocket write error (history)", zap.Error(err))
return
}
}
}
// Subscribe to real-time output
sub := s.procMgr.GetOutputBuffer().Subscribe()
defer s.procMgr.GetOutputBuffer().Unsubscribe(sub)
// Handle WebSocket close
closeCh := make(chan struct{})
go func() {
for {
_, _, err := conn.ReadMessage()
if err != nil {
close(closeCh)
return
}
}
}()
// Stream output to client
for {
select {
case line, ok := <-sub:
if !ok {
return
}
data, err := json.Marshal(line)
if err != nil {
s.logger.Error("failed to marshal output line", zap.Error(err))
continue
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
s.logger.Debug("WebSocket write error", zap.Error(err))
return
}
case <-closeCh:
s.logger.Info("Output stream WebSocket closed by client")
return
}
}
}
@@ -0,0 +1,174 @@
// Package api provides the HTTP REST API for agentctl
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/config"
"github.com/kandev/kandev/internal/agentctl/process"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Server is the HTTP API server
type Server struct {
cfg *config.Config
procMgr *process.Manager
logger *logger.Logger
router *gin.Engine
upgrader websocket.Upgrader
}
// NewServer creates a new API server
func NewServer(cfg *config.Config, procMgr *process.Manager, log *logger.Logger) *Server {
gin.SetMode(gin.ReleaseMode)
s := &Server{
cfg: cfg,
procMgr: procMgr,
logger: log.WithFields(zap.String("component", "api-server")),
router: gin.New(),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for container-local communication
},
},
}
s.setupRoutes()
return s
}
// Router returns the HTTP router
func (s *Server) Router() http.Handler {
return s.router
}
func (s *Server) setupRoutes() {
// Health check
s.router.GET("/health", s.handleHealth)
// Agent control
api := s.router.Group("/api/v1")
{
// Status and info
api.GET("/status", s.handleStatus)
api.GET("/info", s.handleInfo)
// Process control
api.POST("/start", s.handleStart)
api.POST("/stop", s.handleStop)
// ACP high-level methods (using acp-go-sdk)
api.POST("/acp/initialize", s.handleACPInitialize)
api.POST("/acp/session/new", s.handleACPNewSession)
api.POST("/acp/prompt", s.handleACPPrompt)
api.GET("/acp/stream", s.handleACPStreamWS)
// Output streaming
api.GET("/output", s.handleGetOutput)
api.GET("/output/stream", s.handleOutputStreamWS)
}
}
// Health check response
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
}
func (s *Server) handleHealth(c *gin.Context) {
c.JSON(http.StatusOK, HealthResponse{
Status: "ok",
Timestamp: time.Now().UTC().Format(time.RFC3339),
})
}
// Status response
type StatusResponse struct {
AgentStatus string `json:"agent_status"`
ProcessInfo map[string]interface{} `json:"process_info"`
Uptime string `json:"uptime,omitempty"`
}
func (s *Server) handleStatus(c *gin.Context) {
c.JSON(http.StatusOK, StatusResponse{
AgentStatus: string(s.procMgr.Status()),
ProcessInfo: s.procMgr.GetProcessInfo(),
})
}
// Info response
type InfoResponse struct {
Version string `json:"version"`
AgentCommand string `json:"agent_command"`
WorkDir string `json:"work_dir"`
Port int `json:"port"`
}
func (s *Server) handleInfo(c *gin.Context) {
c.JSON(http.StatusOK, InfoResponse{
Version: "0.1.0",
AgentCommand: s.cfg.AgentCommand,
WorkDir: s.cfg.WorkDir,
Port: s.cfg.Port,
})
}
// Start request/response
type StartRequest struct {
// Future: could add options like custom command, env overrides
}
type StartResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleStart(c *gin.Context) {
if err := s.procMgr.Start(c.Request.Context()); err != nil {
s.logger.Error("failed to start agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, StartResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, StartResponse{
Success: true,
Message: "agent started",
})
}
// Stop request/response
type StopRequest struct {
Force bool `json:"force"`
}
type StopResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleStop(c *gin.Context) {
if err := s.procMgr.Stop(c.Request.Context()); err != nil {
s.logger.Error("failed to stop agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, StopResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, StopResponse{
Success: true,
Message: "agent stopped",
})
}
@@ -0,0 +1,106 @@
// Package config provides configuration for agentctl
package config
import (
"os"
"strconv"
"strings"
)
// Config holds the agentctl configuration
type Config struct {
// HTTP server port
Port int
// Agent command to execute (e.g., "auggie --acp")
AgentCommand string
// Agent arguments (parsed from AgentCommand)
AgentArgs []string
// Working directory for the agent process
WorkDir string
// Environment variables to pass to the agent
AgentEnv []string
// Auto-start the agent on agentctl startup
AutoStart bool
// Logging configuration
LogLevel string
LogFormat string
// Buffer size for agent output (in lines)
OutputBufferSize int
// Health check interval
HealthCheckInterval int
}
// Load loads configuration from environment variables
func Load() *Config {
workDir := getEnv("AGENTCTL_WORKDIR", "/workspace")
defaultCmd := "auggie --acp --workspace-root " + workDir
cfg := &Config{
Port: getEnvInt("AGENTCTL_PORT", 9999),
AgentCommand: getEnv("AGENTCTL_AGENT_COMMAND", defaultCmd),
WorkDir: workDir,
AutoStart: getEnvBool("AGENTCTL_AUTO_START", false),
LogLevel: getEnv("AGENTCTL_LOG_LEVEL", "info"),
LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"),
OutputBufferSize: getEnvInt("AGENTCTL_OUTPUT_BUFFER_SIZE", 1000),
HealthCheckInterval: getEnvInt("AGENTCTL_HEALTH_CHECK_INTERVAL", 5),
}
// Parse agent command into args
cfg.AgentArgs = parseCommand(cfg.AgentCommand)
// Collect agent environment variables (pass through most env vars)
cfg.AgentEnv = collectAgentEnv()
return cfg
}
// parseCommand splits a command string into arguments
func parseCommand(cmd string) []string {
// Simple split by spaces (doesn't handle quotes, but good enough for now)
return strings.Fields(cmd)
}
// collectAgentEnv collects environment variables to pass to the agent
func collectAgentEnv() []string {
// Pass through all environment variables except AGENTCTL_* ones
var env []string
for _, e := range os.Environ() {
if !strings.HasPrefix(e, "AGENTCTL_") {
env = append(env, e)
}
}
return env
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if i, err := strconv.Atoi(value); err == nil {
return i
}
}
return defaultValue
}
func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
return strings.ToLower(value) == "true" || value == "1"
}
return defaultValue
}
@@ -0,0 +1,131 @@
package process
import (
"sync"
"time"
)
// OutputLine represents a line of output from the agent
type OutputLine struct {
Timestamp time.Time `json:"timestamp"`
Stream string `json:"stream"` // "stdout" or "stderr"
Content string `json:"content"`
}
// Subscriber is a channel that receives output lines
type Subscriber chan OutputLine
// OutputBuffer is a ring buffer for agent output that supports streaming
type OutputBuffer struct {
lines []OutputLine
size int
head int
count int
mu sync.RWMutex
// Subscribers for real-time streaming
subscribers map[Subscriber]struct{}
subMu sync.RWMutex
}
// NewOutputBuffer creates a new output buffer with the given capacity
func NewOutputBuffer(size int) *OutputBuffer {
return &OutputBuffer{
lines: make([]OutputLine, size),
size: size,
subscribers: make(map[Subscriber]struct{}),
}
}
// Add adds a new line to the buffer and notifies subscribers
func (b *OutputBuffer) Add(line OutputLine) {
b.mu.Lock()
// Add to ring buffer
idx := (b.head + b.count) % b.size
if b.count < b.size {
b.count++
} else {
b.head = (b.head + 1) % b.size
}
b.lines[idx] = line
b.mu.Unlock()
// Notify subscribers (non-blocking)
b.subMu.RLock()
for sub := range b.subscribers {
select {
case sub <- line:
default:
// Subscriber is slow, skip
}
}
b.subMu.RUnlock()
}
// GetAll returns all lines in the buffer (oldest first)
func (b *OutputBuffer) GetAll() []OutputLine {
b.mu.RLock()
defer b.mu.RUnlock()
result := make([]OutputLine, b.count)
for i := 0; i < b.count; i++ {
idx := (b.head + i) % b.size
result[i] = b.lines[idx]
}
return result
}
// GetLast returns the last n lines from the buffer
func (b *OutputBuffer) GetLast(n int) []OutputLine {
b.mu.RLock()
defer b.mu.RUnlock()
if n > b.count {
n = b.count
}
result := make([]OutputLine, n)
start := b.count - n
for i := 0; i < n; i++ {
idx := (b.head + start + i) % b.size
result[i] = b.lines[idx]
}
return result
}
// Subscribe creates a new subscriber that receives output lines in real-time
func (b *OutputBuffer) Subscribe() Subscriber {
sub := make(Subscriber, 100)
b.subMu.Lock()
b.subscribers[sub] = struct{}{}
b.subMu.Unlock()
return sub
}
// Unsubscribe removes a subscriber
func (b *OutputBuffer) Unsubscribe(sub Subscriber) {
b.subMu.Lock()
delete(b.subscribers, sub)
b.subMu.Unlock()
close(sub)
}
// Count returns the number of lines in the buffer
func (b *OutputBuffer) Count() int {
b.mu.RLock()
defer b.mu.RUnlock()
return b.count
}
// Clear clears the buffer
func (b *OutputBuffer) Clear() {
b.mu.Lock()
defer b.mu.Unlock()
b.head = 0
b.count = 0
}
@@ -0,0 +1,331 @@
// Package process manages the agent subprocess lifecycle
package process
import (
"bufio"
"context"
"fmt"
"io"
"log/slog"
"os/exec"
"sync"
"sync/atomic"
"time"
"github.com/coder/acp-go-sdk"
acpclient "github.com/kandev/kandev/internal/agentctl/acp"
"github.com/kandev/kandev/internal/agentctl/config"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Compile-time check that we export our client
var _ acp.Client = (*acpclient.Client)(nil)
// Status represents the agent process status
type Status string
const (
StatusStopped Status = "stopped"
StatusStarting Status = "starting"
StatusRunning Status = "running"
StatusPaused Status = "paused"
StatusStopping Status = "stopping"
StatusError Status = "error"
)
// errorWrapper wraps an error so it can be stored in atomic.Value (which cannot store nil)
type errorWrapper struct {
err error
}
// Manager manages the agent subprocess
type Manager struct {
cfg *config.Config
logger *logger.Logger
// Process state
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
status atomic.Value // Status
exitCode atomic.Int32
exitErr atomic.Value // error
// Output buffer for streaming
outputBuffer *OutputBuffer
// ACP SDK connection
acpClient *acpclient.Client
acpConn *acp.ClientSideConnection
sessionID acp.SessionId
// Session update notifications
updatesCh chan acp.SessionNotification
// Synchronization
mu sync.RWMutex
wg sync.WaitGroup
stopCh chan struct{}
doneCh chan struct{}
startMu sync.Mutex
}
// NewManager creates a new process manager
func NewManager(cfg *config.Config, log *logger.Logger) *Manager {
m := &Manager{
cfg: cfg,
logger: log.WithFields(zap.String("component", "process-manager")),
outputBuffer: NewOutputBuffer(cfg.OutputBufferSize),
updatesCh: make(chan acp.SessionNotification, 100),
}
m.status.Store(StatusStopped)
m.exitCode.Store(-1)
return m
}
// Status returns the current process status
func (m *Manager) Status() Status {
return m.status.Load().(Status)
}
// ExitCode returns the exit code (-1 if not exited)
func (m *Manager) ExitCode() int {
return int(m.exitCode.Load())
}
// ExitError returns the exit error if any
func (m *Manager) ExitError() error {
if v := m.exitErr.Load(); v != nil {
if w, ok := v.(errorWrapper); ok {
return w.err
}
}
return nil
}
// GetOutputBuffer returns the output buffer for streaming
func (m *Manager) GetOutputBuffer() *OutputBuffer {
return m.outputBuffer
}
// Start starts the agent process
func (m *Manager) Start(ctx context.Context) error {
m.startMu.Lock()
defer m.startMu.Unlock()
if m.Status() == StatusRunning || m.Status() == StatusStarting {
return fmt.Errorf("agent is already running")
}
m.logger.Info("starting agent process",
zap.Strings("args", m.cfg.AgentArgs),
zap.String("workdir", m.cfg.WorkDir))
m.status.Store(StatusStarting)
m.exitCode.Store(-1)
m.exitErr.Store(errorWrapper{err: nil})
// Create command
if len(m.cfg.AgentArgs) == 0 {
m.status.Store(StatusError)
return fmt.Errorf("no agent command configured")
}
// NOTE: We intentionally don't use exec.CommandContext here because we don't want
// the HTTP request context to kill the agent process when the request completes.
m.cmd = exec.Command(m.cfg.AgentArgs[0], m.cfg.AgentArgs[1:]...)
m.cmd.Dir = m.cfg.WorkDir
m.cmd.Env = m.cfg.AgentEnv
// Set up pipes
var err error
m.stdin, err = m.cmd.StdinPipe()
if err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
m.stdout, err = m.cmd.StdoutPipe()
if err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
m.stderr, err = m.cmd.StderrPipe()
if err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
// Start the process
if err := m.cmd.Start(); err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to start agent: %w", err)
}
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
// Create ACP client that will handle agent requests
m.acpClient = acpclient.NewClient(
acpclient.WithLogger(m.logger.Zap()),
acpclient.WithWorkspaceRoot(m.cfg.WorkDir),
acpclient.WithUpdateHandler(func(n acp.SessionNotification) {
select {
case m.updatesCh <- n:
default:
m.logger.Warn("updates channel full, dropping notification")
}
}),
)
// Create ACP SDK connection - it handles JSON-RPC communication
m.acpConn = acp.NewClientSideConnection(m.acpClient, m.stdin, m.stdout)
m.acpConn.SetLogger(slog.Default().With("component", "acp-conn"))
// Start stderr reader and exit waiter
m.wg.Add(2)
go m.readStderr()
go m.waitForExit()
m.status.Store(StatusRunning)
m.logger.Info("agent process started", zap.Int("pid", m.cmd.Process.Pid))
return nil
}
// GetUpdates returns the channel for session update notifications
func (m *Manager) GetUpdates() <-chan acp.SessionNotification {
return m.updatesCh
}
// GetConnection returns the ACP connection (for advanced usage)
func (m *Manager) GetConnection() *acp.ClientSideConnection {
m.mu.RLock()
defer m.mu.RUnlock()
return m.acpConn
}
// GetSessionID returns the current session ID
func (m *Manager) GetSessionID() acp.SessionId {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessionID
}
// SetSessionID sets the current session ID
func (m *Manager) SetSessionID(id acp.SessionId) {
m.mu.Lock()
defer m.mu.Unlock()
m.sessionID = id
}
// Stop stops the agent process
func (m *Manager) Stop(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
status := m.Status()
if status == StatusStopped || status == StatusStopping {
return nil
}
m.logger.Info("stopping agent process")
m.status.Store(StatusStopping)
// Close stop channel to signal readers
if m.stopCh != nil {
close(m.stopCh)
}
// Close stdin to signal EOF to agent
if m.stdin != nil {
m.stdin.Close()
}
// Wait for process to exit with timeout
done := make(chan struct{})
go func() {
m.wg.Wait()
close(done)
}()
select {
case <-done:
m.logger.Info("agent process stopped gracefully")
case <-ctx.Done():
// Force kill
if m.cmd != nil && m.cmd.Process != nil {
m.logger.Warn("force killing agent process")
m.cmd.Process.Kill()
}
}
m.status.Store(StatusStopped)
return nil
}
// readStderr reads stderr from the agent
func (m *Manager) readStderr() {
defer m.wg.Done()
scanner := bufio.NewScanner(m.stderr)
for scanner.Scan() {
line := scanner.Text()
m.outputBuffer.Add(OutputLine{
Timestamp: time.Now(),
Stream: "stderr",
Content: line,
})
}
if err := scanner.Err(); err != nil {
m.logger.Debug("stderr reader error", zap.Error(err))
}
}
// waitForExit waits for the process to exit
func (m *Manager) waitForExit() {
defer m.wg.Done()
defer close(m.doneCh)
err := m.cmd.Wait()
if err != nil {
m.exitErr.Store(errorWrapper{err: err})
if exitErr, ok := err.(*exec.ExitError); ok {
m.exitCode.Store(int32(exitErr.ExitCode()))
}
m.logger.Info("agent process exited with error", zap.Error(err))
} else {
m.exitCode.Store(0)
m.logger.Info("agent process exited successfully")
}
m.status.Store(StatusStopped)
}
// GetProcessInfo returns information about the process
func (m *Manager) GetProcessInfo() map[string]interface{} {
info := map[string]interface{}{
"status": string(m.Status()),
"exit_code": m.ExitCode(),
}
if m.cmd != nil && m.cmd.Process != nil {
info["pid"] = m.cmd.Process.Pid
}
if err := m.ExitError(); err != nil {
info["exit_error"] = err.Error()
}
return info
}
@@ -46,6 +46,7 @@ type Service struct {
logger *logger.Logger
eventBus bus.EventBus
db *database.DB
taskRepo scheduler.TaskRepository
// Components
queue *queue.TaskQueue
@@ -100,6 +101,7 @@ func NewService(
logger: svcLogger,
eventBus: eventBus,
db: db,
taskRepo: taskRepo,
queue: taskQueue,
executor: exec,
scheduler: sched,
@@ -338,6 +340,16 @@ func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEv
// Update scheduler and remove from queue
s.scheduler.HandleTaskCompleted(data.TaskID, true)
s.scheduler.RemoveTask(data.TaskID)
// Update task state to COMPLETED
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateCompleted); err != nil {
s.logger.Error("failed to update task state to COMPLETED",
zap.String("task_id", data.TaskID),
zap.Error(err))
} else {
s.logger.Info("task marked as COMPLETED",
zap.String("task_id", data.TaskID))
}
}
// handleAgentFailed handles agent failure events
@@ -352,6 +364,13 @@ func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEvent
if !s.scheduler.RetryTask(data.TaskID) {
s.logger.Error("task failed and retry limit exceeded",
zap.String("task_id", data.TaskID))
// Update task state to FAILED
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateFailed); err != nil {
s.logger.Error("failed to update task state to FAILED",
zap.String("task_id", data.TaskID),
zap.Error(err))
}
}
}

Some files were not shown because too many files have changed in this diff Show More