返回 DeepSeek-Reasonix
clientio.go
根目录 / internal / acp / clientio.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "sort"
9 "strings"
10 "time"
11
12 "reasonix/internal/tool/builtin"
13 )
14
15 // requester is the slice of Conn that clientIO drives: agent → client requests.
16 type requester interface {
17 Request(ctx context.Context, method string, params any) (json.RawMessage, error)
18 }
19
20 // clientIO implements builtin.FileOverlay and builtin.TerminalRunner over the
21 // ACP connection for one session, backed by the capabilities the client
22 // declared at initialize. Every method degrades to "not handled" (ok=false) on
23 // a missing capability or a transport/client error, so the tools fall back to
24 // their local implementations instead of failing the call.
25 type clientIO struct {
26 recoveryID string
27 conn requester
28 sessionID string
29 caps ClientCapabilities
30 }
31
32 func newClientIO(conn requester, sessionID string, caps ClientCapabilities) *clientIO {
33 return &clientIO{recoveryID: fmt.Sprintf("%d:%d", os.Getpid(), time.Now().UnixNano()), conn: conn, sessionID: sessionID, caps: caps}
34 }
35
36 // hasAny reports whether the client offered anything clientIO can use; callers
37 // skip wiring the overlay/terminal entirely when it is false.
38 func (c *clientIO) hasAny() bool {
39 return c.caps.FS.ReadTextFile || c.caps.FS.WriteTextFile || c.caps.Terminal
40 }
41
42 // fileOverlay returns c when the client offers an fs method, else nil. The nil
43 // is typed at the call site (assigned to the interface field only when
44 // non-nil), so a session without fs capability carries a nil interface.
45 func (c *clientIO) fileOverlay() *clientIO {
46 if c.caps.FS.ReadTextFile || c.caps.FS.WriteTextFile {
47 return c
48 }
49 return nil
50 }
51
52 // FileOverlayIdentity binds structured file observations to this exact ACP
53 // connection/session instance. A rebuilt clientIO gets a fresh recoveryID.
54 func (c *clientIO) FileOverlayIdentity() string {
55 return "acp:" + c.sessionID + ":" + c.recoveryID
56 }
57
58 // terminalRunner returns c when the client offers terminals, else nil.
59 func (c *clientIO) terminalRunner() *clientIO {
60 if c.caps.Terminal {
61 return c
62 }
63 return nil
64 }
65
66 // ReadTextFile implements builtin.FileOverlay: the client's view of the file,
67 // including unsaved editor buffers. ok=false on missing capability or any
68 // client/transport error — the tool then reads the local disk.
69 func (c *clientIO) ReadTextFile(ctx context.Context, path string) (string, bool) {
70 if !c.caps.FS.ReadTextFile {
71 return "", false
72 }
73 raw, err := c.conn.Request(ctx, "fs/read_text_file", FSReadTextFileParams{SessionID: c.sessionID, Path: path})
74 if err != nil {
75 return "", false
76 }
77 var res FSReadTextFileResult
78 if json.Unmarshal(raw, &res) != nil {
79 return "", false
80 }
81 return res.Content, true
82 }
83
84 // WriteTextFile implements builtin.FileOverlay: the client writes content to
85 // path and updates any open buffer. ok=false on missing capability so the tool
86 // writes the local disk; with the capability present, a client error is a real
87 // write failure and is surfaced (falling back could double-apply).
88 func (c *clientIO) WriteTextFile(ctx context.Context, path, content string) (bool, error) {
89 if !c.caps.FS.WriteTextFile {
90 return false, nil
91 }
92 if _, err := c.conn.Request(ctx, "fs/write_text_file", FSWriteTextFileParams{SessionID: c.sessionID, Path: path, Content: content}); err != nil {
93 return true, err
94 }
95 return true, nil
96 }
97
98 // terminalOutputByteLimit bounds how much output a client terminal buffers for
99 // one command; matches the local bash tool's practical output scale.
100 const terminalOutputByteLimit = 1 << 20
101
102 // RunCommand implements builtin.TerminalRunner: run the command in a
103 // client-owned terminal (terminal/create → wait_for_exit → output → release)
104 // so the user watches it live. ok=false when the client has no terminal
105 // capability or creation fails — the bash tool then executes locally. A
106 // timeout kills the terminal and returns what it printed.
107 //
108 // envOverrides are standard temporary-directory variables (TMPDIR/TMP/TEMP)
109 // for the session-private temp directory. They are serialized as ACP v1
110 // EnvVariable[] and never include the full host environment.
111 func (c *clientIO) RunCommand(ctx context.Context, command, cwd string, timeout time.Duration, envOverrides map[string]string) (string, bool, error) {
112 if !c.caps.Terminal {
113 return "", false, nil
114 }
115 raw, err := c.conn.Request(ctx, "terminal/create", TerminalCreateParams{
116 SessionID: c.sessionID,
117 Command: command,
118 Cwd: cwd,
119 Env: envMapToVariables(envOverrides),
120 OutputByteLimit: terminalOutputByteLimit,
121 })
122 if err != nil {
123 return "", false, nil
124 }
125 var created TerminalCreateResult
126 if json.Unmarshal(raw, &created) != nil || strings.TrimSpace(created.TerminalID) == "" {
127 return "", false, nil
128 }
129 id := TerminalIDParams{SessionID: c.sessionID, TerminalID: created.TerminalID}
130 defer func() { _, _ = c.conn.Request(context.WithoutCancel(ctx), "terminal/release", id) }()
131
132 waitCtx := ctx
133 var cancel context.CancelFunc
134 if timeout > 0 {
135 waitCtx, cancel = context.WithTimeout(ctx, timeout)
136 defer cancel()
137 }
138 _, waitErr := c.conn.Request(waitCtx, "terminal/wait_for_exit", id)
139 timedOut := waitErr != nil && waitCtx.Err() != nil && ctx.Err() == nil
140 if timedOut {
141 _, _ = c.conn.Request(context.WithoutCancel(ctx), "terminal/kill", id)
142 }
143
144 output, exit := c.terminalOutput(context.WithoutCancel(ctx), id)
145 switch {
146 case ctx.Err() != nil:
147 return output, true, ctx.Err()
148 case timedOut:
149 // Typed timeout so bash.ExecuteDetailed can set state=timed_out /
150 // failurePhase=timeout instead of a generic failed/execution.
151 return output, true, builtin.TerminalTimeoutError{Timeout: timeout}
152 case waitErr != nil:
153 return output, true, waitErr
154 case exit != nil && exit.ExitCode != nil && *exit.ExitCode != 0:
155 // Preserve the real exit code on ShellExecution via TerminalExitError.
156 return output, true, builtin.TerminalExitError{Code: *exit.ExitCode}
157 case exit != nil && exit.Signal != nil && *exit.Signal != "":
158 return output, true, fmt.Errorf("terminated by signal %s", *exit.Signal)
159 }
160 return output, true, nil
161 }
162
163 // envMapToVariables converts a small override map into ACP EnvVariable entries.
164 // Empty or nil maps yield nil (omitted from JSON).
165 func envMapToVariables(env map[string]string) []EnvVariable {
166 if len(env) == 0 {
167 return nil
168 }
169 // Stable order for tests and logs.
170 keys := make([]string, 0, len(env))
171 for k := range env {
172 if strings.TrimSpace(k) == "" {
173 continue
174 }
175 keys = append(keys, k)
176 }
177 sort.Strings(keys)
178 out := make([]EnvVariable, 0, len(keys))
179 for _, k := range keys {
180 out = append(out, EnvVariable{Name: k, Value: env[k]})
181 }
182 return out
183 }
184
185 func (c *clientIO) terminalOutput(ctx context.Context, id TerminalIDParams) (string, *TerminalExitStatus) {
186 raw, err := c.conn.Request(ctx, "terminal/output", id)
187 if err != nil {
188 return "", nil
189 }
190 var res TerminalOutputResult
191 if json.Unmarshal(raw, &res) != nil {
192 return "", nil
193 }
194 out := res.Output
195 if res.Truncated {
196 out += "\n…(output truncated by the client terminal)"
197 }
198 return out, res.ExitStatus
199 }
200
201 // RecoveryIdentity scopes verification to the original live ACP transport.
202 func (c *clientIO) RecoveryIdentity() string { return c.recoveryID }
203
203 lines GO