diff --git a/.env.example b/.env.example index 1cfa39aca5..2a2f4fbffd 100644 --- a/.env.example +++ b/.env.example @@ -619,6 +619,19 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review # # BYOK (anthropic/openai) key; this gives self-host a local option. # # Unset = visual-vision falls back to BYOK-only. # AI_VISION_API_KEY= # bearer credential for AI_VISION_BASE_URL, if it requires one. +# AI_ADVISORY_MODEL=qwen3:8b # local-inference model for advisory-only capabilities (#4364): +# # slop advisory, e2e test-gen, issue planner, AI summaries -- none +# # are gate-blocking, so none need frontier-model accuracy. Only +# # used when AI_ADVISORY_BASE_URL is also set. +# AI_ADVISORY_BASE_URL= # route the four advisory-only capabilities above to a SEPARATE +# # openai-compatible endpoint (e.g. a local Ollama) instead of the +# # shared frontier claude-code/codex chain. Unset = every advisory +# # capability stays on the shared chain. Even when set, a +# # capability only actually routes through it once the repo's +# # .gittensory.yml settings.advisoryAiRouting also opts it in +# # (global default + per-repo override) -- this binding alone +# # changes nothing. +# AI_ADVISORY_API_KEY= # bearer credential for AI_ADVISORY_BASE_URL, if it requires one. # --- Gittensory Orb (#1255; ALWAYS-ON fleet-calibration telemetry) --- # TELEMETRY NOTICE: running this self-hosted image contributes anonymized gate-calibration data to diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 155867777e..902ac43096 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -869,6 +869,18 @@ settings: # action: close # close is the only supported value. Default: close. # message: "Custom close reason..." # Default: null (built-in message). + # Advisory-AI routing (#4364): per-capability opt-in to the local-inference AI_ADVISORY binding for these + # four capabilities, none of which are ever gate-blocking, so none need frontier-model accuracy. Set a + # global default here (or in the shared/root config) and override per-repo; a capability only actually + # routes through AI_ADVISORY when BOTH this is true AND the AI_ADVISORY_BASE_URL binding is configured -- + # absent either one, it stays on the shared frontier env.AI chain (byte-identical to before). All off by + # default. + # advisoryAiRouting: + # slop: false # gate.slop.aiAdvisory's second opinion. Default: false. + # e2eTestGen: false # Auto-generated E2E test scaffolding. Default: false. + # planner: false # The @gittensory plan / issue-planner completion. Default: false. + # summaries: false # AI summaries/rewrite text. Default: false. + # Maintainer AI review tuning (`.gittensory.yml` top-level `review:` block). These knobs shape the advisory AI # review prompt and file selection only — gate/slop/secret-scan are unaffected. # review: diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index efb1b52ef2..1f5d994cb2 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -474,6 +474,164 @@ "byProject" ] }, + "PublicQualityMetrics": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "gate": { + "type": "object", + "properties": { + "blocked": { + "type": "number" + }, + "blockedThenMerged": { + "type": "number" + }, + "falsePositiveRate": { + "type": "number", + "nullable": true + }, + "precisionPct": { + "type": "number", + "nullable": true + }, + "topGateTypes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "gateType": { + "type": "string" + }, + "blocked": { + "type": "number" + }, + "blockedThenMerged": { + "type": "number" + }, + "falsePositiveRate": { + "type": "number", + "nullable": true + }, + "precisionPct": { + "type": "number", + "nullable": true + } + }, + "required": [ + "gateType", + "blocked", + "blockedThenMerged", + "falsePositiveRate", + "precisionPct" + ] + } + } + }, + "required": [ + "blocked", + "blockedThenMerged", + "falsePositiveRate", + "precisionPct", + "topGateTypes" + ] + }, + "outcomes": { + "type": "object", + "properties": { + "merged": { + "type": "number" + }, + "closed": { + "type": "number" + }, + "mergeRatioPct": { + "type": "number", + "nullable": true + } + }, + "required": [ + "merged", + "closed", + "mergeRatioPct" + ] + }, + "slop": { + "type": "object", + "properties": { + "totalResolved": { + "type": "number" + }, + "overallMergeRate": { + "type": "number", + "nullable": true + }, + "discriminates": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "totalResolved", + "overallMergeRate", + "discriminates" + ] + }, + "trend": { + "type": "array", + "items": { + "type": "object", + "properties": { + "weekStart": { + "type": "string" + }, + "gateBlocked": { + "type": "number" + }, + "gateBlockedThenMerged": { + "type": "number" + }, + "gateFalsePositiveRate": { + "type": "number", + "nullable": true + }, + "outcomesMerged": { + "type": "number" + }, + "outcomesClosed": { + "type": "number" + }, + "mergeRatioPct": { + "type": "number", + "nullable": true + } + }, + "required": [ + "weekStart", + "gateBlocked", + "gateBlockedThenMerged", + "gateFalsePositiveRate", + "outcomesMerged", + "outcomesClosed", + "mergeRatioPct" + ] + } + } + }, + "required": [ + "repoFullName", + "generatedAt", + "gate", + "outcomes", + "slop", + "trend" + ] + }, "Advisory": { "type": "object", "properties": { @@ -8497,6 +8655,13 @@ "enabled" ] }, + "regateSweepOrderMode": { + "type": "string", + "enum": [ + "staleness", + "oldest-first" + ] + }, "reviewCheckMode": { "type": "string", "enum": [ @@ -8605,6 +8770,19 @@ "type": "string" } }, + "copycatGateMode": { + "type": "string", + "enum": [ + "off", + "warn", + "label", + "block" + ] + }, + "copycatGateMinScore": { + "type": "number", + "nullable": true + }, "gateDryRun": { "type": "boolean" }, @@ -8641,6 +8819,14 @@ "block" ] }, + "linkedIssueSatisfactionGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, "firstTimeContributorGrace": { "type": "boolean" }, @@ -8867,6 +9053,29 @@ "minConfidence" ] }, + "advisoryAiRouting": { + "type": "object", + "properties": { + "slop": { + "type": "boolean" + }, + "e2eTestGen": { + "type": "boolean" + }, + "planner": { + "type": "boolean" + }, + "summaries": { + "type": "boolean" + } + }, + "required": [ + "slop", + "e2eTestGen", + "planner", + "summaries" + ] + }, "gittensorLabel": { "type": "string" }, @@ -8898,6 +9107,9 @@ "badgeEnabled": { "type": "boolean" }, + "publicQualityMetrics": { + "type": "boolean" + }, "commandAuthorization": { "type": "object", "properties": { @@ -9236,13 +9448,13 @@ "reviewEvasionComment": { "type": "boolean" }, - "createdAt": { - "type": "string", - "nullable": true - }, - "updatedAt": { + "mergeTrainMode": { "type": "string", - "nullable": true + "enum": [ + "off", + "audit", + "enforce" + ] }, "screenshotTableGate": { "type": "object", @@ -9279,43 +9491,12 @@ "action" ] }, - "regateSweepOrderMode": { - "type": "string", - "enum": [ - "staleness", - "oldest-first" - ] - }, - "publicQualityMetrics": { - "type": "boolean" - }, - "mergeTrainMode": { - "type": "string", - "enum": [ - "off", - "audit", - "enforce" - ] - }, - "linkedIssueSatisfactionGateMode": { + "createdAt": { "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] + "nullable": true }, - "copycatGateMode": { + "updatedAt": { "type": "string", - "enum": [ - "off", - "warn", - "label", - "block" - ] - }, - "copycatGateMinScore": { - "type": "number", "nullable": true } }, @@ -9846,11 +10027,18 @@ "enabled" ] }, - "reviewCheckMode": { + "regateSweepOrderMode": { "type": "string", "enum": [ - "required", - "visible", + "staleness", + "oldest-first" + ] + }, + "reviewCheckMode": { + "type": "string", + "enum": [ + "required", + "visible", "disabled" ] }, @@ -9936,6 +10124,14 @@ "block" ] }, + "linkedIssueSatisfactionGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, "firstTimeContributorGrace": { "type": "boolean" }, @@ -9967,6 +10163,9 @@ "badgeEnabled": { "type": "boolean" }, + "publicQualityMetrics": { + "type": "boolean" + }, "aiReviewMode": { "type": "string", "enum": [ @@ -10036,24 +10235,6 @@ "defaultAllowed", "commandOverrides" ] - }, - "regateSweepOrderMode": { - "type": "string", - "enum": [ - "staleness", - "oldest-first" - ] - }, - "publicQualityMetrics": { - "type": "boolean" - }, - "linkedIssueSatisfactionGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] } }, "required": [ @@ -13514,164 +13695,6 @@ "maintainerNextSteps", "privateSummary" ] - }, - "PublicQualityMetrics": { - "type": "object", - "properties": { - "repoFullName": { - "type": "string" - }, - "generatedAt": { - "type": "string" - }, - "gate": { - "type": "object", - "properties": { - "blocked": { - "type": "number" - }, - "blockedThenMerged": { - "type": "number" - }, - "falsePositiveRate": { - "type": "number", - "nullable": true - }, - "precisionPct": { - "type": "number", - "nullable": true - }, - "topGateTypes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "gateType": { - "type": "string" - }, - "blocked": { - "type": "number" - }, - "blockedThenMerged": { - "type": "number" - }, - "falsePositiveRate": { - "type": "number", - "nullable": true - }, - "precisionPct": { - "type": "number", - "nullable": true - } - }, - "required": [ - "gateType", - "blocked", - "blockedThenMerged", - "falsePositiveRate", - "precisionPct" - ] - } - } - }, - "required": [ - "blocked", - "blockedThenMerged", - "falsePositiveRate", - "precisionPct", - "topGateTypes" - ] - }, - "outcomes": { - "type": "object", - "properties": { - "merged": { - "type": "number" - }, - "closed": { - "type": "number" - }, - "mergeRatioPct": { - "type": "number", - "nullable": true - } - }, - "required": [ - "merged", - "closed", - "mergeRatioPct" - ] - }, - "slop": { - "type": "object", - "properties": { - "totalResolved": { - "type": "number" - }, - "overallMergeRate": { - "type": "number", - "nullable": true - }, - "discriminates": { - "type": "boolean", - "nullable": true - } - }, - "required": [ - "totalResolved", - "overallMergeRate", - "discriminates" - ] - }, - "trend": { - "type": "array", - "items": { - "type": "object", - "properties": { - "weekStart": { - "type": "string" - }, - "gateBlocked": { - "type": "number" - }, - "gateBlockedThenMerged": { - "type": "number" - }, - "gateFalsePositiveRate": { - "type": "number", - "nullable": true - }, - "outcomesMerged": { - "type": "number" - }, - "outcomesClosed": { - "type": "number" - }, - "mergeRatioPct": { - "type": "number", - "nullable": true - } - }, - "required": [ - "weekStart", - "gateBlocked", - "gateBlockedThenMerged", - "gateFalsePositiveRate", - "outcomesMerged", - "outcomesClosed", - "mergeRatioPct" - ] - } - } - }, - "required": [ - "repoFullName", - "generatedAt", - "gate", - "outcomes", - "slop", - "trend" - ] } }, "parameters": {}, @@ -13784,6 +13807,46 @@ } } }, + "/v1/public/repos/{owner}/{repo}/quality": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Public per-repo review-quality metrics: gate false-positive rates, merge-vs-close ratio, and weekly trend. Aggregate counts only; opt-in via publicQualityMetrics.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicQualityMetrics" + } + } + } + }, + "404": { + "description": "Repo is unknown/private/uninstalled or has not opted in" + }, + "503": { + "description": "Public quality metrics are temporarily unavailable" + } + } + } + }, "/v1/registry/snapshot": { "get": { "responses": { @@ -16603,11 +16666,14 @@ ] } }, - "/v1/internal/jobs/refresh-scoring-model": { + "/v1/internal/jobs/generate-review-recap": { "post": { "responses": { "202": { - "description": "Internal job queued" + "description": "Maintainer review recap digest queued (#1963)" + }, + "400": { + "description": "Missing repoFullName" }, "401": { "description": "Invalid internal token" @@ -16623,7 +16689,7 @@ ] } }, - "/v1/internal/jobs/refresh-upstream-drift": { + "/v1/internal/jobs/refresh-scoring-model": { "post": { "responses": { "202": { @@ -16643,7 +16709,7 @@ ] } }, - "/v1/internal/jobs/file-upstream-drift-issues": { + "/v1/internal/jobs/refresh-upstream-drift": { "post": { "responses": { "202": { @@ -16663,7 +16729,7 @@ ] } }, - "/v1/internal/jobs/build-contributor-evidence": { + "/v1/internal/jobs/file-upstream-drift-issues": { "post": { "responses": { "202": { @@ -16683,7 +16749,7 @@ ] } }, - "/v1/internal/jobs/build-contributor-decision-packs": { + "/v1/internal/jobs/build-contributor-evidence": { "post": { "responses": { "202": { @@ -16703,7 +16769,7 @@ ] } }, - "/v1/internal/jobs/build-burden-forecasts": { + "/v1/internal/jobs/build-contributor-decision-packs": { "post": { "responses": { "202": { @@ -16723,7 +16789,7 @@ ] } }, - "/v1/internal/jobs/generate-signal-snapshots": { + "/v1/internal/jobs/build-burden-forecasts": { "post": { "responses": { "202": { @@ -16743,7 +16809,7 @@ ] } }, - "/v1/internal/jobs/generate-weekly-value-report": { + "/v1/internal/jobs/generate-signal-snapshots": { "post": { "responses": { "202": { @@ -16763,7 +16829,7 @@ ] } }, - "/v1/internal/jobs/repair-data-fidelity": { + "/v1/internal/jobs/generate-weekly-value-report": { "post": { "responses": { "202": { @@ -16783,11 +16849,11 @@ ] } }, - "/v1/internal/bounties/import": { + "/v1/internal/jobs/repair-data-fidelity": { "post": { "responses": { - "200": { - "description": "Bounty snapshot imported" + "202": { + "description": "Internal job queued" }, "401": { "description": "Invalid internal token" @@ -16803,14 +16869,11 @@ ] } }, - "/v1/internal/jobs/generate-review-recap": { + "/v1/internal/bounties/import": { "post": { "responses": { - "202": { - "description": "Maintainer review recap digest queued (#1963)" - }, - "400": { - "description": "Missing repoFullName" + "200": { + "description": "Bounty snapshot imported" }, "401": { "description": "Invalid internal token" @@ -16825,46 +16888,6 @@ } ] } - }, - "/v1/public/repos/{owner}/{repo}/quality": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Public per-repo review-quality metrics: gate false-positive rates, merge-vs-close ratio, and weekly trend. Aggregate counts only; opt-in via publicQualityMetrics.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicQualityMetrics" - } - } - } - }, - "404": { - "description": "Repo is unknown/private/uninstalled or has not opted in" - }, - "503": { - "description": "Public quality metrics are temporarily unavailable" - } - } - } } }, "servers": [ diff --git a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts index 953cc6600c..669c05a41b 100644 --- a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts +++ b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts @@ -5,6 +5,22 @@ export type SelfHostEnvReferenceRow = { }; export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ + { + name: "AI_ADVISORY", + firstReference: "src/selfhost/ai.ts", + }, + { + name: "AI_ADVISORY_API_KEY", + firstReference: "src/server.ts", + }, + { + name: "AI_ADVISORY_BASE_URL", + firstReference: "src/server.ts", + }, + { + name: "AI_ADVISORY_MODEL", + firstReference: "src/server.ts", + }, { name: "AI_COMBINE", firstReference: "src/selfhost/ai.ts", @@ -446,6 +462,10 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| Name | First reference |", "| --- | --- |", + "| `AI_ADVISORY` | `src/selfhost/ai.ts` |", + "| `AI_ADVISORY_API_KEY` | `src/server.ts` |", + "| `AI_ADVISORY_BASE_URL` | `src/server.ts` |", + "| `AI_ADVISORY_MODEL` | `src/server.ts` |", "| `AI_COMBINE` | `src/selfhost/ai.ts` |", "| `AI_DUAL_REVIEW` | `src/selfhost/ai.ts` |", "| `AI_EMBED_API_KEY` | `src/server.ts` |", diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index da732e84eb..6b1fe56bc5 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -882,6 +882,18 @@ settings: # action: close # close is the only supported value. Default: close. # message: "Custom close reason..." # Default: null (built-in message). + # Advisory-AI routing (#4364): per-capability opt-in to the local-inference AI_ADVISORY binding for these + # four capabilities, none of which are ever gate-blocking, so none need frontier-model accuracy. Set a + # global default here (or in the shared/root config) and override per-repo; a capability only actually + # routes through AI_ADVISORY when BOTH this is true AND the AI_ADVISORY_BASE_URL binding is configured -- + # absent either one, it stays on the shared frontier env.AI chain (byte-identical to before). All off by + # default. + # advisoryAiRouting: + # slop: false # gate.slop.aiAdvisory's second opinion. Default: false. + # e2eTestGen: false # Auto-generated E2E test scaffolding. Default: false. + # planner: false # The @gittensory plan / issue-planner completion. Default: false. + # summaries: false # AI summaries/rewrite text. Default: false. + # Maintainer AI review tuning (`.gittensory.yml` top-level `review:` block). These knobs shape the advisory AI # review prompt and file selection only — gate/slop/secret-scan are unaffected. # review: diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index 8d94069faa..19f3f9a932 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -9,6 +9,7 @@ */ import { parse as parseYaml } from "yaml"; import type { + AdvisoryAiRoutingConfig, CombineStrategy, GatePolicyPack, GateRuleMode, @@ -42,6 +43,7 @@ import { isUnlinkedIssueGuardrailMode, normalizeUnlinkedIssueGuardrailConfig, } from "./review/unlinked-issue-guardrail-config.js"; +import { normalizeAdvisoryAiRoutingConfig } from "./review/advisory-ai-routing-config.js"; import { DEFAULT_SCREENSHOT_TABLE_GATE, isScreenshotTableGateAction, @@ -385,6 +387,9 @@ export type FocusManifestSettings = Partial< // unlinkedIssueGuardrail above -- a manifest naming only `enabled` must not silently reset `whenLabels`/ // `whenPaths`/`action`/`message` back to their defaults. screenshotTableGate?: Partial | undefined; + // Advisory-AI routing (#4364): same sparse-partial merge reasoning -- a manifest naming only `slop` must + // not silently reset `e2eTestGen`/`planner`/`summaries` back to their (false) defaults. + advisoryAiRouting?: Partial | undefined; }; /** Field keys for the public review-panel rows a maintainer can show/hide via `review.fields`. */ @@ -1798,6 +1803,20 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) } else if (r.screenshotTableGate !== undefined) { warnings.push(`Manifest "settings.screenshotTableGate" must be an object; ignoring it and keeping any existing policy.`); } + // Advisory-AI routing (#4364): same sparse-partial overlay contract as screenshotTableGate above -- a repo + // naming only `slop` must not silently reset `e2eTestGen`/`planner`/`summaries` back to their defaults. + if (typeof r.advisoryAiRouting === "object" && r.advisoryAiRouting !== null && !Array.isArray(r.advisoryAiRouting)) { + const rawRouting = r.advisoryAiRouting as Record; + const validated = normalizeAdvisoryAiRoutingConfig(rawRouting, warnings); + const sparseRouting: Partial = {}; + if (typeof rawRouting.slop === "boolean") sparseRouting.slop = validated.slop; + if (typeof rawRouting.e2eTestGen === "boolean") sparseRouting.e2eTestGen = validated.e2eTestGen; + if (typeof rawRouting.planner === "boolean") sparseRouting.planner = validated.planner; + if (typeof rawRouting.summaries === "boolean") sparseRouting.summaries = validated.summaries; + out.advisoryAiRouting = sparseRouting; + } else if (r.advisoryAiRouting !== undefined) { + warnings.push(`Manifest "settings.advisoryAiRouting" must be an object; ignoring it and keeping any existing policy.`); + } // Contributor blacklist (#1425): `settings.contributorBlacklist` is a list of banned-login entries. Only set it // when at least one VALID entry survives normalization, so a malformed block never blanks the DB-configured // list via the resolver's `{...dbSettings, ...manifest.settings}` overlay. Normalization warnings are folded in. diff --git a/packages/gittensory-engine/src/review/advisory-ai-routing-config.ts b/packages/gittensory-engine/src/review/advisory-ai-routing-config.ts new file mode 100644 index 0000000000..fb4bf7aea1 --- /dev/null +++ b/packages/gittensory-engine/src/review/advisory-ai-routing-config.ts @@ -0,0 +1,35 @@ +import type { AdvisoryAiRoutingConfig } from "../types/manifest-deps-types.js"; + +export const DEFAULT_ADVISORY_AI_ROUTING: AdvisoryAiRoutingConfig = { + slop: false, + e2eTestGen: false, + planner: false, + summaries: false, +}; + +function normalizeField(value: unknown, field: keyof AdvisoryAiRoutingConfig, warnings: string[]): boolean { + if (value === undefined) return DEFAULT_ADVISORY_AI_ROUTING[field]; + if (typeof value === "boolean") return value; + warnings.push(`settings.advisoryAiRouting.${field} must be a boolean; using the default "${DEFAULT_ADVISORY_AI_ROUTING[field]}".`); + return DEFAULT_ADVISORY_AI_ROUTING[field]; +} + +/** + * Normalize a raw `.gittensory.yml settings.advisoryAiRouting` value into a typed config, fail-safe: any + * malformed field falls back to its own (false) default and pushes a warning rather than rejecting the + * whole block. Mirrors `normalizeUnlinkedIssueGuardrailConfig`'s per-field discipline. + */ +export function normalizeAdvisoryAiRoutingConfig(input: unknown, warnings: string[]): AdvisoryAiRoutingConfig { + if (input === undefined) return { ...DEFAULT_ADVISORY_AI_ROUTING }; + if (typeof input !== "object" || input === null || Array.isArray(input)) { + warnings.push("settings.advisoryAiRouting must be an object; using the default (every capability off)."); + return { ...DEFAULT_ADVISORY_AI_ROUTING }; + } + const record = input as Record; + return { + slop: normalizeField(record.slop, "slop", warnings), + e2eTestGen: normalizeField(record.e2eTestGen, "e2eTestGen", warnings), + planner: normalizeField(record.planner, "planner", warnings), + summaries: normalizeField(record.summaries, "summaries", warnings), + }; +} diff --git a/packages/gittensory-engine/src/types/manifest-deps-types.ts b/packages/gittensory-engine/src/types/manifest-deps-types.ts index 0195be3fda..f6bcaf577a 100644 --- a/packages/gittensory-engine/src/types/manifest-deps-types.ts +++ b/packages/gittensory-engine/src/types/manifest-deps-types.ts @@ -82,6 +82,21 @@ export type UnlinkedIssueGuardrailConfig = { minConfidence: number; }; +/** Per-capability opt-in to the local-inference AI_ADVISORY binding (#4364): each of these four ADVISORY-ONLY + * (never gate-blocking) capabilities independently decides whether it routes through env.AI_ADVISORY (when + * configured) instead of the shared frontier env.AI chain. Config-as-code only -- no DB column, resolved + * purely from `.gittensory.yml` `settings.advisoryAiRouting` (global default in the shared/root manifest, + * per-repo override), the same "config-as-code only" shape as unlinkedIssueGuardrail above. Every field + * defaults to false: an operator must deliberately opt EACH capability in, and even then a repo only + * actually routes through AI_ADVISORY when the binding itself is configured (env.AI_ADVISORY unset ⇒ every + * capability stays on env.AI regardless of this config, byte-identical to before this existed). */ +export type AdvisoryAiRoutingConfig = { + slop: boolean; + e2eTestGen: boolean; + planner: boolean; + summaries: boolean; +}; + export type ContributorBlacklistEntry = { login: string; /** Why the account is blocked. Free-text maintainer metadata; not published in automated close comments. */ @@ -315,6 +330,11 @@ export type RepositorySettings = { * `.gittensory.yml settings.unlinkedIssueGuardrail` in private/global or per-repo config. Defaults * all-off so a self-hoster opts into their own credibility-gate-farming defense. */ unlinkedIssueGuardrail?: UnlinkedIssueGuardrailConfig | undefined; + /** Per-capability local-inference routing (#4364). Config-as-code only; set with `.gittensory.yml + * settings.advisoryAiRouting` in shared/global or per-repo config (global default + per-repo override, + * the same deep-merge precedence every other settings field uses). Defaults all-false so every advisory + * capability stays on the shared frontier env.AI chain until an operator opts each one in. */ + advisoryAiRouting?: AdvisoryAiRoutingConfig | undefined; publicSurface: "off" | "comment_and_label" | "comment_only" | "label_only"; includeMaintainerAuthors: boolean; requireLinkedIssue: boolean; diff --git a/src/env.d.ts b/src/env.d.ts index beda0df325..81ca26b400 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -16,6 +16,13 @@ declare global { * model). Built at boot from AI_VISION_BASE_URL/AI_VISION_MODEL. Absent ⇒ visual-vision advisory falls * back to requiring a maintainer BYOK key (the only option before this binding existed). */ AI_VISION?: Ai; + /** Self-host (advisory-tier, #4364): a DEDICATED local-inference provider shared by every capability that is + * NEVER gate-blocking (slop advisory, e2e test-gen, issue planner, AI summaries) — separate from the review + * chain, the embed provider, and the vision provider, since none of these need frontier-model accuracy. + * Built at boot from AI_ADVISORY_BASE_URL/AI_ADVISORY_MODEL. Which capability actually routes through it is + * config-driven (`.gittensory.yml`, global default + per-repo override), not hardcoded here. Absent ⇒ every + * advisory capability falls back to `env.AI` (byte-identical to before this binding existed). */ + AI_ADVISORY?: Ai; /** Self-host RAG vector adapter. Cloudflare no longer binds Vectorize for hosted reviews; the Node runtime * injects Qdrant/sqlite/pg adapters here when configured. Absent ⇒ no RAG, review proceeds with no retrieved * context. */ diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index d9715803ca..dca25d0de2 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -714,6 +714,14 @@ export const RepositorySettingsSchema = z minConfidence: z.number().min(0).max(1), }) .optional(), + advisoryAiRouting: z + .object({ + slop: z.boolean(), + e2eTestGen: z.boolean(), + planner: z.boolean(), + summaries: z.boolean(), + }) + .optional(), gittensorLabel: z.string(), blacklistLabel: z.string().nullable(), createMissingLabel: z.boolean(), diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 3f91c38da8..22672b8023 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -390,6 +390,7 @@ import { VISUAL_VISION_SYSTEM_PROMPT, } from "../review/visual/visual-findings"; import { incr } from "../selfhost/metrics"; +import { withAdvisoryAiEnv } from "../selfhost/ai"; import { renderReviewingPlaceholder, shouldPostReviewingPlaceholder, @@ -7738,7 +7739,7 @@ export async function runAiSlopForAdvisory( /* v8 ignore next -- reached only past this function's own `!args.advisory.headSha` early return, so headSha is always truthy here; the `?? null` is a type-level fallback for an unreachable branch. */ metadata: { repoFullName: args.repoFullName, headSha: args.advisory.headSha ?? null }, }).catch(() => undefined); - result = await runGittensoryAiSlopAdvisory(env, { + result = await runGittensoryAiSlopAdvisory(withAdvisoryAiEnv(env, args.settings.advisoryAiRouting?.slop === true), { repoFullName: args.repoFullName, prNumber: args.pr.number, title: args.pr.title, @@ -11623,7 +11624,7 @@ async function runE2eTestGenerationAndDeliver( storedKey && (!args.settings.aiReviewProvider || args.settings.aiReviewProvider === storedKey.provider) ? { provider: storedKey.provider, key: storedKey.key, model: args.settings.aiReviewModel ?? storedKey.model } : null; - const result = await runGittensoryE2eTestGeneration(env, { + const result = await runGittensoryE2eTestGeneration(withAdvisoryAiEnv(env, args.settings.advisoryAiRouting?.e2eTestGen === true), { repoFullName: args.repoFullName, prNumber: args.pr.number, title: args.pr.title, @@ -11915,7 +11916,7 @@ async function maybeProcessPlanCommand( return true; } const plan = await generateIssuePlan( - env, + withAdvisoryAiEnv(env, settings.advisoryAiRouting?.planner === true), { title: req.issue.title, body: req.issue.body }, { actor: req.actor, diff --git a/src/review/advisory-ai-routing-config.ts b/src/review/advisory-ai-routing-config.ts new file mode 100644 index 0000000000..b63dbe356c --- /dev/null +++ b/src/review/advisory-ai-routing-config.ts @@ -0,0 +1,35 @@ +import type { AdvisoryAiRoutingConfig } from "../types"; + +export const DEFAULT_ADVISORY_AI_ROUTING: AdvisoryAiRoutingConfig = { + slop: false, + e2eTestGen: false, + planner: false, + summaries: false, +}; + +function normalizeField(value: unknown, field: keyof AdvisoryAiRoutingConfig, warnings: string[]): boolean { + if (value === undefined) return DEFAULT_ADVISORY_AI_ROUTING[field]; + if (typeof value === "boolean") return value; + warnings.push(`settings.advisoryAiRouting.${field} must be a boolean; using the default "${DEFAULT_ADVISORY_AI_ROUTING[field]}".`); + return DEFAULT_ADVISORY_AI_ROUTING[field]; +} + +/** + * Normalize a raw `.gittensory.yml settings.advisoryAiRouting` value into a typed config, fail-safe: any + * malformed field falls back to its own (false) default and pushes a warning rather than rejecting the + * whole block. Mirrors `normalizeUnlinkedIssueGuardrailConfig`'s per-field discipline. + */ +export function normalizeAdvisoryAiRoutingConfig(input: unknown, warnings: string[]): AdvisoryAiRoutingConfig { + if (input === undefined) return { ...DEFAULT_ADVISORY_AI_ROUTING }; + if (typeof input !== "object" || input === null || Array.isArray(input)) { + warnings.push("settings.advisoryAiRouting must be an object; using the default (every capability off)."); + return { ...DEFAULT_ADVISORY_AI_ROUTING }; + } + const record = input as Record; + return { + slop: normalizeField(record.slop, "slop", warnings), + e2eTestGen: normalizeField(record.e2eTestGen, "e2eTestGen", warnings), + planner: normalizeField(record.planner, "planner", warnings), + summaries: normalizeField(record.summaries, "summaries", warnings), + }; +} diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 039027176e..8e3b6aea76 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -1278,3 +1278,16 @@ export function resolveAiReviewerPlan( const onMerge = ON_MERGE_RULES.has(rawOnMerge) ? rawOnMerge : undefined; return { reviewers: names.slice(0, 2).map((model) => ({ model })), combine, onMerge }; } + +/** + * Advisory-AI routing (#4364): return an `env` view whose `.AI` binding is `env.AI_ADVISORY` instead of the + * shared frontier chain, for a single ADVISORY-ONLY capability (slop, e2e test-gen, planner, summaries) that + * opted in via `settings.advisoryAiRouting`. A shallow spread — every other `env` field (including + * `AI_ADVISORY` itself, `AI_GATEWAY_ID`, the enablement flags) is untouched, so the capability's own + * fail-safe checks keep working unmodified. Falls back to the real `env` (unchanged `.AI`) whenever the + * capability didn't opt in OR the binding itself is unconfigured — byte-identical to before this existed in + * either case, exactly like `AI_EMBED`/`AI_VISION`'s own "absent ⇒ falls back" contract. + */ +export function withAdvisoryAiEnv(env: Env, useAdvisory: boolean): Env { + return useAdvisory && env.AI_ADVISORY ? { ...env, AI: env.AI_ADVISORY } : env; +} diff --git a/src/server.ts b/src/server.ts index 0d3ff97909..934f35190c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -508,6 +508,28 @@ async function main(): Promise { model: process.env.AI_VISION_MODEL, }), ); + // Dedicated advisory-tier provider (#4364): several capabilities (slop advisory, e2e test-gen, issue + // planner, AI summaries) are NEVER gate-blocking and share the review chain's frontier-only env.AI today + // purely because no cheaper alternative existed -- unlike AI_EMBED/AI_VISION, which each back a single + // narrow capability, this one binding is shared across all four, gated per-capability by + // `.gittensory.yml` (global default + per-repo override, see focus-manifest.ts) so routing stays + // config-driven, not hardcoded. Unset ⇒ absent ⇒ every advisory capability falls back to env.AI, byte- + // identical to before this binding existed. + const advisoryAi = process.env.AI_ADVISORY_BASE_URL + ? createOpenAiCompatibleAi({ + baseUrl: process.env.AI_ADVISORY_BASE_URL, + apiKey: process.env.AI_ADVISORY_API_KEY ?? process.env.OPENAI_API_KEY, + model: process.env.AI_ADVISORY_MODEL, + }) + : undefined; + if (advisoryAi) + console.log( + JSON.stringify({ + event: "selfhost_advisory_provider", + baseUrl: process.env.AI_ADVISORY_BASE_URL, + model: process.env.AI_ADVISORY_MODEL, + }), + ); // Dual-review plan (#dual-ai-combiner): resolve which provider(s) review + how to combine, attached to env // below so the review call site uses it. Undefined for a single provider's default review or no AI. const aiReviewPlan = resolveAiReviewerPlan(process.env); @@ -615,6 +637,7 @@ async function main(): Promise { AI: ai, ...(embedAi ? { AI_EMBED: embedAi as unknown as Ai } : {}), ...(visionAi ? { AI_VISION: visionAi as unknown as Ai } : {}), + ...(advisoryAi ? { AI_ADVISORY: advisoryAi as unknown as Ai } : {}), ...(aiReviewPlan ? { AI_REVIEW_PLAN: aiReviewPlan } : {}), SELFHOST_TRANSIENT_CACHE: webhookCache, // Qdrant takes priority; falls back to the backend's built-in vectorize (pgvector or sqlite-vec) diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 0afbc0d37a..69a8cf05aa 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -29,6 +29,8 @@ import { buildContributorFit, buildContributorOutcomeHistory, buildContributorPr import { buildContributorOpenPrMonitor, type ContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest, type LocalBranchAnalysis, type LocalBranchAnalysisInput } from "../signals/local-branch"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; +import { resolveRepositorySettings } from "../settings/repository-settings"; +import { withAdvisoryAiEnv } from "../selfhost/ai"; import { withAgentActionExplanationCard } from "./agent-action-explanation-card"; import { attachRecommendationSnapshots } from "./recommendation-snapshots"; import type { @@ -223,7 +225,12 @@ export async function executeAgentRun(env: Env, runId: string): Promise { - const summary = await summarizeAgentBundleWithAi(env, bundle, "private"); + // Advisory-AI routing (#4364): this summary is never gate-blocking, so it's a routing candidate like + // slop/e2e-test-gen/planner. repoFullName can be absent for a cross-repo run (e.g. plan_next_work) -- + // falls back to the plain env (byte-identical) rather than resolving settings for an empty key. + const repoFullName = String(bundle.run.payload.repoFullName ?? ""); + const routeThroughAdvisory = repoFullName ? (await resolveRepositorySettings(env, repoFullName)).advisoryAiRouting?.summaries === true : false; + const summary = await summarizeAgentBundleWithAi(withAdvisoryAiEnv(env, routeThroughAdvisory), bundle, "private"); if (summary.status === "disabled" || summary.status === "unavailable") return bundle; await updateAgentRun(env, bundle.run.id, { payload: { diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 55d3147522..9e2f12a157 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -76,6 +76,7 @@ import { DEFAULT_TYPE_LABELS } from "../settings/pr-type-label"; import { DEFAULT_LINKED_ISSUE_LABEL_PROPAGATION } from "../review/linked-issue-label-propagation"; import { DEFAULT_LINKED_ISSUE_HARD_RULES } from "../review/linked-issue-hard-rules-config"; import { DEFAULT_UNLINKED_ISSUE_GUARDRAIL } from "../review/unlinked-issue-guardrail-config"; +import { DEFAULT_ADVISORY_AI_ROUTING } from "../review/advisory-ai-routing-config"; import { DEFAULT_SCREENSHOT_TABLE_GATE } from "../review/screenshot-table-gate"; import { classifyChangedFile } from "./path-matchers"; import { @@ -501,6 +502,7 @@ export function resolveEffectiveSettings( linkedIssueHardRules: linkedIssueHardRulesOverride, unlinkedIssueGuardrail: unlinkedIssueGuardrailOverride, screenshotTableGate: screenshotTableGateOverride, + advisoryAiRouting: advisoryAiRoutingOverride, ...restManifestSettings } = manifest.settings; const effective: RepositorySettings = { ...dbSettings, ...restManifestSettings }; @@ -557,6 +559,15 @@ export function resolveEffectiveSettings( message: screenshotTableGateOverride.message ?? base.message, }; } + if (advisoryAiRoutingOverride !== undefined) { + const base = dbSettings.advisoryAiRouting ?? DEFAULT_ADVISORY_AI_ROUTING; + effective.advisoryAiRouting = { + slop: advisoryAiRoutingOverride.slop ?? base.slop, + e2eTestGen: advisoryAiRoutingOverride.e2eTestGen ?? base.e2eTestGen, + planner: advisoryAiRoutingOverride.planner ?? base.planner, + summaries: advisoryAiRoutingOverride.summaries ?? base.summaries, + }; + } applyGateConfigOverrides(effective, manifest.gate); // The dashboard "Require linked issue" toggle must not silently diverge from gate blocking: when the // boolean is on but linkedIssueGateMode is still off, treat it as a block requirement (#797). diff --git a/src/types.ts b/src/types.ts index e9f9c9eab7..7726bb3966 100644 --- a/src/types.ts +++ b/src/types.ts @@ -888,6 +888,10 @@ export type RepositorySettings = { * `.gittensory.yml settings.unlinkedIssueGuardrail` in private/global or per-repo config. Defaults * all-off so a self-hoster opts into their own credibility-gate-farming defense. */ unlinkedIssueGuardrail?: UnlinkedIssueGuardrailConfig | undefined; + /** Per-capability local-inference routing (#4364). Config-as-code only; set with `.gittensory.yml + * settings.advisoryAiRouting` in shared/global or per-repo config. Defaults all-false so every advisory + * capability stays on the shared frontier env.AI chain until an operator opts each one in. */ + advisoryAiRouting?: AdvisoryAiRoutingConfig | undefined; publicSurface: "off" | "comment_and_label" | "comment_only" | "label_only"; includeMaintainerAuthors: boolean; requireLinkedIssue: boolean; @@ -1172,6 +1176,18 @@ export type UnlinkedIssueGuardrailConfig = { minConfidence: number; }; +/** Per-capability opt-in to the local-inference AI_ADVISORY binding (#4364): each of these four ADVISORY-ONLY + * (never gate-blocking) capabilities independently decides whether it routes through env.AI_ADVISORY (when + * configured) instead of the shared frontier env.AI chain. Config-as-code only, `.gittensory.yml + * settings.advisoryAiRouting` (global default in shared/root config, per-repo override); defaults all-false + * so an operator must deliberately opt each capability in. */ +export type AdvisoryAiRoutingConfig = { + slop: boolean; + e2eTestGen: boolean; + planner: boolean; + summaries: boolean; +}; + /** A blocked contributor (#1425, anti-abuse): a GitHub `login` plus optional maintainer metadata. The converged * engine short-circuits a blacklisted author's PR/issue to a deterministic close ahead of any merit/CI/AI * analysis. Metadata can come from private configuration and must not be echoed to public surfaces. */ diff --git a/test/unit/advisory-ai-routing-call-sites.test.ts b/test/unit/advisory-ai-routing-call-sites.test.ts new file mode 100644 index 0000000000..815e9162cd --- /dev/null +++ b/test/unit/advisory-ai-routing-call-sites.test.ts @@ -0,0 +1,90 @@ +// Regression coverage for the #4364 AI_ADVISORY per-capability routing at its real call sites (as opposed to +// advisory-ai-routing-config.test.ts, which only covers the pure yml normalizer, and selfhost-ai.test.ts, +// which only covers withAdvisoryAiEnv in isolation). Each test proves the FULL wire from +// settings.advisoryAiRouting through to which env.AI vs env.AI_ADVISORY binding actually gets called. +import { describe, expect, it, vi } from "vitest"; +import { runAiSlopForAdvisory } from "../../src/queue/processors"; +import { createTestEnv } from "../helpers/d1"; +import type { PullRequestFileRecord, RepositorySettings } from "../../src/types"; +import type { buildPullRequestAdvisory } from "../../src/rules/advisory"; + +type Advisory = Awaited>; + +function settingsFixture(advisoryAiRouting?: RepositorySettings["advisoryAiRouting"]): RepositorySettings { + return { + aiReviewByok: false, + aiReviewProvider: null, + advisoryAiRouting, + } as unknown as RepositorySettings; +} + +describe("runAiSlopForAdvisory routes through AI_ADVISORY (#4364)", () => { + const frontierRun = vi.fn(async () => ({ response: JSON.stringify({ band: "clean", rationale: "ok" }) })); + const advisoryRun = vi.fn(async () => ({ response: JSON.stringify({ band: "clean", rationale: "ok" }) })); + const files: PullRequestFileRecord[] = []; + const advisory = { headSha: "abc123def456", findings: [] } as unknown as Advisory; + + it("calls env.AI_ADVISORY, not the shared frontier env.AI, when settings.advisoryAiRouting.slop is true and the binding is configured", async () => { + frontierRun.mockClear(); + advisoryRun.mockClear(); + const env = createTestEnv({ + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI: { run: frontierRun } as unknown as Ai, + AI_ADVISORY: { run: advisoryRun } as unknown as Ai, + }); + await runAiSlopForAdvisory(env, { + settings: settingsFixture({ slop: true, e2eTestGen: false, planner: false, summaries: false }), + advisory, + repoFullName: "owner/repo", + pr: { number: 1, title: "t" }, + author: "alice", + files, + deterministicBand: "clean", + confirmedContributor: true, + }); + expect(advisoryRun).toHaveBeenCalled(); + expect(frontierRun).not.toHaveBeenCalled(); + }); + + it("calls the shared frontier env.AI (not AI_ADVISORY) when settings.advisoryAiRouting.slop is false/unset", async () => { + frontierRun.mockClear(); + advisoryRun.mockClear(); + const env = createTestEnv({ + AI_SUMMARIES_ENABLED: "true", + AI_PUBLIC_COMMENTS_ENABLED: "true", + AI: { run: frontierRun } as unknown as Ai, + AI_ADVISORY: { run: advisoryRun } as unknown as Ai, + }); + await runAiSlopForAdvisory(env, { + settings: settingsFixture(undefined), + advisory, + repoFullName: "owner/repo", + pr: { number: 2, title: "t" }, + author: "alice", + files, + deterministicBand: "clean", + confirmedContributor: true, + }); + expect(frontierRun).toHaveBeenCalled(); + expect(advisoryRun).not.toHaveBeenCalled(); + }); + + it("calls the shared frontier env.AI when slop is true but AI_ADVISORY is unconfigured (fail-safe fallback)", async () => { + frontierRun.mockClear(); + advisoryRun.mockClear(); + const env = createTestEnv({ AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI: { run: frontierRun } as unknown as Ai }); + await runAiSlopForAdvisory(env, { + settings: settingsFixture({ slop: true, e2eTestGen: false, planner: false, summaries: false }), + advisory, + repoFullName: "owner/repo", + pr: { number: 3, title: "t" }, + author: "alice", + files, + deterministicBand: "clean", + confirmedContributor: true, + }); + expect(frontierRun).toHaveBeenCalled(); + expect(advisoryRun).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/advisory-ai-routing-config-engine.test.ts b/test/unit/advisory-ai-routing-config-engine.test.ts new file mode 100644 index 0000000000..f67344d843 --- /dev/null +++ b/test/unit/advisory-ai-routing-config-engine.test.ts @@ -0,0 +1,46 @@ +// Mirror of the app suite pointed at the gittensory-engine copy so the extracted module owns its branch coverage (#2280). +import { describe, expect, it } from "vitest"; +import { DEFAULT_ADVISORY_AI_ROUTING, normalizeAdvisoryAiRoutingConfig } from "../../packages/gittensory-engine/src/review/advisory-ai-routing-config"; + +describe("normalizeAdvisoryAiRoutingConfig", () => { + it("returns the all-off default when input is undefined (no warnings)", () => { + const warnings: string[] = []; + expect(normalizeAdvisoryAiRoutingConfig(undefined, warnings)).toEqual(DEFAULT_ADVISORY_AI_ROUTING); + expect(warnings).toEqual([]); + }); + + it("normalizes a fully-valid config", () => { + const warnings: string[] = []; + expect(normalizeAdvisoryAiRoutingConfig({ slop: true, e2eTestGen: true, planner: true, summaries: true }, warnings)).toEqual({ + slop: true, + e2eTestGen: true, + planner: true, + summaries: true, + }); + expect(warnings).toEqual([]); + }); + + it.each(["slop", "e2eTestGen", "planner", "summaries"] as const)("defaults %s to false when omitted", (field) => { + const warnings: string[] = []; + expect(normalizeAdvisoryAiRoutingConfig({}, warnings)[field]).toBe(false); + expect(warnings).toEqual([]); + }); + + it.each(["slop", "e2eTestGen", "planner", "summaries"] as const)("falls back to false and warns on a non-boolean %s", (field) => { + const warnings: string[] = []; + const cfg = normalizeAdvisoryAiRoutingConfig({ [field]: "yes" }, warnings); + expect(cfg[field]).toBe(false); + expect(warnings).toEqual([`settings.advisoryAiRouting.${field} must be a boolean; using the default "false".`]); + }); + + it.each([ + ["an array", []], + ["null", null], + ["a string", "on"], + ["a number", 1], + ])("normalizes a malformed top-level value (%s) back to the all-off default", (_label, badInput) => { + const warnings: string[] = []; + expect(normalizeAdvisoryAiRoutingConfig(badInput, warnings)).toEqual(DEFAULT_ADVISORY_AI_ROUTING); + expect(warnings).toEqual(["settings.advisoryAiRouting must be an object; using the default (every capability off)."]); + }); +}); diff --git a/test/unit/advisory-ai-routing-config.test.ts b/test/unit/advisory-ai-routing-config.test.ts new file mode 100644 index 0000000000..2b777eb9e8 --- /dev/null +++ b/test/unit/advisory-ai-routing-config.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_ADVISORY_AI_ROUTING, normalizeAdvisoryAiRoutingConfig } from "../../src/review/advisory-ai-routing-config"; + +describe("normalizeAdvisoryAiRoutingConfig", () => { + it("returns the all-off default when input is undefined (no warnings)", () => { + const warnings: string[] = []; + expect(normalizeAdvisoryAiRoutingConfig(undefined, warnings)).toEqual(DEFAULT_ADVISORY_AI_ROUTING); + expect(warnings).toEqual([]); + }); + + it("normalizes a fully-valid config", () => { + const warnings: string[] = []; + expect(normalizeAdvisoryAiRoutingConfig({ slop: true, e2eTestGen: true, planner: true, summaries: true }, warnings)).toEqual({ + slop: true, + e2eTestGen: true, + planner: true, + summaries: true, + }); + expect(warnings).toEqual([]); + }); + + it.each(["slop", "e2eTestGen", "planner", "summaries"] as const)("defaults %s to false when omitted", (field) => { + const warnings: string[] = []; + expect(normalizeAdvisoryAiRoutingConfig({}, warnings)[field]).toBe(false); + expect(warnings).toEqual([]); + }); + + it.each(["slop", "e2eTestGen", "planner", "summaries"] as const)("falls back to false and warns on a non-boolean %s", (field) => { + const warnings: string[] = []; + const cfg = normalizeAdvisoryAiRoutingConfig({ [field]: "yes" }, warnings); + expect(cfg[field]).toBe(false); + expect(warnings).toEqual([`settings.advisoryAiRouting.${field} must be a boolean; using the default "false".`]); + }); + + it("normalizes one valid field alongside one invalid field independently", () => { + const warnings: string[] = []; + const cfg = normalizeAdvisoryAiRoutingConfig({ slop: true, planner: "nope" }, warnings); + expect(cfg).toEqual({ slop: true, e2eTestGen: false, planner: false, summaries: false }); + expect(warnings).toEqual([`settings.advisoryAiRouting.planner must be a boolean; using the default "false".`]); + }); + + it.each([ + ["an array", []], + ["null", null], + ["a string", "on"], + ["a number", 1], + ])("normalizes a malformed top-level value (%s) back to the all-off default", (_label, badInput) => { + const warnings: string[] = []; + expect(normalizeAdvisoryAiRoutingConfig(badInput, warnings)).toEqual(DEFAULT_ADVISORY_AI_ROUTING); + expect(warnings).toEqual(["settings.advisoryAiRouting must be an object; using the default (every capability off)."]); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index ac972d98ae..3a5258fa38 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -350,6 +350,7 @@ describe(".gittensory.yml.example field-exhaustiveness (#1670)", () => { linkedIssueHardRules: "linkedIssueHardRules:", unlinkedIssueGuardrail: "unlinkedIssueGuardrail:", screenshotTableGate: "screenshotTableGate:", + advisoryAiRouting: "advisoryAiRouting:", } satisfies Record, string>; it.each(Object.entries(SETTINGS_FIELD_TOKENS))("documents settings.%s", (_field, token) => { @@ -2807,6 +2808,36 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(parsed.warnings).toContain(`Manifest "settings.screenshotTableGate" must be an object; ignoring it and keeping any existing policy.`); }); + it("wires settings.advisoryAiRouting into the manifest parser as a sparse override (#4364)", () => { + const parsed = parseFocusManifest({ settings: { advisoryAiRouting: { slop: true, summaries: true } } }); + expect(parsed.settings.advisoryAiRouting).toEqual({ slop: true, summaries: true }); + expect(parsed.warnings).toEqual([]); + }); + + it("resolveEffectiveSettings merges a partial advisoryAiRouting override without clearing the lower-layer fields (#4364)", () => { + const db = { advisoryAiRouting: { slop: false, e2eTestGen: true, planner: false, summaries: true } } as unknown as RepositorySettings; + const eff = resolveEffectiveSettings(db, parseFocusManifest({ settings: { advisoryAiRouting: { slop: true } } })); + expect(eff.advisoryAiRouting).toEqual({ slop: true, e2eTestGen: true, planner: false, summaries: true }); + }); + + it("resolveEffectiveSettings falls back to the all-off built-in default when the DB layer has no advisoryAiRouting at all (#4364)", () => { + const db = {} as unknown as RepositorySettings; + const eff = resolveEffectiveSettings(db, parseFocusManifest({ settings: { advisoryAiRouting: { planner: true } } })); + expect(eff.advisoryAiRouting).toEqual({ slop: false, e2eTestGen: false, planner: true, summaries: false }); + }); + + it("drops a malformed advisoryAiRouting.slop field instead of replacing existing policy with defaults (#4364)", () => { + const parsed = parseFocusManifest({ settings: { advisoryAiRouting: { slop: "yes", planner: true } } }); + expect(parsed.settings.advisoryAiRouting).toEqual({ planner: true }); + expect(parsed.warnings.some((w) => w.includes("settings.advisoryAiRouting.slop"))).toBe(true); + }); + + it("warns and ignores a malformed top-level advisoryAiRouting value (#4364)", () => { + const parsed = parseFocusManifest({ settings: { advisoryAiRouting: "oops" } }); + expect(parsed.settings.advisoryAiRouting).toBeUndefined(); + expect(parsed.warnings).toContain(`Manifest "settings.advisoryAiRouting" must be an object; ignoring it and keeping any existing policy.`); + }); + it("parses aiReview from settings: and lets gate.aiReview win in resolveEffectiveSettings", () => { const parsed = parseFocusManifest({ settings: { aiReviewMode: "advisory", aiReviewByok: true } }); expect(parsed.settings.aiReviewMode).toBe("advisory"); diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts index 6cffa8b810..50820780b3 100644 --- a/test/unit/selfhost-ai.test.ts +++ b/test/unit/selfhost-ai.test.ts @@ -2,7 +2,7 @@ import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { assertNoLegacySharedAiEnv, buildProvider, claudeErrorStatus, codexErrorFromStdout, createAnthropicAi, createChainAi, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, extractCliUsage, isAiProviderHealthy, markAiProviderUnhealthyAtBoot, resetAiProviderCircuitBreakerForTest, resetAiProviderHealthForTest, resolveAiReviewerPlan, resolveClaudeCliTimeoutMs, resolveCodexAuthPath, resolveCodexCliTimeoutMs, resolveCodexEffort, resolveCodexFirstOutputTimeoutMs, resolveEffort, resolveModel, resolveProviderNames, resolveRequiredCliProviders, resolveSubscriptionCliPath, redactSecrets, routeProviders, shouldMarkAiProviderUnhealthyAtBoot, subscriptionCliEnv } from "../../src/selfhost/ai"; +import { assertNoLegacySharedAiEnv, buildProvider, claudeErrorStatus, codexErrorFromStdout, createAnthropicAi, createChainAi, createClaudeCodeAi, createCodexAi, createOpenAiCompatibleAi, createSelfHostAi, extractCliText, extractCliUsage, isAiProviderHealthy, markAiProviderUnhealthyAtBoot, resetAiProviderCircuitBreakerForTest, resetAiProviderHealthForTest, resolveAiReviewerPlan, resolveClaudeCliTimeoutMs, resolveCodexAuthPath, resolveCodexCliTimeoutMs, resolveCodexEffort, resolveCodexFirstOutputTimeoutMs, resolveEffort, resolveModel, resolveProviderNames, resolveRequiredCliProviders, resolveSubscriptionCliPath, redactSecrets, routeProviders, shouldMarkAiProviderUnhealthyAtBoot, subscriptionCliEnv, withAdvisoryAiEnv } from "../../src/selfhost/ai"; import { labelSelfHostReviewerModel, labelSelfHostReviewerModels } from "../../src/selfhost/ai-config"; import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics"; @@ -1778,3 +1778,30 @@ describe("redactSecrets — strip credentials from untrusted CLI stderr before i expect(redactSecrets("disk-usage-report-2024-summary failed")).toBe("disk-usage-report-2024-summary failed"); }); }); + +describe("withAdvisoryAiEnv (#4364 — per-capability local-inference routing)", () => { + const frontierAi = { run: async () => ({ response: "frontier" }) }; + const advisoryAi = { run: async () => ({ response: "advisory" }) }; + + it("swaps .AI to AI_ADVISORY when the capability opted in AND the binding is configured", () => { + const env = { AI: frontierAi, AI_ADVISORY: advisoryAi } as unknown as Env; + const result = withAdvisoryAiEnv(env, true); + expect(result.AI).toBe(advisoryAi); + // every other field is untouched (shallow spread, not a new unrelated object) + expect(result.AI_ADVISORY).toBe(advisoryAi); + }); + + it("leaves .AI unchanged when the capability did not opt in, even though the binding is configured", () => { + const env = { AI: frontierAi, AI_ADVISORY: advisoryAi } as unknown as Env; + const result = withAdvisoryAiEnv(env, false); + expect(result).toBe(env); // same reference — no-op, not just an equal value + expect(result.AI).toBe(frontierAi); + }); + + it("falls back to the shared frontier chain when opted in but AI_ADVISORY is unconfigured (byte-identical fail-safe)", () => { + const env = { AI: frontierAi } as unknown as Env; + const result = withAdvisoryAiEnv(env, true); + expect(result).toBe(env); + expect(result.AI).toBe(frontierAi); + }); +});