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

525 lines
17 KiB
Go

package acp
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strconv"
"strings"
"sync"
"unicode/utf8"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/permission"
"reasonix/internal/provider"
)
// notifier is the slice of Conn the dispatch sink depends on: it pushes
// session/update notifications and, when a tool needs approval, makes a
// session/request_permission request. Narrowing to this interface keeps the sink
// unit-testable with a fake.
type notifier interface {
Notify(method string, params any) error
Request(ctx context.Context, method string, params any) (json.RawMessage, error)
}
// maxResultChars clips a tool result before it crosses the wire, matching main's
// dispatch.ts (the full result still goes to the model; this is display only).
const maxResultChars = 8000
// updateSink is an event.Sink bound to one session that maps the agent's typed
// event stream onto ACP session/update notifications. It is the v2 counterpart of
// main's dispatchKernelEvent: where main translated kernel events, we translate
// the event.Event the v2 agent already emits.
//
// v2 has no separate "tool intent" event — a call goes ToolDispatch → ToolResult,
// two states — so we emit a single pending tool_call on dispatch (already carrying
// rawInput, which main only had by the intent step) and a completed/failed
// tool_call_update on result. Message/Usage/Phase/TurnStarted/TurnDone have no
// place in main's update set and are dropped (TurnDone's outcome surfaces as the
// session/prompt stopReason instead).
//
// An ApprovalRequest is the controller asking the user to allow a gated tool
// call; the sink forwards it as a session/request_permission round-trip and feeds
// the answer back via approve (control.Controller.Approve), which the run loop is
// blocked on.
type updateSink struct {
conn notifier
sessionID string
// cwd resolves relative tool-arg paths for tool_call locations. Set once
// via bindCwd before the sink receives events.
cwd string
approve func(id string, allow, session, persist bool)
answer func(id string, answers []event.AskAnswer)
mu sync.Mutex
turnCtx context.Context
}
func newUpdateSink(conn notifier, sessionID string) *updateSink {
return &updateSink{conn: conn, sessionID: sessionID}
}
// bindCwd installs the session root used to absolutize tool_call locations.
func (s *updateSink) bindCwd(cwd string) { s.cwd = cwd }
// bindApprove installs the controller's Approve callback, called by the service
// once the controller exists (the sink is built first, to hand to the Factory).
func (s *updateSink) bindApprove(fn func(id string, allow, session, persist bool)) {
if fn == nil {
s.approve = nil
return
}
s.approve = fn
}
// bindAnswer installs the controller's AnswerQuestion callback for AskRequest
// events.
func (s *updateSink) bindAnswer(fn func(id string, answers []event.AskAnswer)) {
s.answer = fn
}
func (s *updateSink) setTurnContext(ctx context.Context) {
s.mu.Lock()
s.turnCtx = ctx
s.mu.Unlock()
}
func (s *updateSink) clearTurnContext() {
s.mu.Lock()
s.turnCtx = nil
s.mu.Unlock()
}
func (s *updateSink) currentTurnContext() context.Context {
s.mu.Lock()
ctx := s.turnCtx
s.mu.Unlock()
if ctx == nil {
return context.Background()
}
return ctx
}
// Emit implements event.Sink. The agent calls it serially (see event.Sink), so no
// locking is needed; write serialization lives in Conn.
func (s *updateSink) Emit(e event.Event) {
switch e.Kind {
case event.Reasoning:
if e.Text == "" {
return
}
s.send(messageChunk{SessionUpdate: "agent_thought_chunk", Content: textBlock(e.Text)})
case event.Text:
if e.Text == "" {
return
}
s.send(messageChunk{SessionUpdate: "agent_message_chunk", Content: textBlock(e.Text)})
case event.ToolDispatch:
// Skip the early (Partial) dispatch: it carries no args, and the full one
// that follows is the single pending tool_call the protocol expects.
if e.Tool.Partial {
return
}
// todo_write is the agent's task list; mirror it as an ACP plan update so
// the client renders structured progress alongside the tool_call.
if e.Tool.Name == "todo_write" {
if entries, ok := planEntriesFromTodoArgs(e.Tool.Args); ok {
s.send(planUpdate{SessionUpdate: "plan", Entries: entries})
}
}
s.send(toolCall{
SessionUpdate: "tool_call",
ToolCallID: e.Tool.ID,
Title: e.Tool.Name,
Kind: toolKindFor(e.Tool.Name),
Status: "pending",
RawInput: rawJSON(e.Tool.Args),
Locations: s.toolLocations(e.Tool.Name, e.Tool.Args),
})
case event.ToolResult:
status := "completed"
text := e.Tool.Output
if e.Tool.Err != "" {
status = "failed"
text = e.Tool.Err
}
s.send(toolCallUpdateMsg{
SessionUpdate: "tool_call_update",
ToolCallID: e.Tool.ID,
Status: status,
Content: []toolContent{{Type: "content", Content: textBlock(clip(text))}},
})
case event.Notice:
// Surface warnings to the host as a message chunk so they're not lost;
// info-level notices stay out of band.
if e.Level == event.LevelWarn && e.Text != "" {
s.send(messageChunk{
SessionUpdate: "agent_message_chunk",
Content: textBlock("\n\n[warning] " + e.Text),
})
}
case event.CompactionDone:
// ACP has no compaction-card concept; surface a one-line note so the host
// knows the context was summarized (an aborted pass has no summary).
if e.Compaction.Summary != "" {
s.send(messageChunk{
SessionUpdate: "agent_message_chunk",
Content: textBlock(fmt.Sprintf("\n\n[compacted %d earlier messages to save context]", e.Compaction.Messages)),
})
}
case event.ApprovalRequest:
// The run loop is now blocked awaiting Approve(id, …). Do the
// client round-trip off the emit goroutine so Emit returns at once
// (the agent emits serially); the answer unblocks the loop.
turnCtx := s.currentTurnContext()
go s.requestPermission(turnCtx, e.Approval)
case event.AskRequest:
// ACP has no separate "ask the user a business question" method. Reuse
// the standard permission round-trip with the question options as choices;
// clients such as Zed already know how to render this interaction.
turnCtx := s.currentTurnContext()
go s.requestAsk(turnCtx, e.Ask)
}
}
func (s *updateSink) send(update any) {
_ = s.conn.Notify("session/update", SessionUpdateParams{SessionID: s.sessionID, Update: update})
}
// replay streams a loaded conversation back to the client as session/update
// notifications so a resumed session reconstructs its transcript view. The
// system message is skipped (not user-visible); everything is reported as already
// completed since it is history, not a live turn.
func (s *updateSink) replay(msgs []provider.Message) {
for _, m := range msgs {
switch m.Role {
case provider.RoleUser:
if m.Content != "" {
s.send(messageChunk{SessionUpdate: "user_message_chunk", Content: textBlock(m.Content)})
}
case provider.RoleAssistant:
if m.ReasoningContent != "" {
s.send(messageChunk{SessionUpdate: "agent_thought_chunk", Content: textBlock(m.ReasoningContent)})
}
if m.Content != "" {
s.send(messageChunk{SessionUpdate: "agent_message_chunk", Content: textBlock(m.Content)})
}
for _, tc := range m.ToolCalls {
s.send(toolCall{
SessionUpdate: "tool_call",
ToolCallID: tc.ID,
Title: tc.Name,
Kind: toolKindFor(tc.Name),
Status: "completed",
RawInput: rawJSON(tc.Arguments),
Locations: s.toolLocations(tc.Name, tc.Arguments),
})
// Replaying the latest plan keeps the client's plan view in sync
// with the restored conversation; each update replaces the last.
if tc.Name == "todo_write" {
if entries, ok := planEntriesFromTodoArgs(tc.Arguments); ok {
s.send(planUpdate{SessionUpdate: "plan", Entries: entries})
}
}
}
case provider.RoleTool:
s.send(toolCallUpdateMsg{
SessionUpdate: "tool_call_update",
ToolCallID: m.ToolCallID,
Status: "completed",
Content: []toolContent{{Type: "content", Content: textBlock(clip(m.Content))}},
})
}
}
}
// requestPermission forwards an approval request to the client as a
// session/request_permission round-trip and feeds the outcome back through
// approve. Any transport failure or a cancelled/rejected outcome denies the call,
// so the model gets a blocked result rather than the turn hanging.
func (s *updateSink) requestPermission(ctx context.Context, a event.Approval) {
if s.approve == nil {
return
}
title := a.Tool
if a.Subject != "" {
title = a.Tool + " " + a.Subject
}
options := approvalOptions(a.Tool, a.Subject)
params := PermissionRequestParams{
SessionID: s.sessionID,
ToolCall: PermissionToolCall{
ToolCallID: "gate-" + a.ID,
Title: title,
Kind: toolKindFor(a.Tool),
Status: "pending",
},
Options: options,
}
allow, session, persist := false, false, false
if raw, err := s.conn.Request(ctx, "session/request_permission", params); err == nil {
var res PermissionRequestResult
if json.Unmarshal(raw, &res) == nil && res.Outcome.Outcome == "selected" {
switch PermissionOptionKind(res.Outcome.OptionID) {
case OptAllowOnce:
allow = true
case OptAllowAlways:
allow, session = true, true
}
}
}
s.approve(a.ID, allow, session, persist)
}
func (s *updateSink) requestAsk(ctx context.Context, a event.Ask) {
if s.answer == nil {
return
}
answers := make([]event.AskAnswer, 0, len(a.Questions))
for _, q := range a.Questions {
selected, ok := s.requestAskQuestion(ctx, a.ID, q)
if !ok {
s.answer(a.ID, nil)
return
}
answers = append(answers, event.AskAnswer{QuestionID: q.ID, Selected: []string{selected}})
}
s.answer(a.ID, answers)
}
func (s *updateSink) requestAskQuestion(ctx context.Context, askID string, q event.AskQuestion) (string, bool) {
title := strings.TrimSpace(q.Prompt)
if title == "" {
title = strings.TrimSpace(q.Header)
}
if title == "" {
title = "Question"
}
content := []toolContent(nil)
if q.Header != "" && q.Header != title {
content = append(content, toolContent{Type: "content", Content: textBlock(q.Header)})
}
options := make([]PermissionOption, 0, len(q.Options)+1)
labelsByID := make(map[string]string, len(q.Options))
for i, opt := range q.Options {
id := fmt.Sprintf("%s:%d", q.ID, i+1)
name := strings.TrimSpace(opt.Label)
if strings.TrimSpace(opt.Description) != "" {
name += " - " + strings.TrimSpace(opt.Description)
}
options = append(options, PermissionOption{OptionID: id, Name: name, Kind: OptAllowOnce})
labelsByID[id] = opt.Label
}
options = append(options, PermissionOption{OptionID: q.ID + ":cancel", Name: "Cancel", Kind: OptRejectOnce})
rawInput, _ := json.Marshal(map[string]any{
"id": q.ID,
"question": title,
"options": q.Options,
"multi": q.Multi,
})
params := PermissionRequestParams{
SessionID: s.sessionID,
ToolCall: PermissionToolCall{
ToolCallID: "ask-" + askID + "-" + q.ID,
Title: title,
Kind: "other",
Status: "pending",
Content: content,
RawInput: rawInput,
},
Options: options,
}
raw, err := s.conn.Request(ctx, "session/request_permission", params)
if err != nil {
return "", false
}
var res PermissionRequestResult
if json.Unmarshal(raw, &res) != nil || res.Outcome.Outcome != "selected" {
return "", false
}
label, ok := labelsByID[res.Outcome.OptionID]
return label, ok
}
func approvalSessionOptionName(tool, subject string) string {
if tool == control.SandboxEscapeApprovalTool {
return "Use real environment for this session"
}
sessionRule := permission.SessionGrantRuleForScope(tool, subject)
return "Allow " + sessionRule + " for this session"
}
func approvalOptions(tool, subject string) []PermissionOption {
if control.RequiresFreshHumanApprovalTool(tool) {
if tool == control.SandboxEscapeApprovalTool {
return []PermissionOption{
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
{OptionID: string(OptAllowAlways), Name: approvalSessionOptionName(tool, subject), Kind: OptAllowAlways},
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
}
}
return []PermissionOption{
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
}
}
allowSessionName := approvalSessionOptionName(tool, subject)
options := []PermissionOption{
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
{OptionID: string(OptAllowAlways), Name: allowSessionName, Kind: OptAllowAlways},
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
}
return options
}
// textBlock builds a text content block.
func textBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
// rawJSON returns args as a raw JSON value when it is valid JSON, else nil so the
// rawInput field is omitted rather than carrying a malformed payload.
func rawJSON(args string) json.RawMessage {
if args == "" || !json.Valid([]byte(args)) {
return nil
}
return json.RawMessage(args)
}
// clip truncates text to maxResultChars, appending a note, matching dispatch.ts.
func clip(text string) string {
if len(text) <= maxResultChars {
return text
}
end := maxResultChars
for end > 0 && !utf8.ValidString(text[:end]) {
end--
}
return text[:end] + "\n…(" +
strconv.Itoa(len(text)-end) + " more chars truncated)"
}
// toolKindFor maps a tool name to the ACP tool kind the host uses to categorize
// the call in its UI. The kinds match main's restricted set
// (read/edit/search/execute/other). Known v2 built-ins map explicitly; anything
// else (plugins, the task tool) falls back to a name heuristic, then "other".
func toolKindFor(name string) string {
switch name {
case "read_file", "ls", "glob":
return "read"
case "grep":
return "search"
case "edit_file", "move_file", "multiedit", "write_file":
return "edit"
case "bash":
return "execute"
case control.SandboxEscapeApprovalTool:
return "execute"
}
n := strings.ToLower(name)
switch {
case strings.Contains(n, "search") || strings.Contains(n, "grep") || strings.Contains(n, "find"):
return "search"
case strings.Contains(n, "edit") || strings.Contains(n, "write") || strings.Contains(n, "replace"):
return "edit"
case strings.Contains(n, "read") || strings.Contains(n, "cat") || strings.Contains(n, "view"):
return "read"
case strings.Contains(n, "bash") || strings.Contains(n, "exec") || strings.Contains(n, "shell") || strings.Contains(n, "run"):
return "execute"
default:
return "other"
}
}
// locationTools names the builtin tools whose "path" argument is a real file
// target worth a follow-along location. Search/list tools are excluded: their
// path is a directory scope, not a file the user would want opened.
var locationTools = map[string]bool{
"read_file": true,
"write_file": true,
"edit_file": true,
"multi_edit": true,
"notebook_edit": true,
"delete_range": true,
"delete_symbol": true,
"code_index": true,
}
// toolLocations derives the file location a tool call touches from its raw
// args, so the client can follow along in the editor. Unknown tools and
// path-less args yield nil.
func (s *updateSink) toolLocations(name, rawArgs string) []ToolCallLocation {
if !locationTools[name] {
return nil
}
var p struct {
Path string `json:"path"`
Offset int `json:"offset"`
}
if json.Unmarshal([]byte(rawArgs), &p) != nil || strings.TrimSpace(p.Path) == "" {
return nil
}
loc := ToolCallLocation{Path: s.absPath(p.Path)}
// read_file's offset is a 0-based start line; surface it so the editor can
// jump to the region being read.
if name == "read_file" && p.Offset > 0 {
line := p.Offset + 1
loc.Line = &line
}
return []ToolCallLocation{loc}
}
func (s *updateSink) absPath(p string) string {
if filepath.IsAbs(p) || s.cwd == "" {
return p
}
return filepath.Join(s.cwd, p)
}
// planEntriesFromTodoArgs maps a todo_write argument payload onto ACP plan
// entries. Phase items (level 0) rank high, sub-steps medium; unknown statuses
// degrade to pending so a malformed item cannot poison the whole update.
func planEntriesFromTodoArgs(rawArgs string) ([]PlanEntry, bool) {
var p struct {
Todos []struct {
Content string `json:"content"`
Status string `json:"status"`
Level int `json:"level"`
} `json:"todos"`
}
if json.Unmarshal([]byte(rawArgs), &p) != nil || len(p.Todos) == 0 {
return nil, false
}
entries := make([]PlanEntry, 0, len(p.Todos))
for _, t := range p.Todos {
if strings.TrimSpace(t.Content) == "" {
continue
}
status := t.Status
switch status {
case "pending", "in_progress", "completed":
default:
status = "pending"
}
priority := "medium"
if t.Level == 0 {
priority = "high"
}
entries = append(entries, PlanEntry{Content: t.Content, Priority: priority, Status: status})
}
if len(entries) == 0 {
return nil, false
}
return entries, true
}