返回 DeepSeek-Reasonix
review.go
根目录 / internal / cli / review.go
1 package cli
2
3 import (
4 "context"
5 "flag"
6 "fmt"
7 "os"
8 "strings"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/boot"
12 "reasonix/internal/config"
13 "reasonix/internal/event"
14 "reasonix/internal/sandbox"
15 "reasonix/internal/skill"
16 "reasonix/internal/tool"
17 "reasonix/internal/tool/builtin"
18 )
19
20 func reviewCommand(args []string) int {
21 fs := flag.NewFlagSet("review", flag.ContinueOnError)
22 base := fs.String("base", "", "base branch/commit to diff against (defaults to HEAD — reviews uncommitted working-tree changes)")
23 commit := fs.String("commit", "", "review a specific commit (shows changes introduced by that commit)")
24 model := fs.String("model", "", "provider name override (default: config default_model)")
25 instructions := fs.String("instructions", "", "extra review instructions appended to the prompt")
26 if code, ok := parseCommandFlags(fs, args); !ok {
27 return code
28 }
29
30 // 1. Get the diff.
31 diff, err := getReviewDiff(*base, *commit)
32 if err != nil {
33 fmt.Fprintln(os.Stderr, "error:", err)
34 return 1
35 }
36 if diff == "" {
37 fmt.Println("No changes to review.")
38 return 0
39 }
40
41 // 2. Load config and resolve model. resolveModelForCLI transparently
42 // falls through a keyless default to the next configured provider
43 // (issue #6996), so a user whose default_model no longer has a key
44 // (e.g. they migrated providers) does not have to add --model to
45 // every `reasonix review` invocation.
46 cfg, err := config.Load()
47 if err != nil {
48 fmt.Fprintln(os.Stderr, "error: failed to load config:", err)
49 return 1
50 }
51 modelName, _, err := resolveModelForCLI(*model, cfg)
52 if err != nil {
53 fmt.Fprintln(os.Stderr, "error:", err)
54 return 1
55 }
56 entry, ok := cfg.ResolveModel(modelName)
57 if !ok {
58 fmt.Fprintf(os.Stderr, "error: unknown model %q — check your config\n", modelName)
59 return 1
60 }
61 if err := cfg.Validate(modelName); err != nil {
62 fmt.Fprintln(os.Stderr, "error:", err)
63 return 1
64 }
65
66 // 3. Create provider.
67 prov, err := boot.NewProviderWithProxy(entry, cfg.NetworkProxySpec())
68 if err != nil {
69 fmt.Fprintln(os.Stderr, "error: failed to create provider:", err)
70 return 1
71 }
72
73 // 4. Get the built-in review skill.
74 root, _ := os.Getwd()
75 skillStore := skill.New(skill.Options{ProjectRoot: root, Stderr: os.Stderr})
76 reviewSk, ok := skillStore.Read("review")
77 if !ok {
78 fmt.Fprintln(os.Stderr, "error: built-in review skill not found")
79 return 1
80 }
81 if reviewSk.RunAs != skill.RunSubagent {
82 fmt.Fprintln(os.Stderr, "error: review skill is not a subagent skill")
83 return 1
84 }
85
86 // 5. Build a review-scoped sub-agent registry.
87 reg := buildReviewSubagentRegistry(reviewSk, cfg, root)
88
89 // 6. Prepare the review prompt.
90 task := buildReviewTask(diff, *instructions)
91
92 // 7. Run the review subagent.
93 ctx := context.Background()
94 // Deliberately minimal Options: this one-shot CLI path has no gate, no
95 // compaction, and no session, unlike the in-session sub-agent paths built
96 // through TaskTool.subagentOptions / boot's subagentSkillOptions. If a new
97 // Options field becomes load-bearing for sub-agents, decide explicitly
98 // whether this path needs it too.
99 result, err := agent.RunReadOnlySubAgentWithSession(ctx, prov, reg, agent.NewSession(reviewSk.Body), task, agent.Options{
100 MaxSteps: 12,
101 Temperature: cfg.Agent.Temperature,
102 Pricing: entry.Price,
103 ContextWindow: entry.ContextWindow,
104 }, event.Discard)
105 if err != nil {
106 fmt.Fprintln(os.Stderr, "error: review failed:", err)
107 return 1
108 }
109
110 fmt.Print(result)
111 return 0
112 }
113
114 func buildReviewSubagentRegistry(reviewSk skill.Skill, cfg *config.Config, root string) *tool.Registry {
115 // The shared helper strips subagent-unavailable background capabilities while
116 // preserving foreground bash. This direct CLI path does not go through boot,
117 // so it first builds the small parent set from the review skill allow-list.
118 parentReg := tool.NewRegistry()
119 for _, name := range reviewSk.AllowedTools {
120 if tl, ok := tool.LookupBuiltin(name); ok {
121 parentReg.Add(tl)
122 }
123 }
124 // Replace the unconfined init-time defaults with confined instances,
125 // mirroring boot's addBuiltins: readers/search bound to the configured
126 // forbid-read roots, bash to the OS sandbox spec plus the session-data
127 // guard. The zero-value tools registered at init honor none of the user's
128 // [sandbox] config, so `reasonix review` previously read forbid_read
129 // paths a normal session would refuse.
130 writeRoots := cfg.WriteRootsForRoot(root)
131 forbidReadRoots := boot.RuntimeForbidReadRoots(cfg, root)
132 guard := builtin.NewSessionDataGuard(config.MemoryUserDir(), cfg.AllowWriteRoots())
133 bashSpec := sandbox.Spec{
134 Mode: cfg.BashMode(),
135 WriteRoots: writeRoots,
136 ForbidReadRoots: forbidReadRoots,
137 Network: cfg.Sandbox.Network,
138 }
139 searchSpec := builtin.ResolveSearch(cfg.Tools.Search.Engine, cfg.Tools.Search.RgPath, os.Stderr)
140 confined := append(builtin.ConfineReaders(forbidReadRoots),
141 builtin.ConfineBash(bashSpec, guard),
142 builtin.ConfineSearch(searchSpec, bashSpec, forbidReadRoots))
143 for _, tl := range confined {
144 if _, ok := parentReg.Get(tl.Name()); ok {
145 parentReg.Add(tl)
146 }
147 }
148 if reviewSk.ReadOnly {
149 // The built-in review skill declares read-only; enforce it here exactly
150 // like the in-session runner does (writer tools stripped, bash under the
151 // permission-classified read-only policy) so `reasonix review` is not a
152 // writable backdoor.
153 return agent.ReadOnlySubagentToolRegistry(parentReg, reviewSk.AllowedTools)
154 }
155 return agent.SubagentToolRegistry(parentReg, reviewSk.AllowedTools)
156 }
157
158 // getReviewDiff runs the appropriate git diff command and returns its output.
159 // - commit="abc": shows diff of abc^..abc
160 // - base="main": shows diff of main...HEAD
161 // - neither: shows diff of uncommitted working-tree changes
162 func getReviewDiff(base, commit string) (string, error) {
163 cwd, _ := os.Getwd()
164 ctx := context.Background()
165 switch {
166 case commit != "":
167 return runGit(ctx, cwd, "diff", commit+"^.."+commit)
168 case base != "":
169 return runGit(ctx, cwd, "diff", base+"...HEAD")
170 default:
171 // Working tree changes: staged + unstaged.
172 out, err := runGit(ctx, cwd, "diff", "HEAD")
173 if err != nil {
174 return "", err
175 }
176 if out == "" {
177 // No working-tree changes; check for staged-only.
178 out, err = runGit(ctx, cwd, "diff", "--cached")
179 }
180 return out, err
181 }
182 }
183
184 func buildReviewTask(diff string, extra string) string {
185 var b strings.Builder
186 b.WriteString("Review the following changes. ")
187 if extra != "" {
188 b.WriteString(extra)
189 b.WriteString(" ")
190 }
191 b.WriteString("The diff is:\n\n```diff\n")
192 // Truncate huge diffs to protect the review subagent's context budget.
193 const maxLen = 16000
194 if len(diff) > maxLen {
195 b.WriteString(diff[:maxLen])
196 b.WriteString("\n```\n\n(diff truncated at ")
197 fmt.Fprint(&b, maxLen)
198 b.WriteString(" chars — focus on the changes shown)")
199 } else {
200 b.WriteString(diff)
201 b.WriteString("\n```")
202 }
203 return b.String()
204 }
205
205 lines GO