Files
2026-07-13 13:00:08 +08:00

677 lines
24 KiB
Go

// Package acp implements the Agent Client Protocol (https://agentclientprotocol.com)
// transport: a stdio JSON-RPC 2.0 agent that editors and other host clients speak
// to drive Reasonix. Many tools integrated with the v1 (main-branch) agent over
// ACP, so v2 keeps the wire contract identical — the wire types in this file are a
// faithful port of main's src/acp/protocol.ts (ACP protocol version 1).
//
// The package is an adapter layer over the v2 kernel and depends only on stable
// contracts: it maps the agent's typed event.Event stream onto session/update
// notifications (see dispatch.go), bridges permission.Approver onto
// session/request_permission round-trips (see permission.go), and exposes the
// whole thing over NDJSON JSON-RPC (see server.go). How a per-session agent is
// actually assembled — provider, tools rooted at the session cwd, per-session MCP
// — is left to a Factory the composition root supplies (see service.go), so this
// package stays independent of the cli wiring.
package acp
import (
"encoding/json"
"fmt"
"strings"
)
// ProtocolVersion is the ACP version this agent implements. Matches main.
const ProtocolVersion = 1
// JSON-RPC 2.0 error codes (subset used on the wire). Mirrors protocol.ts.
const (
ErrParse = -32700
ErrInvalidRequest = -32600
ErrMethodNotFound = -32601
ErrInvalidParams = -32602
ErrInternal = -32603
)
// --- initialize ---
// InitializeParams is the client's handshake. The agent records the client's
// capabilities — fs read/write proxying and host terminals are used when
// offered — and advertises its own fixed capability set in reply.
type InitializeParams struct {
ProtocolVersion int `json:"protocolVersion"`
ClientInfo *Implementation `json:"clientInfo,omitempty"`
ClientCapabilities ClientCapabilities `json:"clientCapabilities,omitempty"`
}
// ClientCapabilities is what the client offers the agent: filesystem proxy
// methods (fs/read_text_file, fs/write_text_file) that see unsaved editor
// buffers, and host-owned terminals (terminal/*).
type ClientCapabilities struct {
FS FSCapabilities `json:"fs,omitempty"`
Terminal bool `json:"terminal,omitempty"`
}
// FSCapabilities reports which client filesystem methods are available.
type FSCapabilities struct {
ReadTextFile bool `json:"readTextFile,omitempty"`
WriteTextFile bool `json:"writeTextFile,omitempty"`
}
// Implementation names a participant (client or agent) on the wire.
type Implementation struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
Version string `json:"version,omitempty"`
}
// InitializeResult advertises what this agent supports: persisted session load,
// ACP v1 session lifecycle helpers, inline resource text (embeddedContext) but
// not image/audio, and stdio / Streamable HTTP MCP (no legacy sse).
type InitializeResult struct {
ProtocolVersion int `json:"protocolVersion"`
AgentCapabilities AgentCapabilities `json:"agentCapabilities"`
AgentInfo Implementation `json:"agentInfo"`
AuthMethods []AuthMethod `json:"authMethods"`
}
// AgentCapabilities is the agentCapabilities object in InitializeResult.
type AgentCapabilities struct {
LoadSession bool `json:"loadSession"`
SessionCapabilities SessionCapabilities `json:"sessionCapabilities,omitempty"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
MCPCapabilities MCPCapabilities `json:"mcpCapabilities"`
}
// EmptyCapability serializes to {} for ACP capability flags.
type EmptyCapability struct{}
// SessionCapabilities advertises optional session lifecycle methods.
type SessionCapabilities struct {
List *EmptyCapability `json:"list,omitempty"`
Resume *EmptyCapability `json:"resume,omitempty"`
Close *EmptyCapability `json:"close,omitempty"`
Delete *EmptyCapability `json:"delete,omitempty"`
}
// PromptCapabilities reports which content-block kinds prompts may carry.
type PromptCapabilities struct {
Image bool `json:"image"`
Audio bool `json:"audio"`
EmbeddedContext bool `json:"embeddedContext"`
}
// MCPCapabilities reports which MCP transports session/new accepts.
type MCPCapabilities struct {
HTTP bool `json:"http"`
SSE bool `json:"sse"`
}
// AuthMethod advertises how a client can prepare credentials for the agent.
type AuthMethod struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Type string `json:"type,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
// AuthenticateParams selects one advertised auth method. Terminal methods are
// normally handled by the client by launching the agent with the method's args;
// accepting this request keeps clients that call authenticate directly working.
type AuthenticateParams struct {
MethodID string `json:"methodId"`
}
// AuthenticateResult is the empty authentication ack.
type AuthenticateResult struct{}
// --- session/new ---
// SessionNewParams opens a session rooted at cwd, optionally with MCP servers
// the agent should connect for the session's lifetime.
type SessionNewParams struct {
Cwd string `json:"cwd,omitempty"`
MCPServers []MCPServerSpec `json:"mcpServers,omitempty"`
}
// MCPServerSpec describes one MCP server the client asks the agent to connect.
type MCPServerSpec struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env MCPEnv `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers MCPHeaders `json:"headers,omitempty"`
}
// MCPEnv accepts ACP's official EnvVariable[] shape while still accepting the
// older map shape that Reasonix v1 clients used.
type MCPEnv map[string]string
// MCPHeaders accepts ACP's official HTTPHeader[] shape while still accepting
// the older map shape that Reasonix v1 clients used. The official spec
// (https://agentclientprotocol.com) ships HTTP/SSE MCP headers as an array of
// {name,value} objects, even when empty.
type MCPHeaders map[string]string
// EnvVariable is one official ACP MCP environment variable entry. The same
// {name,value} shape is also used by HTTP/SSE headers in the ACP spec, so we
// reuse it as the parse target for [MCPHeaders] too.
type EnvVariable struct {
Name string `json:"name"`
Value string `json:"value"`
}
func (e *MCPEnv) UnmarshalJSON(raw []byte) error {
out, err := unmarshalNameValueMap(raw, "env")
if err != nil {
return err
}
*e = out
return nil
}
func (h *MCPHeaders) UnmarshalJSON(raw []byte) error {
out, err := unmarshalNameValueMap(raw, "headers")
if err != nil {
return err
}
*h = out
return nil
}
// unmarshalNameValueMap parses ACP's official [{name,value}, ...] array shape
// or the legacy {name: value, ...} map shape into a map. field names the JSON
// field for error messages.
func unmarshalNameValueMap(raw []byte, field string) (map[string]string, error) {
if s := strings.TrimSpace(string(raw)); s == "" || s == "null" {
return nil, nil
}
var vars []EnvVariable
if err := json.Unmarshal(raw, &vars); err == nil {
out := make(map[string]string, len(vars))
for i, v := range vars {
if strings.TrimSpace(v.Name) == "" {
return nil, fmt.Errorf("%s[%d].name is required", field, i)
}
out[v.Name] = v.Value
}
return out, nil
}
var legacy map[string]string
if err := json.Unmarshal(raw, &legacy); err == nil {
return legacy, nil
}
return nil, fmt.Errorf("%s must be an array of {name,value} objects", field)
}
// SessionNewResult returns the opaque id used to address the session thereafter.
type SessionNewResult struct {
SessionID string `json:"sessionId"`
Models *SessionModelState `json:"models,omitempty"`
Modes *SessionModeState `json:"modes,omitempty"`
ConfigOptions []SessionConfigOption `json:"configOptions,omitempty"`
}
// --- session modes ---
// SessionMode is one operating mode the client can switch the session into.
type SessionMode struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// SessionModeState reports the current mode and the full mode list.
type SessionModeState struct {
CurrentModeID string `json:"currentModeId"`
AvailableModes []SessionMode `json:"availableModes"`
}
// SessionSetModeParams switches a session's operating mode.
type SessionSetModeParams struct {
SessionID string `json:"sessionId"`
ModeID string `json:"modeId"`
}
// SessionSetModeResult is the empty ack.
type SessionSetModeResult struct{}
// ModelInfo describes one selectable model in ACP's legacy model selector.
type ModelInfo struct {
ModelID string `json:"modelId"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// SessionModelState is ACP's legacy model selector state. New clients should
// prefer the category:"model" config option, but some hosts still probe this.
type SessionModelState struct {
AvailableModels []ModelInfo `json:"availableModels"`
CurrentModelID string `json:"currentModelId"`
}
// --- session/load ---
// SessionLoadParams resumes a session saved under sessionId (the id a prior
// session/new returned), optionally re-rooting it at cwd with fresh MCP servers.
// The agent replays the stored conversation as session/update notifications
// before the request returns.
type SessionLoadParams struct {
SessionID string `json:"sessionId"`
Cwd string `json:"cwd,omitempty"`
MCPServers []MCPServerSpec `json:"mcpServers,omitempty"`
}
// SessionLoadResult is the empty ack; the conversation has already arrived as a
// burst of session/update notifications by the time it is sent.
type SessionLoadResult struct {
Models *SessionModelState `json:"models,omitempty"`
Modes *SessionModeState `json:"modes,omitempty"`
ConfigOptions []SessionConfigOption `json:"configOptions,omitempty"`
}
// --- session/resume ---
// SessionResumeParams resumes a session without replaying its transcript.
type SessionResumeParams struct {
SessionID string `json:"sessionId"`
Cwd string `json:"cwd,omitempty"`
MCPServers []MCPServerSpec `json:"mcpServers,omitempty"`
}
// SessionResumeResult is the empty ack returned once the session is ready.
type SessionResumeResult struct {
Models *SessionModelState `json:"models,omitempty"`
Modes *SessionModeState `json:"modes,omitempty"`
ConfigOptions []SessionConfigOption `json:"configOptions,omitempty"`
}
// --- session/set_config_option ---
// SetSessionConfigOptionParams changes one advertised session config option.
type SetSessionConfigOptionParams struct {
SessionID string `json:"sessionId"`
ConfigID string `json:"configId"`
Value string `json:"value"`
}
// SetSessionConfigOptionResult returns the full refreshed config state.
type SetSessionConfigOptionResult struct {
ConfigOptions []SessionConfigOption `json:"configOptions"`
}
// SessionConfigOption is a single-value ACP session selector.
type SessionConfigOption struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
Type string `json:"type"`
CurrentValue string `json:"currentValue"`
Options []SessionConfigSelectOption `json:"options"`
}
// SessionConfigSelectOption is one selectable value for a config option.
type SessionConfigSelectOption struct {
Value string `json:"value"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// --- session/set_model ---
// SetSessionModelParams is ACP's legacy model-switching request.
type SetSessionModelParams struct {
SessionID string `json:"sessionId"`
ModelID string `json:"modelId"`
}
// SetSessionModelResult is the empty ack for legacy model switching.
type SetSessionModelResult struct{}
// --- session/list ---
// SessionListParams lists known sessions, optionally filtered by cwd.
type SessionListParams struct {
Cwd string `json:"cwd,omitempty"`
Cursor string `json:"cursor,omitempty"`
}
// SessionListResult is the first and only page of sessions Reasonix currently
// returns. NextCursor is omitted because the in-process list is unpaged.
type SessionListResult struct {
Sessions []SessionInfo `json:"sessions"`
NextCursor string `json:"nextCursor,omitempty"`
}
// SessionInfo is the ACP session/list item shape.
type SessionInfo struct {
SessionID string `json:"sessionId"`
Cwd string `json:"cwd"`
Title string `json:"title,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
Meta map[string]any `json:"_meta,omitempty"`
}
// --- session/close ---
// SessionCloseParams closes an active session and releases its resources.
type SessionCloseParams struct {
SessionID string `json:"sessionId"`
}
// SessionCloseResult is the empty close ack.
type SessionCloseResult struct{}
// --- session/delete ---
// SessionDeleteParams removes a session from future session/list results.
type SessionDeleteParams struct {
SessionID string `json:"sessionId"`
}
// SessionDeleteResult is the empty delete ack.
type SessionDeleteResult struct{}
// --- content blocks (inbound prompt) ---
// ContentBlock is one piece of a prompt. The agent reads text blocks and the
// inline text of resource blocks (embeddedContext); image/audio are accepted on
// the wire but ignored, matching the advertised capabilities.
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Resource *ResourceContents `json:"resource,omitempty"`
MimeType string `json:"mimeType,omitempty"`
Data string `json:"data,omitempty"`
}
// ResourceContents is the embedded resource of a "resource" content block.
type ResourceContents struct {
URI string `json:"uri"`
MimeType string `json:"mimeType,omitempty"`
Text string `json:"text,omitempty"`
}
// FlattenPrompt extracts the user-visible prompt text out of ACP content blocks.
// Text blocks contribute their text; resource blocks contribute their inline
// text when present (embeddedContext). Other block kinds are dropped. Ported from
// protocol.ts flattenPrompt.
func FlattenPrompt(blocks []ContentBlock) string {
parts := make([]string, 0, len(blocks))
for _, b := range blocks {
switch b.Type {
case "text":
if b.Text != "" {
parts = append(parts, b.Text)
}
case "resource":
if b.Resource != nil && b.Resource.Text != "" {
parts = append(parts, b.Resource.Text)
}
}
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
// --- session/prompt ---
// SessionPromptParams sends a turn's prompt to a session.
type SessionPromptParams struct {
SessionID string `json:"sessionId"`
Prompt []ContentBlock `json:"prompt"`
}
// StopReason tells the client why a turn ended. Values match main's wire.
type StopReason string
const (
StopEndTurn StopReason = "end_turn"
StopCancelled StopReason = "cancelled"
StopError StopReason = "error"
)
// SessionPromptResult ends a session/prompt. TranscriptPath is reserved for a
// future on-disk transcript pointer; omitted (null) for now.
type SessionPromptResult struct {
StopReason StopReason `json:"stopReason"`
TranscriptPath *string `json:"transcriptPath,omitempty"`
}
// --- session/update (agent → client notifications) ---
//
// SessionUpdate is a tagged union discriminated by sessionUpdate. The variants
// reuse the JSON key "content" with two incompatible shapes (a single block for
// message chunks, an array for tool results), so we model each variant as its own
// struct rather than one struct with conflicting tags, and carry it through
// SessionUpdateParams.Update as an interface value.
// SessionUpdateParams wraps one update for a session.
type SessionUpdateParams struct {
SessionID string `json:"sessionId"`
Update any `json:"update"`
}
// messageChunk is agent_message_chunk / agent_thought_chunk.
type messageChunk struct {
SessionUpdate string `json:"sessionUpdate"`
Content ContentBlock `json:"content"`
Metadata *updateMeta `json:"metadata,omitempty"`
}
// updateMeta carries optional error detail on an agent_message_chunk.
type updateMeta struct {
Error *updateError `json:"error,omitempty"`
}
type updateError struct {
Name string `json:"name"`
Message string `json:"message"`
}
// toolCall is a "tool_call" update (announces a call, with title/kind/rawInput).
type toolCall struct {
SessionUpdate string `json:"sessionUpdate"`
ToolCallID string `json:"toolCallId"`
Title string `json:"title,omitempty"`
Kind string `json:"kind,omitempty"`
Status string `json:"status,omitempty"`
RawInput json.RawMessage `json:"rawInput,omitempty"`
Locations []ToolCallLocation `json:"locations,omitempty"`
}
// ToolCallLocation names a file (and optionally a line) a tool call touches, so
// the client can follow along in the editor.
type ToolCallLocation struct {
Path string `json:"path"`
Line *int `json:"line,omitempty"`
}
// toolCallUpdateMsg is a "tool_call_update" update (status + result content).
type toolCallUpdateMsg struct {
SessionUpdate string `json:"sessionUpdate"`
ToolCallID string `json:"toolCallId"`
Status string `json:"status,omitempty"`
Content []toolContent `json:"content,omitempty"`
}
// toolContent wraps a tool result's text, per the ACP tool_call_update shape.
type toolContent struct {
Type string `json:"type"`
Content ContentBlock `json:"content"`
}
// availableCommandsUpdate advertises slash commands that the ACP client may
// surface in its composer. The client sends invocations back as normal
// session/prompt text such as "/review diff".
type availableCommandsUpdate struct {
SessionUpdate string `json:"sessionUpdate"`
AvailableCommands []AvailableCommand `json:"availableCommands"`
}
// AvailableCommand is one slash command available in a session.
type AvailableCommand struct {
Name string `json:"name"`
Description string `json:"description"`
Input *AvailableCommandInput `json:"input,omitempty"`
}
// AvailableCommandInput describes a command's free-form text argument.
type AvailableCommandInput struct {
Hint string `json:"hint"`
}
// configOptionUpdate reports a complete refreshed session config state.
type configOptionUpdate struct {
SessionUpdate string `json:"sessionUpdate"`
ConfigOptions []SessionConfigOption `json:"configOptions"`
}
// planUpdate is a "plan" update: the agent's current task list. Each update
// carries the complete plan and replaces the previous one, mirroring the
// todo_write contract it is derived from.
type planUpdate struct {
SessionUpdate string `json:"sessionUpdate"`
Entries []PlanEntry `json:"entries"`
}
// PlanEntry is one task in a plan update.
type PlanEntry struct {
Content string `json:"content"`
Priority string `json:"priority"`
Status string `json:"status"`
}
// currentModeUpdate reports that the session switched operating modes.
type currentModeUpdate struct {
SessionUpdate string `json:"sessionUpdate"`
CurrentModeID string `json:"currentModeId"`
}
// --- fs/* (agent → client requests) ---
// FSReadTextFileParams asks the client for a file's current text, including
// unsaved editor state. Line (1-based) and Limit page the content; Reasonix
// always reads whole files and pages locally, so it sends neither.
type FSReadTextFileParams struct {
SessionID string `json:"sessionId"`
Path string `json:"path"`
Line *int `json:"line,omitempty"`
Limit *int `json:"limit,omitempty"`
}
// FSReadTextFileResult carries the file content.
type FSReadTextFileResult struct {
Content string `json:"content"`
}
// FSWriteTextFileParams asks the client to write content to path, updating any
// open buffer as well as the file on disk.
type FSWriteTextFileParams struct {
SessionID string `json:"sessionId"`
Path string `json:"path"`
Content string `json:"content"`
}
// --- terminal/* (agent → client requests) ---
// TerminalCreateParams starts a command in a client-owned terminal.
type TerminalCreateParams struct {
SessionID string `json:"sessionId"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Cwd string `json:"cwd,omitempty"`
OutputByteLimit int `json:"outputByteLimit,omitempty"`
}
// TerminalCreateResult returns the id used by the other terminal methods.
type TerminalCreateResult struct {
TerminalID string `json:"terminalId"`
}
// TerminalIDParams addresses one terminal (output / kill / wait / release).
type TerminalIDParams struct {
SessionID string `json:"sessionId"`
TerminalID string `json:"terminalId"`
}
// TerminalOutputResult is the terminal's captured output so far.
type TerminalOutputResult struct {
Output string `json:"output"`
Truncated bool `json:"truncated"`
ExitStatus *TerminalExitStatus `json:"exitStatus,omitempty"`
}
// TerminalWaitResult reports how the command exited.
type TerminalWaitResult struct {
ExitCode *int `json:"exitCode,omitempty"`
Signal *string `json:"signal,omitempty"`
}
// TerminalExitStatus mirrors TerminalWaitResult inside terminal/output.
type TerminalExitStatus struct {
ExitCode *int `json:"exitCode,omitempty"`
Signal *string `json:"signal,omitempty"`
}
// --- session/cancel (client → agent notification) ---
// SessionCancelParams cancels an in-progress turn.
type SessionCancelParams struct {
SessionID string `json:"sessionId"`
}
// --- session/request_permission (agent → client request) ---
// PermissionOptionKind classifies an option for host UI styling. It is an ACP v1
// wire enum, so host-visible permission choices must stay within the official
// protocol values.
type PermissionOptionKind string
const (
OptAllowOnce PermissionOptionKind = "allow_once"
OptAllowAlways PermissionOptionKind = "allow_always"
OptRejectOnce PermissionOptionKind = "reject_once"
OptRejectAlways PermissionOptionKind = "reject_always"
)
// PermissionOption is one choice offered to the user for a permission request.
type PermissionOption struct {
OptionID string `json:"optionId"`
Name string `json:"name"`
Kind PermissionOptionKind `json:"kind"`
}
// PermissionRequestParams asks the client to approve a pending tool call.
type PermissionRequestParams struct {
SessionID string `json:"sessionId"`
ToolCall PermissionToolCall `json:"toolCall"`
Options []PermissionOption `json:"options"`
}
// PermissionToolCall describes the call awaiting approval.
type PermissionToolCall struct {
ToolCallID string `json:"toolCallId"`
Title string `json:"title,omitempty"`
Kind string `json:"kind,omitempty"`
Status string `json:"status,omitempty"`
Content []toolContent `json:"content,omitempty"`
RawInput json.RawMessage `json:"rawInput,omitempty"`
}
// PermissionRequestResult is the client's reply to a permission request.
type PermissionRequestResult struct {
Outcome PermissionOutcome `json:"outcome"`
}
// PermissionOutcome is "selected" (with optionId) or "cancelled".
type PermissionOutcome struct {
Outcome string `json:"outcome"`
OptionID string `json:"optionId,omitempty"`
}