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