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