返回 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 SessionHostID: "local",
177 ExtraPlugins: p.MCPServers,
178 MCPHostProfile: plugin.HostProfileForInteractive(p.MCPInteractions),
179 CleanupPendingReconciler: acp.ReconcileCleanupPending,
180 OnSessionRecovered: p.OnSessionRecovered,
181 OnSessionTransition: p.OnSessionTransition,
182 FileOverlay: p.FileOverlay,
183 TerminalRunner: p.Terminal,
184 Ablation: f.ablationSet(),
185 SandboxNetworkOverride: f.networkOverride,
186 SandboxBashOverride: bashOverride,
187 WorkspaceOnly: f.workspaceOnly,
188 }, nil
189 }
190
191 func (f *acpFactory) SessionRuntimeState(_ context.Context, p acp.SessionRuntimeStateParams) (acp.SessionRuntimeState, error) {
192 cfg, err := config.LoadForRoot(p.Cwd)
193 if err != nil {
194 return acp.SessionRuntimeState{}, err
195 }
196 plannerMode := effectiveACPPlannerMode(cfg, f.plannerOff, p.Model)
197 writeRoots := cfg.WriteRootsForRoot(p.Cwd)
198 if f.workspaceOnly {
199 writeRoots = []string{p.Cwd}
200 }
201 networkEnabled := cfg.Sandbox.Network
202 if f.networkOverride != nil {
203 networkEnabled = *f.networkOverride
204 }
205 effectiveBash := cfg.BashMode()
206 if f.bashOverride == "enforce" {
207 effectiveBash = "enforce"
208 }
209 sandboxAvailable := true
210 if effectiveBash == "enforce" {
211 sandboxAvailable = f.isSandboxAvailable()
212 } else {
213 // Without an OS sandbox the shell is intentionally unconfined, including
214 // network access; report the actual posture rather than the inert config bit.
215 networkEnabled = true
216 }
217 if f.requireSandbox && !sandboxAvailable {
218 return acp.SessionRuntimeState{}, fmt.Errorf("effective bash sandbox unavailable: %s", sandbox.UnavailableMessage())
219 }
220 return acp.SessionRuntimeState{
221 PlannerMode: plannerMode,
222 Sandbox: acp.SessionSandboxState{
223 Mode: effectiveBash,
224 Engine: acpSandboxEngine(effectiveBash),
225 Available: sandboxAvailable,
226 WorkspaceRoot: p.Cwd,
227 WriteRoots: writeRoots,
228 NetworkEnabled: networkEnabled,
229 },
230 }, nil
231 }
232
233 func (f *acpFactory) isSandboxAvailable() bool {
234 if f.sandboxAvailable != nil {
235 return f.sandboxAvailable()
236 }
237 return sandbox.Available()
238 }
239
240 func effectiveACPPlannerMode(cfg *config.Config, disabled bool, model string) string {
241 if cfg == nil || disabled {
242 return "off"
243 }
244 plannerRef := strings.TrimSpace(cfg.Agent.PlannerModel)
245 if plannerRef == "" {
246 return "off"
247 }
248 planner, plannerOK := cfg.ResolveModel(plannerRef)
249 executor, executorOK := cfg.ResolveModel(strings.TrimSpace(model))
250 if !plannerOK || !executorOK || planner.Model == executor.Model {
251 return "off"
252 }
253 return "on"
254 }
255
256 func acpSandboxEngine(mode string) string {
257 if mode != "enforce" {
258 return "none"
259 }
260 switch runtime.GOOS {
261 case "darwin":
262 return "seatbelt"
263 case "linux":
264 return "bubblewrap"
265 default:
266 return "none"
267 }
268 }
269
270 func (f *acpFactory) SessionConfigState(_ context.Context, p acp.SessionConfigStateParams) (acp.SessionConfigState, error) {
271 root := strings.TrimSpace(p.Cwd)
272 if root == "" {
273 if wd, err := os.Getwd(); err == nil {
274 root = wd
275 }
276 }
277 if root != "" && !filepath.IsAbs(root) {
278 return acp.SessionConfigState{}, fmt.Errorf("session cwd must be an absolute path: %s", root)
279 }
280 _, _ = config.MigrateLegacyIfNeededForRoot(root)
281 _, _ = config.MigrateMCPToUserConfigOnUpgrade([]string{root})
282 cfg, err := config.LoadForRoot(root)
283 if err != nil {
284 return acp.SessionConfigState{}, err
285 }
286
287 // Session or factory model overrides are explicit; empty falls back (#6996).
288 explicit := firstNonEmpty(p.Model, f.model)
289 ref, _, err := resolveModelForCLI(explicit, cfg)
290 if err != nil {
291 return acp.SessionConfigState{}, err
292 }
293 if strings.TrimSpace(ref) == "" {
294 return acp.SessionConfigState{}, fmt.Errorf("no default_model configured")
295 }
296 // Plugin-namespaced refs belong to extension sidecars: they never resolve
297 // through the config catalog, so their configured/current handling keys off
298 // the ref itself and boot's merged resolver is the gate.
299 pluginRef := providerext.PluginRefOwner(ref) != ""
300 entry, ok := cfg.ResolveModel(ref)
301 if !ok && !pluginRef {
302 return acp.SessionConfigState{}, fmt.Errorf("unknown model %q", ref)
303 }
304 if ok && !entry.Configured() {
305 return acp.SessionConfigState{}, fmt.Errorf("model %q is not configured", ref)
306 }
307 currentModel := ref
308 entryDescription := ""
309 if ok {
310 currentModel = entry.Name + "/" + entry.Model
311 entryDescription = entry.Name
312 }
313 modelOptions, modelInfos := acpModelOptions(cfg)
314 if !hasModelOption(modelOptions, currentModel) {
315 modelOptions = append(modelOptions, acp.SessionConfigSelectOption{
316 Value: currentModel,
317 Name: currentModel,
318 Description: entryDescription,
319 })
320 modelInfos = append(modelInfos, acp.ModelInfo{
321 ModelID: currentModel,
322 Name: currentModel,
323 Description: entryDescription,
324 })
325 }
326
327 effortEntry := config.ProviderEntry{}
328 if ok {
329 effortEntry = *entry
330 }
331 effortOverride := cloneStringPtr(p.EffortOverride)
332 if effortOverride != nil {
333 if strings.TrimSpace(*effortOverride) == "" {
334 effortEntry.Effort = ""
335 } else {
336 normalized, err := config.NormalizeEffort(&effortEntry, *effortOverride)
337 if err != nil {
338 return acp.SessionConfigState{}, err
339 } else {
340 effortEntry.Effort = normalized
341 effortOverride = &normalized
342 }
343 }
344 }
345 if err := config.ReasoningCapabilityForEntry(&effortEntry).Validate(effortEntry.Model, config.EffectiveEffort(&effortEntry)); err != nil {
346 return acp.SessionConfigState{}, err
347 }
348
349 options := []acp.SessionConfigOption{{
350 ID: "model",
351 Name: "Model",
352 Category: "model",
353 Type: "select",
354 CurrentValue: currentModel,
355 Options: modelOptions,
356 }}
357 if cap := config.EffortCapabilityForEntry(&effortEntry); cap.Supported {
358 currentEffort := config.EffortDisplay(&effortEntry)
359 if !containsString(cap.Levels, currentEffort) {
360 currentEffort = config.EffectiveEffort(&effortEntry)
361 }
362 options = append(options, acp.SessionConfigOption{
363 ID: "effort",
364 Name: "Effort",
365 Category: "thought_level",
366 Type: "select",
367 CurrentValue: currentEffort,
368 Options: acpEffortOptions(cap.Levels),
369 })
370 }
371 // RuntimeProfile stays pinned for old status readers; mode options are unpublished.
372 return acp.SessionConfigState{
373 Model: currentModel,
374 EffortOverride: effortOverride,
375 RuntimeProfile: "balanced",
376 Models: &acp.SessionModelState{
377 AvailableModels: modelInfos,
378 CurrentModelID: currentModel,
379 },
380 ConfigOptions: options,
381 }, nil
382 }
383
384 func acpBuiltinTools(cfg *config.Config, cwd string, writeRoots []string) []tool.Tool {
385 bashSpec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: writeRoots, Network: cfg.Sandbox.Network}
386 ws := builtin.Workspace{
387 Dir: cwd,
388 WriteRoots: writeRoots,
389 Bash: bashSpec,
390 BashTimeout: time.Duration(cfg.BashTimeoutSeconds()) * time.Second,
391 Search: builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, nil),
392 ProxySpec: cfg.NetworkProxySpec(),
393 SessionGuard: builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots()),
394 }
395 return ws.Tools(cfg.Tools.Enabled...)
396 }
397
398 func acpModelOptions(cfg *config.Config) ([]acp.SessionConfigSelectOption, []acp.ModelInfo) {
399 if cfg == nil {
400 return nil, nil
401 }
402 var options []acp.SessionConfigSelectOption
403 var models []acp.ModelInfo
404 for i := range cfg.Providers {
405 p := &cfg.Providers[i]
406 if !p.Configured() {
407 continue
408 }
409 for _, model := range p.ChatModelList() {
410 ref := p.Name + "/" + model
411 options = append(options, acp.SessionConfigSelectOption{
412 Value: ref,
413 Name: ref,
414 Description: p.Name,
415 })
416 models = append(models, acp.ModelInfo{
417 ModelID: ref,
418 Name: ref,
419 Description: p.Name,
420 })
421 }
422 }
423 return options, models
424 }
425
426 func hasModelOption(options []acp.SessionConfigSelectOption, ref string) bool {
427 for _, opt := range options {
428 if opt.Value == ref {
429 return true
430 }
431 }
432 return false
433 }
434
435 func acpEffortOptions(levels []string) []acp.SessionConfigSelectOption {
436 out := make([]acp.SessionConfigSelectOption, 0, len(levels))
437 for _, level := range levels {
438 out = append(out, acp.SessionConfigSelectOption{Value: level, Name: effortOptionName(level)})
439 }
440 return out
441 }
442
443 func effortOptionName(level string) string {
444 if level == "" {
445 return ""
446 }
447 if level == "xhigh" {
448 return "XHigh"
449 }
450 return strings.ToUpper(level[:1]) + level[1:]
451 }
452
453 func firstNonEmpty(values ...string) string {
454 for _, value := range values {
455 if strings.TrimSpace(value) != "" {
456 return strings.TrimSpace(value)
457 }
458 }
459 return ""
460 }
461
462 func cloneStringPtr(p *string) *string {
463 if p == nil {
464 return nil
465 }
466 cp := *p
467 return &cp
468 }
469
470 func acpTaskProfileDefaults(cfg *config.Config) (string, string) {
471 if cfg == nil {
472 return "", ""
473 }
474 model := strings.TrimSpace(cfg.Agent.SubagentModels["task"])
475 if model == "" {
476 model = strings.TrimSpace(cfg.Agent.SubagentModel)
477 }
478 effort := strings.TrimSpace(cfg.Agent.SubagentEfforts["task"])
479 if effort == "" {
480 effort = strings.TrimSpace(cfg.Agent.SubagentEffort)
481 }
482 return model, effort
483 }
484
485 func newACPSubagentProviderResolver(cfg *config.Config, parent *config.ProviderEntry, proxySpec netclient.ProxySpec) func(string, string) (provider.Provider, *provider.Pricing, int, error) {
486 return func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) {
487 modelRef = strings.TrimSpace(modelRef)
488 effort = strings.TrimSpace(effort)
489
490 var entry *config.ProviderEntry
491 if modelRef != "" {
492 var ok bool
493 entry, ok = cfg.ResolveModel(modelRef)
494 if !ok {
495 return nil, nil, 0, fmt.Errorf("subagent_model %q is not a configured provider", modelRef)
496 }
497 } else {
498 cp := *parent
499 entry = &cp
500 }
501
502 if effort != "" {
503 normalized, err := config.NormalizeEffort(entry, effort)
504 if err != nil {
505 return nil, nil, 0, err
506 }
507 entry.Effort = normalized
508 if entry.Kind == "anthropic" && strings.TrimSpace(entry.Effort) != "" && strings.TrimSpace(entry.Thinking) == "" {
509 entry.Thinking = "adaptive"
510 }
511 }
512
513 prov, err := boot.NewProviderWithProxy(entry, proxySpec)
514 if err != nil {
515 return nil, nil, 0, err
516 }
517 return prov, entry.Price, entry.ContextWindow, nil
518 }
519 }
520
520 lines GO