88 "errors"
99 "fmt"
1010 "io"
11+ "log"
1112 "os/exec"
1213 "strings"
1314)
@@ -27,6 +28,10 @@ func truncateStderr(stderr string) string {
2728 return stderr [:maxStderrLen ] + "... (truncated)"
2829}
2930
31+ // defaultGeminiModel is the built-in default that may be auto-retried
32+ // without -m if Google retires the model name.
33+ const defaultGeminiModel = "gemini-3.1-pro-preview"
34+
3035// GeminiAgent runs code reviews using the Gemini CLI
3136type GeminiAgent struct {
3237 Command string // The gemini command to run (default: "gemini")
@@ -40,7 +45,7 @@ func NewGeminiAgent(command string) *GeminiAgent {
4045 if command == "" {
4146 command = "gemini"
4247 }
43- return & GeminiAgent {Command : command , Model : "gemini-3.1-pro-preview" , Reasoning : ReasoningStandard }
48+ return & GeminiAgent {Command : command , Model : defaultGeminiModel , Reasoning : ReasoningStandard }
4449}
4550
4651// WithReasoning returns a copy of the agent with the model preserved (reasoning not yet supported).
@@ -91,86 +96,115 @@ func (a *GeminiAgent) CommandLine() string {
9196}
9297
9398func (a * GeminiAgent ) buildArgs (agenticMode bool ) []string {
94- // Use stream-json output for parsing, prompt via stdin
99+ return a .buildArgsWithModel (a .Model , agenticMode )
100+ }
101+
102+ func (a * GeminiAgent ) Review (ctx context.Context , repoPath , commitSHA , prompt string , output io.Writer ) (string , error ) {
103+ agenticMode := a .Agentic || AllowUnsafeAgents ()
104+ args := a .buildArgs (agenticMode )
105+
106+ result , stderrStr , err := a .runGemini (ctx , repoPath , prompt , args , output )
107+ if err != nil && a .Model == defaultGeminiModel && isModelNotFoundError (stderrStr ) {
108+ // Built-in default model may be stale (Google renames
109+ // frequently). Retry without -m to let the Gemini CLI use
110+ // its own default. Non-default models (set via WithModel /
111+ // config) fail fast so config errors are surfaced.
112+ log .Printf ("gemini: model %q not found, retrying without -m flag" , a .Model )
113+ noModelArgs := a .buildArgsWithModel ("" , agenticMode )
114+ result , _ , err = a .runGemini (ctx , repoPath , prompt , noModelArgs , output )
115+ }
116+ return result , err
117+ }
118+
119+ // buildArgsWithModel builds CLI args with an explicit model override
120+ // (empty string omits the -m flag entirely).
121+ func (a * GeminiAgent ) buildArgsWithModel (model string , agenticMode bool ) []string {
95122 args := []string {"--output-format" , "stream-json" }
96123
97- if a . Model != "" {
98- args = append (args , "-m" , a . Model )
124+ if model != "" {
125+ args = append (args , "-m" , model )
99126 }
100127
101128 if agenticMode {
102- // Agentic mode: auto-approve all actions, allow write tools
103- args = append (args , "--yolo" )
104- args = append (args , "--allowed-tools" , "Edit,Write,Read,Glob,Grep,Bash,Shell" )
129+ args = append (args , "--approval-mode" , "yolo" )
105130 } else {
106- // Review mode: read-only tools only
107- args = append (args , "--allowed-tools" , "Read,Glob,Grep" )
131+ args = append (args , "--approval-mode" , "plan" )
108132 }
109133 return args
110134}
111135
112- func (a * GeminiAgent ) Review (ctx context.Context , repoPath , commitSHA , prompt string , output io.Writer ) (string , error ) {
113- // Use agentic mode if either per-job setting or global setting enables it
114- agenticMode := a .Agentic || AllowUnsafeAgents ()
115- args := a .buildArgs (agenticMode )
116-
136+ // runGemini executes the Gemini CLI with the given args and returns
137+ // the review result, captured stderr, and any error.
138+ func (a * GeminiAgent ) runGemini (ctx context.Context , repoPath , prompt string , args []string , output io.Writer ) (string , string , error ) {
117139 cmd := exec .CommandContext (ctx , a .Command , args ... )
118140 cmd .Dir = repoPath
119141 tracker := configureSubprocess (cmd )
120142
121- // Pipe prompt via stdin
122143 cmd .Stdin = strings .NewReader (prompt )
123144
124- // Create one shared sync writer for thread-safe output
125145 sw := newSyncWriter (output )
126146
127147 var stderr bytes.Buffer
128148 stdoutPipe , err := cmd .StdoutPipe ()
129149 if err != nil {
130- return "" , fmt .Errorf ("create stdout pipe: %w" , err )
150+ return "" , "" , fmt .Errorf ("create stdout pipe: %w" , err )
131151 }
132152 stopClosingPipe := closeOnContextDone (ctx , stdoutPipe )
133153 defer stopClosingPipe ()
134- // Tee stderr to output writer for live error visibility
135154 if sw != nil {
136155 cmd .Stderr = io .MultiWriter (& stderr , sw )
137156 } else {
138157 cmd .Stderr = & stderr
139158 }
140159
141160 if err := cmd .Start (); err != nil {
142- return "" , fmt .Errorf ("start gemini: %w" , err )
161+ return "" , "" , fmt .Errorf ("start gemini: %w" , err )
143162 }
144163
145- // Parse stream-json output
146164 parsed , parseErr := a .parseStreamJSON (stdoutPipe , sw )
147165
148- if waitErr := cmd .Wait (); waitErr != nil {
166+ // Wait() is the synchronization point: all stderr writes are
167+ // guaranteed complete after it returns.
168+ waitErr := cmd .Wait ()
169+ stderrStr := stderr .String ()
170+
171+ if waitErr != nil {
149172 if ctxErr := contextProcessError (ctx , tracker , waitErr , parseErr ); ctxErr != nil {
150- return "" , ctxErr
173+ return "" , stderrStr , ctxErr
151174 }
152175 if parseErr != nil {
153- return "" , fmt .Errorf ("gemini failed: %w (parse error: %v)\n stderr: %s" , waitErr , parseErr , truncateStderr (stderr . String () ))
176+ return "" , stderrStr , fmt .Errorf ("gemini failed: %w (parse error: %v)\n stderr: %s" , waitErr , parseErr , truncateStderr (stderrStr ))
154177 }
155- return "" , fmt .Errorf ("gemini failed: %w\n stderr: %s" , waitErr , truncateStderr (stderr . String () ))
178+ return "" , stderrStr , fmt .Errorf ("gemini failed: %w\n stderr: %s" , waitErr , truncateStderr (stderrStr ))
156179 }
157180
158181 if ctxErr := contextProcessError (ctx , tracker , nil , parseErr ); ctxErr != nil {
159- return "" , ctxErr
182+ return "" , stderrStr , ctxErr
160183 }
161184
162185 if parseErr != nil {
163186 if errors .Is (parseErr , errNoStreamJSON ) {
164- return "" , fmt .Errorf ("gemini CLI must support --output-format stream-json; upgrade to latest version\n stderr: %s: %w" , truncateStderr (stderr . String () ), errNoStreamJSON )
187+ return "" , stderrStr , fmt .Errorf ("gemini CLI must support --output-format stream-json; upgrade to latest version\n stderr: %s: %w" , truncateStderr (stderrStr ), errNoStreamJSON )
165188 }
166- return "" , parseErr
189+ return "" , stderrStr , parseErr
167190 }
168191
169192 if parsed .result != "" {
170- return parsed .result , nil
193+ return parsed .result , stderrStr , nil
171194 }
172195
173- return "No review output generated" , nil
196+ return "No review output generated" , stderrStr , nil
197+ }
198+
199+ // isModelNotFoundError returns true if stderr indicates the requested
200+ // model does not exist. Google's API returns 404 with "model not found"
201+ // or "is not found" messages when a model name is invalid or retired.
202+ func isModelNotFoundError (stderr string ) bool {
203+ lower := strings .ToLower (stderr )
204+ return strings .Contains (lower , "model" ) &&
205+ (strings .Contains (lower , "not found" ) ||
206+ strings .Contains (lower , "is not found" ) ||
207+ strings .Contains (lower , "not_found" ))
174208}
175209
176210// geminiStreamMessage represents a message in Gemini's stream-json output format
0 commit comments