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