refactor: extract GEMINI/LLM into feature/llm (part of #778)#783
refactor: extract GEMINI/LLM into feature/llm (part of #778)#783apstndb wants to merge 4 commits into
Conversation
Move the GEMINI statement family out of core internal/mycli into internal/mycli/feature/llm through the Feature seam: the statement, the two-phase compose flow, the tool-use Developer Knowledge client, the doc cache (session-scoped, now a FeatureState holder closed via io.Closer), and the embedded doc catalog all move; core no longer imports google.golang.org/genai, genaischema, developerknowledge-go, or spanner-docs-embed. First use of the Flags/KongVars/ApplyFlags seam: the --vertexai-* flags move to Feature.Flags (kong.Plugins), the default-model/location help values to Feature.KongVars, and flag application routes through the registry via applyFeatureFlags in initializeSystemVariables - after the registry is available and before --set processing, preserving the pre-extraction precedence (flag < --set; unset pointer flags do not clobber TOML/--set). The CLI_VERTEXAI_* variables move to FeatureVars with verbatim descriptions and the same defaults; docs/system_variables.md is byte-identical. kong.Plugins renders plugin flags at the end of --help, so the three --vertexai-* lines move to the end of the flag list in the regenerated README readme-help block - the flag text itself is byte-identical, and in the slim variant these lines disappear entirely, so optional-feature flags listing last mirrors the statement-table layout. GeminiStatement keeps implementing no marker interfaces (not detached-compatible, not READONLY-classified), unchanged from core. Core test adjustments: the TOML nested-typo case now uses the [emulator] prefix (vertexai left core; the table name must be a non-flag prefix of real flags for the unknown-key rejection path); CLI_VERTEXAI_* Set/Get and enumeration/default coverage moves to the external llm_variables_test with the real llm feature registered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V28qKRVwUCkpT5pepTksgr
Code Metrics Report📊 View detailed coverage report (available for 7 days)
Details | | main (53bd54f) | #783 (36d63dc) | +/- |
|---------------------|----------------|----------------|-------|
- | Coverage | 73.2% | 73.1% | -0.1% |
| Files | 93 | 93 | 0 |
| Lines | 7933 | 7943 | +10 |
| Covered | 5808 | 5808 | 0 |
+ | Code to Test Ratio | 1:1.3 | 1:1.3 | +0.0 |
| Code | 18517 | 18568 | +51 |
+ | Test | 25146 | 25230 | +84 |
+ | Test Execution Time | 1m57s | 1m40s | -17s |Code coverage of files in pull request scope (78.3% → 77.9%)
Reported by octocov |
There was a problem hiding this comment.
Code Review
This pull request extracts the GEMINI/LLM feature from the core internal/mycli package into a dedicated optional feature package under internal/mycli/feature/llm. This migration moves the associated CLI flags, system variables, and the session-scoped documentation cache to the new package, updating registration and tests accordingly. The review feedback highlights three critical issues in the newly added llm.go file: a recommendation to implement retry-on-error semantics for the lazy initialization of the feature state to avoid permanently poisoning the session on transient failures, and two potential runtime panics due to missing defensive nil checks on the LLM response (composed and composed.Statement) and on the tool-use candidate content (result.Candidates[0].Content).
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if err != nil { | ||
| return nil, err | ||
| } | ||
| cache := holder.cache |
There was a problem hiding this comment.
Instead of adding defensive nil checks for the holder returned by mycli.FeatureState, implement retry-on-error semantics for the lazy initialization of the feature state. This ensures that transient initialization failures (such as a temporary network issue during client or cache creation) do not permanently poison the session, allowing subsequent requests to retry.
References
- Implement retry-on-error semantics instead of sticky error caching for lazy initialization of feature states (e.g., FeatureState). This ensures that transient initialization failures do not permanently poison the session.
| var rows []mycli.Row | ||
| if composed.ErrorDescription != "" { | ||
| rows = append(rows, mycli.NewRow("errorDescription", composed.ErrorDescription)) | ||
| } | ||
| rows = append(rows, | ||
| mycli.NewRow("text", composed.Statement.Text), | ||
| mycli.NewRow("semanticDescription", composed.Statement.SemanticDescription), | ||
| mycli.NewRow("syntaxDescription", composed.Statement.SyntaxDescription), | ||
| ) |
There was a problem hiding this comment.
The composed response or its nested Statement field can be nil if the LLM returns an unexpected response format or an error description. Accessing composed.Statement.Text directly without a nil check can lead to a runtime panic. Please add defensive nil checks for both composed and composed.Statement before accessing their properties.
if composed == nil {
return nil, fmt.Errorf("GEMINI returned no response")
}
if composed.Statement == nil {
if composed.ErrorDescription != "" {
return nil, fmt.Errorf("GEMINI error: %s", composed.ErrorDescription)
}
return nil, fmt.Errorf("GEMINI returned no statement")
}
var rows []mycli.Row
if composed.ErrorDescription != "" {
rows = append(rows, mycli.NewRow("errorDescription", composed.ErrorDescription))
}
rows = append(rows,
mycli.NewRow("text", composed.Statement.Text),
mycli.NewRow("semanticDescription", composed.Statement.SemanticDescription),
mycli.NewRow("syntaxDescription", composed.Statement.SyntaxDescription),
)| if len(result.Candidates) > 0 { | ||
| history = append(history, &genai.Content{ | ||
| Role: genai.RoleModel, | ||
| Parts: result.Candidates[0].Content.Parts, | ||
| }) | ||
| } |
There was a problem hiding this comment.
In the tool-use loop, result.Candidates[0].Content can be nil (for example, if the candidate was blocked or had other generation issues). Accessing result.Candidates[0].Content.Parts directly without a nil check can lead to a runtime panic. Please add a defensive nil check for result.Candidates[0].Content before accessing its parts.
| if len(result.Candidates) > 0 { | |
| history = append(history, &genai.Content{ | |
| Role: genai.RoleModel, | |
| Parts: result.Candidates[0].Content.Parts, | |
| }) | |
| } | |
| if len(result.Candidates) > 0 && result.Candidates[0].Content != nil { | |
| history = append(history, &genai.Content{ | |
| Role: genai.RoleModel, | |
| Parts: result.Candidates[0].Content.Parts, | |
| }) | |
| } |
Mechanism tests with a synthetic feature proving kong.Plugins flag structs participate in the TOML config resolver and CLI precedence exactly like core flags (scalar and pointer shapes, unset pointer stays nil). Empirically verified before adding: the resolver populates plugin structs, so the extracted --vertexai-* flags keep their TOML keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V28qKRVwUCkpT5pepTksgr
Adversarial-review follow-ups on #783: the pre-extraction GEMINI parse case was deleted without relocation - re-add it as a dispatch-level test over the merged def table asserting the unquoted prompt and that GeminiStatement keeps implementing no marker interfaces (not a static MutationStatement, not conditionally mutating, not detached-compatible). Correct the main_flags_test comment that overstated existing coverage (precedence is pinned generically in feature_flags_toml_test.go, llm variables in llm_variables_test.go) and the Feature.ApplyFlags doc that still said no feature uses the hook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V28qKRVwUCkpT5pepTksgr
|
Internal review results (orchestrator line review + independent adversarial review): both conclude SAFE TO MERGE. Verified under attack:
All four adversarial-review recommendations addressed in-review: merged origin/main (picks up the spannerplan v0.2.0 bump), relocated the dropped GEMINI dispatch test (asserts unquoted prompt + no marker interfaces on the dispatch-built statement), and fixed the two stale/overstated comments (main_flags_test.go coverage note, Feature.ApplyFlags doc). Provenance note for the reviewer: the implementing subagent was interrupted repeatedly; the final ~15% (two failing-test fixes, the disclosed README reposition, the seam mechanism tests, and these follow-ups) was completed by the orchestrator, so the independent adversarial pass and maintainer review carry extra weight on this PR. |
apstndb
left a comment
There was a problem hiding this comment.
REQUEST-CHANGES: one blocking nil-response path remains at current head 57bd0ca.
- [P2]
internal/mycli/feature/llm/llm.go:246:genaischema.GenerateObjectContent[*output]can return a nil*outputfor JSONnull, or anoutputwithStatement == nilwhen the structured model response omits/nullsstatement.Executethen dereferencescomposed.ErrorDescription,composed.Statement.Text, andcomposed.Statement.*while building rows andPreInput, so a malformed/partial model response can panic the CLI instead of returning a controlled error. Please guard bothcomposed == nilandcomposed.Statement == nilbefore dereferencing; ifErrorDescriptionis present without a statement, return/report that error without touchingStatement.
I checked the other unresolved retry-on-error thread: FeatureState already retries after init errors (TestFeatureStateRetriesAfterInitError), so I do not consider that one blocking. The tool-use Content nil thread is also not independently blocking from my read because FunctionCalls() returns nil when Content is nil and the loop breaks before appending result.Candidates[0].Content.Parts; adding an explicit nil guard there would still make the invariant clearer.
Validation:
GOCACHE=/private/tmp/spanner-mycli-pr783-review.k2aNN8/gocache go test ./internal/mycli -run '^(TestFeatureFlagsResolveFromTOML|TestFeatureFlagsCLIOverridesTOML|TestFeatureFlagsUnsetPointerStaysNil|TestGeminiStatementDispatch|TestLLMVariables|TestMainFlagsVertexAI|TestSystemVariablesVertexAI)'GOCACHE=/private/tmp/spanner-mycli-pr783-review.k2aNN8/gocache go list -deps ./internal/mycli | rg 'google.golang.org/genai|github.com/apstndb/genaischema|github.com/apstndb/developerknowledge-go|github.com/apstndb/spanner-docs-embed'
Part of #778 (PR3 of the extraction series, after #779/#780/#781/#782).
Summary
internal/mycli/feature/llm: the statement + two-phase compose flow, the tool-use Developer Knowledge client, the session-scoped doc cache (now aFeatureStateholder, keyllm.doccache, closed via io.Closer), and the embedded doc catalog.google.golang.org/genai,github.com/apstndb/genaischema,github.com/apstndb/developerknowledge-go, orgithub.com/apstndb/spanner-docs-embed(go list -deps ./internal/mycliverified clean of all four;golang.org/x/time/rateremains only as a transitive dep of the Spanner client, with no direct core importer).feature/allnow registers the final order (llm, cql, bigquery).First use of the Flags/KongVars/ApplyFlags seam
--vertexai-project/model/locationmove toFeature.Flags(kong.Plugins) with identical names/help/placeholders;Feature.KongVarssupplies the default-model/location help interpolation.applyFeatureFlagsruns ininitializeSystemVariablesafter the registry is available and before--setprocessing, so precedence is unchanged (flag <--set; unset pointer flags do not clobber TOML/--setvalues — Model/Location are pointer flags, Project mirrors the pre-extraction unconditional string assignment).docs/system_variables.mdis byte-identical.Disclosed one-time README diff
kong.Plugins renders plugin-contributed flags at the end of the
--helpflag list, so the three--vertexai-*lines move from mid-list to the end of the regenerated readme-help block. The flag text itself is byte-identical (this is the mechanical proof that the KongVars interpolation reproduces kong's output). In the slim variant these lines will disappear entirely, so optional-feature flags listing last mirrors the statement-table layout established in #780. No other docs change.Behavior preservation notes
GeminiStatementkeeps implementing no marker interfaces (not detached-compatible, not READONLY-classified) — unchanged from core.getOrCreateDocCache), env keysDEVELOPERKNOWLEDGE_API_KEY/GOOGLE_API_KEYread per Execute as before.[emulator]prefix (the table name must be a non-flag prefix of real flags —vertexaileft core); CLI_VERTEXAI_* enumeration/default/Set-Get coverage moves to the externalllm_variables_test.gowith the realllm.Feature()registered.Validation
make check,make check-race,go vet ./...: passmake docs-update: docs/system_variables.md byte-identical; README diff is exactly the 3-line flag reposition described above🤖 Generated with Claude Code
https://claude.ai/code/session_01V28qKRVwUCkpT5pepTksgr