返回 DeepSeek-Reasonix
acp.go
根目录 / internal / cli / acp.go
1 package cli
2
3 import (
4 "context"
5 "flag"
6 "fmt"
7 "os"
8 "os/signal"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "time"
13
14 "reasonix/internal/ablation"
15 "reasonix/internal/acp"
16 "reasonix/internal/boot"
17 "reasonix/internal/config"
18 "reasonix/internal/control"
19 "reasonix/internal/extension/providerext"
20 "reasonix/internal/i18n"
21 "reasonix/internal/netclient"
22 "reasonix/internal/plugin"
23 "reasonix/internal/provider"
24 "reasonix/internal/sandbox"
25 "reasonix/internal/tool"
26 "reasonix/internal/tool/builtin"
27 )
28
29 // acpCommand runs Reasonix as an Agent Client Protocol agent: a stdio JSON-RPC
30 // server that editors and other host clients drive (initialize, session/new,
31 // session/prompt, session/cancel). It keeps v2 wire-compatible with the many
32 // tools that integrated with v1 over ACP.
33 //
34 // stdin/stdout are the JSON-RPC channel — nothing else may write to stdout, so
35 // all diagnostics go to stderr. Each session is assembled by acpFactory, rooted
36 // at the cwd the client opens.
37 func acpCommand(args []string, version string) int {
38 args, deprecatedMode, err := consumeDeprecatedModeFlags(args, "profile")
39 if err != nil {
40 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
41 return 2
42 }
43 fs := flag.NewFlagSet("acp", flag.ContinueOnError)
44 model := fs.String("model", "", "provider name (default: config default_model)")
45 plannerFlag := fs.String("planner", "auto", "planner policy: auto | off")
46 networkFlag := fs.String("sandbox-network", "auto", "sandbox network policy: auto | on | off")
47 bashFlag := fs.String("sandbox-bash", "auto", "bash sandbox policy: auto | enforce")
48 workspaceOnly := fs.Bool("workspace-only", false, "ignore configured extra write roots and confine writes to the session cwd")
49 if code, ok := parseCommandFlags(fs, args); !ok {
50 return code
51 }
52 plannerMode := strings.ToLower(strings.TrimSpace(*plannerFlag))
53 if plannerMode != "auto" && plannerMode != "off" {
54 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "planner must be auto or off")
55 return 2
56 }
57 networkMode := strings.ToLower(strings.TrimSpace(*networkFlag))
58 var networkOverride *bool
59 switch networkMode {
60 case "auto":
61 case "on":
62 on := true
63 networkOverride = &on
64 case "off":
65 off := false
66 networkOverride = &off
67 default:
68 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "sandbox-network must be auto, on, or off")
69 return 2
70 }
71 bashMode := strings.ToLower(strings.TrimSpace(*bashFlag))
72 if bashMode != "auto" && bashMode != "enforce" {
73 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "sandbox-bash must be auto or enforce")
74 return 2
75 }
76 if err := acceptDeprecatedModeFlag(deprecatedMode); err != nil {
77 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
78 return 2
79 }
80
81 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
82 defer stop()
83
84 factory := &acpFactory{
85 model: *model, plannerOff: plannerMode == "off",
86 networkOverride: networkOverride, workspaceOnly: *workspaceOnly,
87 bashOverride: bashMode, requireSandbox: bashMode == "enforce",
88 }
89 info := acp.AgentInfo{Name: "reasonix", Version: version}
90 if err := acp.Serve(ctx, os.Stdin, os.Stdout, factory, info); err != nil {
91 fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
92 return 1
93 }
94 return 0
95 }
96
97 // acpFactory builds one control.Controller per ACP session by reusing boot.Build
98 // with the session cwd as WorkspaceRoot. That keeps ACP aligned with chat,
99 // desktop, and serve assembly while still adding the host-supplied MCP servers
100 // for this session only.
101 type acpFactory struct {
102 model string
103 plannerOff bool
104 networkOverride *bool
105 bashOverride string
106 workspaceOnly bool
107 requireSandbox bool
108 sandboxAvailable func() bool
109 }
110
111 func (f *acpFactory) SessionDir() string {
112 return config.SessionDir()
113 }
114
115 // ablationSet maps the ACP --planner=off hard override onto the shared
116 // subsystem switch boot consults.
117 func (f *acpFactory) ablationSet() ablation.Set {
118 if f.plannerOff {
119 return ablation.New(ablation.Planner)
120 }
121 return ablation.Set{}
122 }
123
124 // NewSession assembles the per-session controller. Resources (MCP subprocesses)
125 // are released via the controller's Cleanup, run on ctrl.Close().
126 func (f *acpFactory) NewSession(ctx context.Context, p acp.SessionParams) (*control.Controller, error) {
127 opts, err := f.sessionBootOptions(p)
128 if err != nil {
129 return nil, err
130 }
131 return boot.Build(ctx, opts)
132 }
133
134 // RebuildSession rebuilds the session controller with the same boot.Options.
135 func (f *acpFactory) RebuildSession(ctx context.Context, p acp.SessionParams, old *control.Controller) (*control.Controller, error) {
136 opts, err := f.sessionBootOptions(p)
137 if err != nil {
138 return nil, err
139 }
140 res, err := boot.Rebuild(ctx, old, opts)
141 if err != nil {
142 return nil, err
143 }
144 // The stage-3a runtime set is always empty, so nothing leaks by returning
145 // only the controller (see boot.Build's compatibility wrapper).
146 return res.Controller, nil
147 }
148
149 // sessionBootOptions builds the boot.Options every ACP session controller —
150 // initial build or boot.Rebuild replacement — is assembled from.
151 func (f *acpFactory) sessionBootOptions(p acp.SessionParams) (boot.Options, error) {
152 root := strings.TrimSpace(p.Cwd)
153 if root == "" {
154 if wd, err := os.Getwd(); err == nil {
155 root = wd
156 }
157 }
158 if root != "" && !filepath.IsAbs(root) {
159 return boot.Options{}, fmt.Errorf("session cwd must be an absolute path: %s", root)
160 }
161 bashOverride := ""
162 if f.bashOverride == "enforce" {
163 bashOverride = "enforce"
164 }
165 sessionDir := f.SessionDir()
166 return boot.Options{
167 Model: firstNonEmpty(p.Model, f.model),
168 RequireKey: true,
169 Sink: p.Sink,
170 StatsSource: "cli",
171 EffortOverride: p.EffortOverride,
172 Stderr: os.Stderr,
173 WorkspaceRoot: root,
174 SessionDir: sessionDir,
175 SessionService: cliSessionService(sessionDir),
176 NativeLegacySession: p.NativeLegacySession,
177 BackgroundScope: p.BackgroundScope,
178 SessionTemp: p.SessionTemp,
179 PersistentShell: p.PersistentShell,
180 SessionHostID: "local",
181 ExtraPlugins: p.MCPServers,
182 MCPHostProfile: plugin.HostProfileForInteractive(p.MCPInteractions),
183 CleanupPendingReconciler: acp.ReconcileCleanupPending,
184 OnSessionRecovered: p.OnSessionRecovered,
185 OnSessionTransition: p.OnSessionTransition,
186 FileOverlay: p.FileOverlay,
187 TerminalRunner: p.Terminal,
188 Ablation: f.ablationSet(),
189 SandboxNetworkOverride: f.networkOverride,
190 SandboxBashOverride: bashOverride,
191 WorkspaceOnly: f.workspaceOnly,
192 }, nil
193 }
194
195 func (f *acpFactory) SessionRuntimeState(_ context.Context, p acp.SessionRuntimeStateParams) (acp.SessionRuntimeState, error) {
196 cfg, err := config.LoadForRoot(p.Cwd)
197 if err != nil {
198 return acp.SessionRuntimeState{}, err
199 }
200 plannerMode := effectiveACPPlannerMode(cfg, f.plannerOff, p.Model)
201 writeRoots := cfg.WriteRootsForRoot(p.Cwd)
202 if f.workspaceOnly {
203 writeRoots = []string{p.Cwd}
204 }
205 networkEnabled := cfg.Sandbox.Network
206 if f.networkOverride != nil {
207 networkEnabled = *f.networkOverride
208 }
209 effectiveBash := cfg.BashMode()
210 if f.bashOverride == "enforce" {
211 effectiveBash = "enforce"
212 }
213 sandboxAvailable := true
214 if effectiveBash == "enforce" {
215 sandboxAvailable = f.isSandboxAvailable()
216 } else {
217 // Without an OS sandbox the shell is intentionally unconfined, including
218 // network access; report the actual posture rather than the inert config bit.
219 networkEnabled = true
220 }
221 if f.requireSandbox && !sandboxAvailable {
222 return acp.SessionRuntimeState{}, fmt.Errorf("effective bash sandbox unavailable: %s", sandbox.UnavailableMessage())
223 }
224 return acp.SessionRuntimeState{
225 PlannerMode: plannerMode,
226 Sandbox: acp.SessionSandboxState{
227 Mode: effectiveBash,
228 Engine: acpSandboxEngine(effectiveBash),
229 Available: sandboxAvailable,
230 WorkspaceRoot: p.Cwd,
231 WriteRoots: writeRoots,
232 NetworkEnabled: networkEnabled,
233 },
234 }, nil
235 }
236
237 func (f *acpFactory) isSandboxAvailable() bool {
238 if f.sandboxAvailable != nil {
239 return f.sandboxAvailable()
240 }
241 return sandbox.Available()
242 }
243
244 func effectiveACPPlannerMode(cfg *config.Config, disabled bool, model string) string {
245 if cfg == nil || disabled {
246 return "off"
247 }
248 plannerRef := strings.TrimSpace(cfg.Agent.PlannerModel)
249 if plannerRef == "" {
250 return "off"
251 }
252 planner, plannerOK := cfg.ResolveModel(plannerRef)
253 executor, executorOK := cfg.ResolveModel(strings.TrimSpace(model))
254 if !plannerOK || !executorOK || planner.Model == executor.Model {
255 return "off"
256 }
257 return "on"
258 }
259
260 func acpSandboxEngine(mode string) string {
261 if mode != "enforce" {
262 return "none"
263 }
264 switch runtime.GOOS {
265 case "darwin":
266 return "seatbelt"
267 case "linux":
268 return "bubblewrap"
269 default:
270 return "none"
271 }
272 }
273
274 func (f *acpFactory) SessionConfigState(_ context.Context, p acp.SessionConfigStateParams) (acp.SessionConfigState, error) {
275 root := strings.TrimSpace(p.Cwd)
276 if root == "" {
277 if wd, err := os.Getwd(); err == nil {
278 root = wd
279 }
280 }
281 if root != "" && !filepath.IsAbs(root) {
282 return acp.SessionConfigState{}, fmt.Errorf("session cwd must be an absolute path: %s", root)
283 }
284 _, _ = config.MigrateLegacyIfNeededForRoot(root)
285 _, _ = config.MigrateMCPToUserConfigOnUpgrade([]string{root})
286 cfg, err := config.LoadForRoot(root)
287 if err != nil {
288 return acp.SessionConfigState{}, err
289 }
290
291 // Session or factory model overrides are explicit; empty falls back (#6996).
292 explicit := firstNonEmpty(p.Model, f.model)
293 ref, _, err := resolveModelForCLI(explicit, cfg)
294 if err != nil {
295 return acp.SessionConfigState{}, err
296 }
297 if strings.TrimSpace(ref) == "" {
298 return acp.SessionConfigState{}, fmt.Errorf("no default_model configured")
299 }
300 // Plugin-namespaced refs belong to extension sidecars: they never resolve
301 // through the config catalog, so their configured/current handling keys off
302 // the ref itself and boot's merged resolver is the gate.
303 pluginRef := providerext.PluginRefOwner(ref) != ""
304 entry, ok := cfg.ResolveModel(ref)
305 if !ok && !pluginRef {
306 return acp.SessionConfigState{}, fmt.Errorf("unknown model %q", ref)
307 }
308 if ok && !entry.Configured() {
309 return acp.SessionConfigState{}, fmt.Errorf("model %q is not configured", ref)
310 }
311 currentModel := ref
312 entryDescription := ""
313 if ok {
314 currentModel = entry.Name + "/" + entry.Model
315 entryDescription = entry.Name
316 }
317 modelOptions, modelInfos := acpModelOptions(cfg)
318 if !hasModelOption(modelOptions, currentModel) {
319 modelOptions = append(modelOptions, acp.SessionConfigSelectOption{
320 Value: currentModel,
321 Name: currentModel,
322 Description: entryDescription,
323 })
324 modelInfos = append(modelInfos, acp.ModelInfo{
325 ModelID: currentModel,
326 Name: currentModel,
327 Description: entryDescription,
328 })
329 }
330
331 effortEntry := config.ProviderEntry{}
332 if ok {
333 effortEntry = *entry
334 }
335 effortOverride := cloneStringPtr(p.EffortOverride)
336 if effortOverride != nil {
337 if strings.TrimSpace(*effortOverride) == "" {
338 effortEntry.Effort = ""
339 } else {
340 normalized, err := config.NormalizeEffort(&effortEntry, *effortOverride)
341 if err != nil {
342 return acp.SessionConfigState{}, err
343 } else {
344 effortEntry.Effort = normalized
345 effortOverride = &normalized
346 }
347 }
348 }
349 if err := config.ReasoningCapabilityForEntry(&effortEntry).Validate(effortEntry.Model, config.EffectiveEffort(&effortEntry)); err != nil {
350 return acp.SessionConfigState{}, err
351 }
352
353 options := []acp.SessionConfigOption{{
354 ID: "model",
355 Name: "Model",
356 Category: "model",
357 Type: "select",
358 CurrentValue: currentModel,
359 Options: modelOptions,
360 }}
361 if cap := config.EffortCapabilityForEntry(&effortEntry); cap.Supported {
362 currentEffort := config.EffortDisplay(&effortEntry)
363 if !containsString(cap.Levels, currentEffort) {
364 currentEffort = config.EffectiveEffort(&effortEntry)
365 }
366 options = append(options, acp.SessionConfigOption{
367 ID: "effort",
368 Name: "Effort",
369 Category: "thought_level",
370 Type: "select",
371 CurrentValue: currentEffort,
372 Options: acpEffortOptions(cap.Levels),
373 })
374 }
375 // RuntimeProfile stays pinned for old status readers; mode options are unpublished.
376 return acp.SessionConfigState{
377 Model: currentModel,
378 EffortOverride: effortOverride,
379 RuntimeProfile: "balanced",
380 Models: &acp.SessionModelState{
381 AvailableModels: modelInfos,
382 CurrentModelID: currentModel,
383 },
384 ConfigOptions: options,
385 }, nil
386 }
387
388 func acpBuiltinTools(cfg *config.Config, cwd string, writeRoots []string) []tool.Tool {
389 bashSpec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: writeRoots, Network: cfg.Sandbox.Network}
390 ws := builtin.Workspace{
391 Dir: cwd,
392 WriteRoots: writeRoots,
393 Bash: bashSpec,
394 BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second,
395 Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, nil),
396 ProxySpec: cfg.NetworkProxySpec(),
397 SessionGuard: builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots()),
398 }
399 return ws.Tools(cfg.Tools.Enabled...)
400 }
401
402 func acpModelOptions(cfg *config.Config) ([]acp.SessionConfigSelectOption, []acp.ModelInfo) {
403 if cfg == nil {
404 return nil, nil
405 }
406 var options []acp.SessionConfigSelectOption
407 var models []acp.ModelInfo
408 for i := range cfg.Providers {
409 p := &cfg.Providers[i]
410 if !p.Configured() {
411 continue
412 }
413 for _, model := range p.ChatModelList() {
414 ref := p.Name + "/" + model
415 options = append(options, acp.SessionConfigSelectOption{
416 Value: ref,
417 Name: ref,
418 Description: p.Name,
419 })
420 models = append(models, acp.ModelInfo{
421 ModelID: ref,
422 Name: ref,
423 Description: p.Name,
424 })
425 }
426 }
427 return options, models
428 }
429
430 func hasModelOption(options []acp.SessionConfigSelectOption, ref string) bool {
431 for _, opt := range options {
432 if opt.Value == ref {
433 return true
434 }
435 }
436 return false
437 }
438
439 func acpEffortOptions(levels []string) []acp.SessionConfigSelectOption {
440 out := make([]acp.SessionConfigSelectOption, 0, len(levels))
441 for _, level := range levels {
442 out = append(out, acp.SessionConfigSelectOption{Value: level, Name: effortOptionName(level)})
443 }
444 return out
445 }
446
447 func effortOptionName(level string) string {
448 if level == "" {
449 return ""
450 }
451 if level == "xhigh" {
452 return "XHigh"
453 }
454 return strings.ToUpper(level[:1]) + level[1:]
455 }
456
457 func firstNonEmpty(values ...string) string {
458 for _, value := range values {
459 if strings.TrimSpace(value) != "" {
460 return strings.TrimSpace(value)
461 }
462 }
463 return ""
464 }
465
466 func cloneStringPtr(p *string) *string {
467 if p == nil {
468 return nil
469 }
470 cp := *p
471 return &cp
472 }
473
474 func acpTaskProfileDefaults(cfg *config.Config) (string, string) {
475 if cfg == nil {
476 return "", ""
477 }
478 model := strings.TrimSpace(cfg.Agent.SubagentModels["task"])
479 if model == "" {
480 model = strings.TrimSpace(cfg.Agent.SubagentModel)
481 }
482 effort := strings.TrimSpace(cfg.Agent.SubagentEfforts["task"])
483 if effort == "" {
484 effort = strings.TrimSpace(cfg.Agent.SubagentEffort)
485 }
486 return model, effort
487 }
488
489 func newACPSubagentProviderResolver(cfg *config.Config, parent *config.ProviderEntry, proxySpec netclient.ProxySpec) func(string, string) (provider.Provider, *provider.Pricing, int, error) {
490 return func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) {
491 modelRef = strings.TrimSpace(modelRef)
492 effort = strings.TrimSpace(effort)
493
494 var entry *config.ProviderEntry
495 if modelRef != "" {
496 var ok bool
497 entry, ok = cfg.ResolveModel(modelRef)
498 if !ok {
499 return nil, nil, 0, fmt.Errorf("subagent_model %q is not a configured provider", modelRef)
500 }
501 } else {
502 cp := *parent
503 entry = &cp
504 }
505
506 if effort != "" {
507 normalized, err := config.NormalizeEffort(entry, effort)
508 if err != nil {
509 return nil, nil, 0, err
510 }
511 entry.Effort = normalized
512 if entry.Kind == "anthropic" && strings.TrimSpace(entry.Effort) != "" && strings.TrimSpace(entry.Thinking) == "" {
513 entry.Thinking = "adaptive"
514 }
515 }
516
517 prov, err := boot.NewProviderWithProxy(entry, proxySpec)
518 if err != nil {
519 return nil, nil, 0, err
520 }
521 return prov, entry.Price, entry.ContextWindow, nil
522 }
523 }
524
524 lines GO