Files
kandev/apps/backend/internal/agent/acpdbg/ops.go
T

539 lines
16 KiB
Go

package acpdbg
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
)
// methodSessionUpdate is the ACP notification an agent streams for every
// message chunk, tool call, and replayed history entry.
const methodSessionUpdate = "session/update"
// IsAuthErrorMessage is a coarse substring heuristic mirroring
// hostutility.isAuthError. ACP auth failures bubble up as plain-text
// JSON-RPC errors without a distinct code, so we match obvious markers so
// callers can route the user to the login flow; anything unmatched stays
// as a generic failure.
func IsAuthErrorMessage(s string) bool {
lower := strings.ToLower(s)
for _, needle := range []string{"auth", "login", "unauthorized", "credential", "api key", "token"} {
if strings.Contains(lower, needle) {
return true
}
}
return false
}
// ProbeResult is the parsed summary of a successful probe. It mirrors the
// high-level fields we extract from `initialize` and `session/new` so the
// CLI's `matrix` summary and the skill don't have to re-parse JSONL.
type ProbeResult struct {
ProtocolVersion int
AgentName string
AgentVersion string
AuthMethods []string
Models []string
CurrentModelID string
Modes []string
CurrentModeID string
SessionID string
MCPHTTP bool
MCPSSE bool
}
// MCPProbeResult describes what was delivered to an agent separately from
// what the injected sentinel observed. An unobserved sentinel is normal for
// agents which defer MCP connections or do not support the supplied transport.
type MCPProbeResult struct {
ProbeResult
Delivered bool
Sentinel MCPSentinelSummary
}
// Probe runs the minimum ACP handshake: initialize → session/new → close.
// Returns a parsed summary, or an error describing where the handshake
// broke. The runner's JSONL file captures the full wire payload regardless
// of the outcome.
func Probe(ctx context.Context, r *Runner) (*ProbeResult, error) {
initResp, err := sendInitialize(ctx, r)
if err != nil {
return nil, fmt.Errorf("initialize: %w", err)
}
newResp, err := sendSessionNew(ctx, r)
if err != nil {
return nil, fmt.Errorf("session/new: %w", err)
}
return buildProbeResult(initResp, newResp), nil
}
// MCPProbe runs initialize and injects a short-lived HTTP sentinel into
// session/new. The caller's timeout bounds both ACP delivery and observation.
func MCPProbe(ctx context.Context, r *Runner) (*MCPProbeResult, error) {
initResp, err := sendInitialize(ctx, r)
if err != nil {
return nil, fmt.Errorf("initialize: %w", err)
}
sentinel := NewMCPSentinel(r.rec)
defer sentinel.Close()
newResp, err := sendSessionNewWithMCP(ctx, r, []any{map[string]any{
"name": "acpdbg-sentinel",
"type": "http",
"url": sentinel.URL(),
}})
if err != nil {
return nil, fmt.Errorf("session/new: %w", err)
}
result := &MCPProbeResult{
ProbeResult: *buildProbeResult(initResp, newResp),
Delivered: true,
}
// Give an asynchronously connecting agent a small, bounded opportunity to
// reach the sentinel without turning missing traffic into a failure.
wait := time.NewTimer(750 * time.Millisecond)
defer wait.Stop()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-wait.C:
}
result.Sentinel = sentinel.Summary()
return result, nil
}
// PromptOptions configures the Prompt operation.
type PromptOptions struct {
Model string // if non-empty, set via session/set_model (or unstable variant)
Mode string // if non-empty, set via session/set_mode
Prompt string // required — text body of the session/prompt request
// Linger, when > 0, keeps the session open after session/prompt returns
// its stopReason and drains (recording, auto-replying to) any further
// out-of-band frames the agent pushes. This is how we observe a delayed
// async subagent result delivered on the same session after end_turn.
Linger time.Duration
}
// PromptResult holds the text response collected from session/update
// message_chunks while session/prompt is running.
type PromptResult struct {
ProbeResult
Text string
}
// SessionLoadOptions configures a session/load operation and an optional
// prompt used to verify the loaded session's effective workspace.
type SessionLoadOptions struct {
SessionID string
Prompt string
}
// SessionLoadResult holds the loaded session metadata and optional response.
type SessionLoadResult struct {
ProbeResult
Text string
}
// Prompt runs the full prompt round-trip: initialize → session/new →
// [set_model] → [set_mode] → session/prompt → drain updates until the
// prompt response arrives.
func Prompt(ctx context.Context, r *Runner, opts PromptOptions) (*PromptResult, error) {
if opts.Prompt == "" {
return nil, errors.New("prompt is required")
}
probe, err := Probe(ctx, r)
if err != nil {
return nil, err
}
if opts.Model != "" {
if err := sendSetModel(ctx, r, probe.SessionID, opts.Model); err != nil {
return nil, fmt.Errorf("session/set_model: %w", err)
}
}
if opts.Mode != "" {
if err := sendSetMode(ctx, r, probe.SessionID, opts.Mode); err != nil {
return nil, fmt.Errorf("session/set_mode: %w", err)
}
}
text, err := sendPromptAndCollect(ctx, r, probe.SessionID, opts.Prompt)
if err != nil {
return nil, fmt.Errorf("session/prompt: %w", err)
}
if opts.Linger > 0 {
lingerAfterPrompt(ctx, r, opts.Linger)
}
return &PromptResult{ProbeResult: *probe, Text: text}, nil
}
// lingerAfterPrompt keeps draining out-of-band frames for the given duration
// after session/prompt has already returned its stopReason. Every frame is
// still recorded by the read loop, and NextOOB auto-replies to any
// agent-initiated request so the agent does not hang. This exists to capture a
// delayed async update (e.g. Cursor pushing a background subagent result on the
// same session after end_turn). We swallow the terminal error: a deadline or a
// child that exits first are both expected ways to stop lingering.
func lingerAfterPrompt(ctx context.Context, r *Runner, d time.Duration) {
lingerCtx, cancel := context.WithTimeout(ctx, d)
defer cancel()
// Never returns true, so it drains until the linger deadline or EOF.
_ = r.DrainOOBUntil(lingerCtx, func(Frame) bool { return false })
}
// SessionLoad runs initialize → session/load → [session/prompt].
func SessionLoad(ctx context.Context, r *Runner, opts SessionLoadOptions) (*SessionLoadResult, error) {
if opts.SessionID == "" {
return nil, errors.New("session-id is required")
}
initResp, err := sendInitialize(ctx, r)
if err != nil {
return nil, fmt.Errorf("initialize: %w", err)
}
req, _ := r.Framer().NewRequest("session/load", sessionLoadParams(opts.SessionID, r.cfg.Workdir))
resp, err := r.Request(ctx, req)
if err != nil {
return nil, fmt.Errorf("session/load: %w", err)
}
if errMap, ok := resp["error"].(map[string]any); ok {
return nil, fmt.Errorf("session/load error: %v", errMap["message"])
}
result := &SessionLoadResult{ProbeResult: *buildProbeResult(initResp, resp)}
if opts.Prompt == "" {
return result, nil
}
result.Text, err = sendPromptAndCollect(ctx, r, opts.SessionID, opts.Prompt)
if err != nil {
return nil, fmt.Errorf("session/prompt: %w", err)
}
return result, nil
}
func sessionLoadParams(sessionID, workdir string) map[string]any {
return map[string]any{
"sessionId": sessionID,
"cwd": workdir,
"mcpServers": []any{},
}
}
// --- JSON-RPC helpers ---
func sendInitialize(ctx context.Context, r *Runner) (Frame, error) {
req, _ := r.Framer().NewRequest("initialize", map[string]any{
"protocolVersion": 1,
"clientInfo": map[string]any{
"name": "acpdbg",
"version": "0.1.0",
},
"clientCapabilities": map[string]any{
"fs": map[string]any{"readTextFile": false, "writeTextFile": false},
"terminal": false,
},
})
resp, err := r.Request(ctx, req)
if err != nil {
return nil, err
}
if errMap, ok := resp["error"].(map[string]any); ok {
return nil, fmt.Errorf("%v", errMap["message"])
}
return resp, nil
}
func sendSessionNew(ctx context.Context, r *Runner) (Frame, error) {
return sendSessionNewWithMCP(ctx, r, []any{})
}
func sendSessionNewWithMCP(ctx context.Context, r *Runner, mcpServers []any) (Frame, error) {
req, _ := r.Framer().NewRequest("session/new", map[string]any{
"cwd": r.cfg.Workdir,
"mcpServers": mcpServers,
})
resp, err := r.Request(ctx, req)
if err != nil {
return nil, err
}
if errMap, ok := resp["error"].(map[string]any); ok {
return nil, fmt.Errorf("%v", errMap["message"])
}
return resp, nil
}
func sendSetModel(ctx context.Context, r *Runner, sessionID, modelID string) error {
req, _ := r.Framer().NewRequest("session/set_model", map[string]any{
"sessionId": sessionID,
"modelId": modelID,
})
resp, err := r.Request(ctx, req)
if err != nil {
return err
}
if errMap, ok := resp["error"].(map[string]any); ok {
return fmt.Errorf("%v", errMap["message"])
}
return nil
}
func sendSetMode(ctx context.Context, r *Runner, sessionID, modeID string) error {
req, _ := r.Framer().NewRequest("session/set_mode", map[string]any{
"sessionId": sessionID,
"modeId": modeID,
})
resp, err := r.Request(ctx, req)
if err != nil {
return err
}
if errMap, ok := resp["error"].(map[string]any); ok {
return fmt.Errorf("%v", errMap["message"])
}
return nil
}
func sendPromptAndCollect(ctx context.Context, r *Runner, sessionID, prompt string) (string, error) {
req, promptID := r.Framer().NewRequest("session/prompt", map[string]any{
"sessionId": sessionID,
"prompt": []any{
map[string]any{"type": "text", "text": prompt},
},
})
// We can't use r.Request here because we want to drain update
// notifications while waiting for the response. Write directly and
// loop on the OOB channel + a pending response future.
if err := r.rec.Sent(req); err != nil {
return "", err
}
respCh, err := r.registerPending(promptID)
if err != nil {
return "", err
}
// Every exit path drops the waiter, including the ones that give up before
// a response arrives, so the map does not accumulate dead entries.
defer func() {
r.mu.Lock()
delete(r.pending, promptID)
r.mu.Unlock()
}()
var text strings.Builder
// drain consumes everything currently queued. Frames that arrived before
// the prompt was written are answered but never collected: a session/load
// replay leaves the whole conversation history queued, and folding that
// into the prompt's text would return the transcript instead of the reply.
drain := func(collect bool) {
for {
frame, ok, _ := r.popOOB()
if !ok {
return
}
// Agent-initiated request: auto-reply so it doesn't hang.
if frame.Method() != "" && frame.ID() != nil {
reply := NewMethodNotFound(frame.ID(), frame.Method())
_ = r.rec.Sent(reply)
_ = r.framer.Write(reply)
continue
}
if collect && frame.Method() == methodSessionUpdate {
if chunk := extractTextChunk(frame); chunk != "" {
text.WriteString(chunk)
}
}
}
}
drain(false)
if err := r.framer.Write(req); err != nil {
return "", err
}
for {
drain(true)
select {
case resp, ok := <-respCh:
if !ok {
// The read loop closed our waiter: the child died or the
// runner is shutting down. Without this check the nil frame
// would read as a successful, empty response.
r.mu.Lock()
readErr := r.readLoopEr
r.mu.Unlock()
if readErr == nil {
readErr = io.EOF
}
return text.String(), fmt.Errorf("read loop exited: %w", readErr)
}
// The read loop is serial and the agent emits every chunk before
// the response, so anything left queued belongs to this prompt.
drain(true)
if errMap, ok := resp["error"].(map[string]any); ok {
return text.String(), fmt.Errorf("%v", errMap["message"])
}
return text.String(), nil
case <-r.oobWake:
case <-ctx.Done():
return text.String(), ctx.Err()
}
if r.oobFinished() {
return text.String(), io.EOF
}
}
}
// extractTextChunk pulls an `agentMessageChunk.content.text.text` value out
// of a session/update notification, if present. ACP's session/update is a
// tagged variant; we walk the structure defensively.
func extractTextChunk(frame Frame) string {
params, ok := frame["params"].(map[string]any)
if !ok {
return ""
}
update, ok := params["update"].(map[string]any)
if !ok {
return ""
}
// Variant 1: { "sessionUpdate": "agent_message_chunk", "content": {...} }
if kind, _ := update["sessionUpdate"].(string); kind == "agent_message_chunk" {
if content, ok := update["content"].(map[string]any); ok {
if t, ok := content["text"].(string); ok {
return t
}
}
}
// Variant 2 (older SDKs): { "agentMessageChunk": { "content": {...} } }
return extractVariant2Text(update)
}
func extractVariant2Text(update map[string]any) string {
chunk, ok := update["agentMessageChunk"].(map[string]any)
if !ok {
return ""
}
content, ok := chunk["content"].(map[string]any)
if !ok {
return ""
}
if inner, ok := content["text"].(map[string]any); ok {
if t, ok := inner["text"].(string); ok {
return t
}
}
if t, ok := content["text"].(string); ok {
return t
}
return ""
}
func buildProbeResult(initResp, newResp Frame) *ProbeResult {
out := &ProbeResult{}
if initResult, ok := initResp["result"].(map[string]any); ok {
parseInitResult(initResult, out)
}
if newResult, ok := newResp["result"].(map[string]any); ok {
parseSessionResult(newResult, out)
}
return out
}
func parseInitResult(initResult map[string]any, out *ProbeResult) {
if pv, ok := initResult["protocolVersion"].(float64); ok {
out.ProtocolVersion = int(pv)
}
if info, ok := initResult["agentInfo"].(map[string]any); ok {
out.AgentName, _ = info["name"].(string)
out.AgentVersion, _ = info["version"].(string)
}
if methods, ok := initResult["authMethods"].([]any); ok {
out.AuthMethods = extractStringField(methods, "id")
}
if capabilities, ok := initResult["capabilities"].(map[string]any); ok {
if mcp, ok := capabilities["mcpCapabilities"].(map[string]any); ok {
out.MCPHTTP, _ = mcp["http"].(bool)
out.MCPSSE, _ = mcp["sse"].(bool)
}
}
}
func parseSessionResult(newResult map[string]any, out *ProbeResult) {
out.SessionID, _ = newResult["sessionId"].(string)
if models, ok := newResult["models"].(map[string]any); ok {
out.CurrentModelID, _ = models["currentModelId"].(string)
if avail, ok := models["availableModels"].([]any); ok {
out.Models = extractStringField(avail, "modelId")
}
} else {
out.CurrentModelID, out.Models = extractSelectConfigOption(newResult, "model")
}
if modes, ok := newResult["modes"].(map[string]any); ok {
out.CurrentModeID, _ = modes["currentModeId"].(string)
if avail, ok := modes["availableModes"].([]any); ok {
out.Modes = extractStringField(avail, "id")
}
} else {
out.CurrentModeID, out.Modes = extractSelectConfigOption(newResult, "mode")
}
}
func extractSelectConfigOption(newResult map[string]any, category string) (string, []string) {
opts, ok := newResult["configOptions"].([]any)
if !ok {
return "", nil
}
for _, item := range opts {
opt, ok := item.(map[string]any)
if !ok || opt["category"] != category || opt["type"] != "select" {
continue
}
current, _ := opt["currentValue"].(string)
return current, extractConfigOptionValues(opt["options"])
}
return "", nil
}
func extractConfigOptionValues(raw any) []string {
list, ok := raw.([]any)
if !ok {
return nil
}
var out []string
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
continue
}
if v, ok := m["value"].(string); ok {
out = append(out, v)
continue
}
if nested, ok := m["options"].([]any); ok {
out = append(out, extractStringField(nested, "value")...)
}
}
return out
}
// extractStringField pulls a string field from each map entry in the list.
func extractStringField(list []any, field string) []string {
var out []string
for _, item := range list {
mm, ok := item.(map[string]any)
if !ok {
continue
}
if v, ok := mm[field].(string); ok {
out = append(out, v)
}
}
return out
}