feat(mcp): add embedded MCP server with dual transport support (#67)

This commit is contained in:
João Salavisa
2026-01-21 12:03:46 +00:00
committed by GitHub
parent fa8364307f
commit c57a82f81d
33 changed files with 1549 additions and 71 deletions
+247
View File
@@ -0,0 +1,247 @@
# Cancel/Stop Agent Turn Implementation
This document describes how to implement end-to-end functionality for cancelling the current agent turn for both Codex and Auggie agents.
## Overview
Users need the ability to stop an agent mid-execution without killing the entire agent process. This allows for quick iteration—cancelling a wrong path and sending a new prompt—without waiting for the agent to finish or restarting the session.
## Protocol Summary
### Auggie (ACP Protocol)
- **Method**: `session/cancel` (notification)
- **Transport**: JSON-RPC 2.0 over stdin/stdout
- **Message format**:
```json
{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"<session-id>"}}
```
- **Key characteristics**:
- It's a notification (no `id` field, no response expected)
- Only requires `sessionId`
- The pending `session/prompt` request returns with `{"stopReason":"cancelled"}`
- Works immediately—agent stops current operation
### Codex (App-Server Protocol)
- **Method**: `turn/interrupt` (request)
- **Transport**: JSON-RPC variant over stdin/stdout (omits `jsonrpc` field)
- **Message format**:
```json
{"id":N,"method":"turn/interrupt","params":{"threadId":"<thread-id>","turnId":"<turn-id>"}}
```
- **Key characteristics**:
- It's a request (has `id` field, expects response)
- Requires both `threadId` AND `turnId`
- `threadId` = session ID (from `thread/start` response)
- `turnId` = operation ID (from `turn/start` response)
## Current State
### Already Implemented ✅
1. **AgentAdapter interface** has `Cancel(ctx context.Context) error` method
2. **ACPAdapter.Cancel()** is fully implemented using `acpConn.Cancel()`
3. **CodexAdapter.Cancel()** is fully implemented using `turn/interrupt`
4. **AgentAdapter.GetOperationID()** returns `turnID` for Codex (needed for cancel)
5. **WebSocket action constant** `ActionAgentCancel = "agent.cancel"` exists
### Not Implemented ❌
1. **agentctl HTTP endpoint** for cancel (`POST /api/v1/acp/cancel`)
2. **agentctl Client method** to call the cancel endpoint
3. **Backend orchestrator/lifecycle integration** to route cancel requests
4. **WebSocket handler** for `agent.cancel` action
5. **Frontend UI** cancel button and hook
## High-Level Flow
```
Frontend (Cancel Button)
|
| agent.cancel (WS request)
v
Backend (WS Gateway → Dispatcher)
|
| orchestrator handler
v
Backend (Executor → AgentManager)
|
| CancelAgent(ctx, sessionID)
v
Lifecycle Manager
|
| HTTP POST /api/v1/acp/cancel
v
agentctl (api server)
|
| adapter.Cancel(ctx)
v
Agent subprocess (ACP or Codex protocol)
|
v
Agent stops current turn
|
| Returns stopReason=cancelled (ACP) or turn/completed with cancelled (Codex)
v
Prompt() returns → lifecycle emits session.cancelled event → UI updates
```
## Implementation Plan
### Layer 1: agentctl Server (HTTP API)
**File**: `apps/backend/internal/agentctl/server/api/acp.go`
Add a new endpoint:
- `POST /api/v1/acp/cancel` → `handleACPCancel`
Handler implementation:
1. Get the adapter from process manager
2. Call `adapter.Cancel(ctx)`
3. Return success/error response
**File**: `apps/backend/internal/agentctl/server/api/server.go`
Register the new route in the router group.
### Layer 2: agentctl Client
**File**: `apps/backend/internal/agentctl/client/acp.go`
Add `Cancel(ctx context.Context) error` method:
1. Make HTTP POST to `/api/v1/acp/cancel`
2. Parse response and return error if failed
### Layer 3: Lifecycle Manager
**File**: `apps/backend/internal/agent/lifecycle/manager.go`
Add `CancelAgent(ctx context.Context, executionID string) error`:
1. Look up execution by ID
2. Validate execution is in running state
3. Call `execution.agentctl.Cancel(ctx)`
4. The running Prompt() call will return with cancelled stop reason
**File**: `apps/backend/internal/agent/lifecycle/session.go`
Handle cancelled stop reason in `SendPrompt()`:
- When prompt returns with `stopReason=cancelled`, don't treat as error
- Emit appropriate event for UI notification
### Layer 4: Executor & Orchestrator
**File**: `apps/backend/internal/orchestrator/executor/executor.go`
Add interface method to `AgentManagerClient`:
```go
CancelAgent(ctx context.Context, sessionID string) error
```
Add `Cancel(ctx, taskID, sessionID string) error`:
1. Look up session by ID
2. Get agent execution ID
3. Call `agentManager.CancelAgent(ctx, executionID)`
**File**: `apps/backend/cmd/kandev/adapters.go`
Implement `CancelAgent` on `lifecycleAdapter`:
- Delegate to `mgr.CancelAgent()`
### Layer 5: WebSocket Handler
**File**: `apps/backend/internal/orchestrator/handlers/handlers.go`
Add handler for `agent.cancel`:
```go
func (h *Handlers) wsCancelAgent(ctx context.Context, msg *ws.Message) (*ws.Message, error)
```
Request payload:
```go
type wsCancelAgentRequest struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
}
```
Register in `RegisterHandlers()`:
```go
d.RegisterFunc(ws.ActionAgentCancel, h.wsCancelAgent)
```
**File**: `apps/backend/internal/orchestrator/controller/controller.go`
Add controller method:
```go
func (c *Controller) CancelAgent(ctx context.Context, req dto.CancelAgentRequest) (dto.CancelAgentResponse, error)
```
**File**: `apps/backend/internal/orchestrator/controller/dto/dto.go`
Add request/response types:
```go
type CancelAgentRequest struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
}
type CancelAgentResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
```
### Layer 6: Frontend
**File**: `apps/web/hooks/use-session-agent.ts`
Add `handleCancelTurn` callback:
```typescript
const handleCancelTurn = useCallback(async () => {
if (!task?.id || !agentSessionId) return;
const client = getWebSocketClient();
if (!client) return;
await client.request('agent.cancel', {
task_id: task.id,
session_id: agentSessionId
});
}, [task?.id, agentSessionId]);
```
Return from hook: `{ ..., handleCancelTurn }`
**UI Integration** (location TBD—likely task chat panel or top bar):
- Add "Stop" button visible when agent is running
- Wire to `handleCancelTurn()`
- Show loading state during cancel
- Button disabled when no active turn
## Event Flow After Cancel
1. `adapter.Cancel()` sends protocol message
2. Agent stops and responds with cancelled status
3. `Prompt()` returns with `stopReason = "cancelled"`
4. Lifecycle manager:
- Updates execution status to READY (not COMPLETED)
- Emits `session.agent.cancelled` or similar event
5. Frontend receives event and updates UI:
- Stops spinner
- Shows "Cancelled" indicator
- Re-enables input for new prompt
## Error Handling
| Scenario | Behavior |
|----------|----------|
| No active turn | Return success (no-op) |
| Agent not running | Return error "agent not running" |
| Session not found | Return error "session not found" |
| Cancel timeout | Return error, but cancel still in flight |
| Cancel during permission request | Cancel the permission, return cancelled |
## Testing
1. **Unit tests** for each layer (adapter, client, handler)
2. **Integration test**: Start agent, send prompt, cancel mid-execution
3. **E2E test**: UI cancel button flow
4. **Protocol tests**: Verify correct JSON-RPC messages for ACP and Codex