Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,8 @@ settings:
# 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.
# chatQa: false # @gittensory chat <question> grounded LLM Q&A. Ollama-ONLY (never the frontier env.AI); needs env.AI_ADVISORY set. Co-requisite: commandRateLimitPolicy: hold (defaults off). Default: false.
# chatQa: false # @gittensory chat <question> grounded LLM Q&A. Ollama-first (never the frontier env.AI unless chatQaFrontierFallback below is also true); needs env.AI_ADVISORY set. Co-requisite: commandRateLimitPolicy: hold (defaults off). Default: false.
# chatQaFrontierFallback: false # Opt-in only: falls back to the frontier env.AI chain if env.AI_ADVISORY is unconfigured, instead of declining. Meaningless unless chatQa is also true. 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.
Expand Down
9 changes: 7 additions & 2 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9170,15 +9170,20 @@
},
"chatQa": {
"type": "boolean",
"description": "Opt the `@gittensory chat <question>` grounded Q&A surface (#4595) into local Ollama inference. Ollama-only: unlike the four capabilities above it NEVER falls back to the frontier env.AI when off; it declines unless this is true AND env.AI_ADVISORY is configured. Co-requisite: set `commandRateLimitPolicy` to `hold` (it defaults to `off` fleet-wide) so the tighter `commandRateLimitAiMaxPerWindow` ceiling actually throttles this cost-bearing command."
"description": "Opt the `@gittensory chat <question>` grounded Q&A surface (#4595) into local Ollama inference. Ollama-first: unlike the four capabilities above, it declines instead of falling back to the frontier env.AI when env.AI_ADVISORY is unconfigured, unless `chatQaFrontierFallback` is also enabled. Co-requisite: set `commandRateLimitPolicy` to `hold` (it defaults to `off` fleet-wide) so the tighter `commandRateLimitAiMaxPerWindow` ceiling actually throttles this cost-bearing command."
},
"chatQaFrontierFallback": {
"type": "boolean",
"description": "Opt-in only (#4595 follow-up): when true, `@gittensory chat` falls back to the shared frontier env.AI chain if env.AI_ADVISORY is unconfigured, instead of declining. Meaningless unless `chatQa` is also true. Default false -- a self-hoster without a local GPU may enable this to use their own frontier subscription/tokens for chat instead."
}
},
"required": [
"slop",
"e2eTestGen",
"planner",
"summaries",
"chatQa"
"chatQa",
"chatQaFrontierFallback"
]
},
"gittensorLabel": {
Expand Down
3 changes: 2 additions & 1 deletion config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,8 @@ settings:
# 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.
# chatQa: false # @gittensory chat <question> grounded LLM Q&A. Ollama-ONLY (never the frontier env.AI); needs env.AI_ADVISORY set. Co-requisite: commandRateLimitPolicy: hold (defaults off). Default: false.
# chatQa: false # @gittensory chat <question> grounded LLM Q&A. Ollama-first (never the frontier env.AI unless chatQaFrontierFallback below is also true); needs env.AI_ADVISORY set. Co-requisite: commandRateLimitPolicy: hold (defaults off). Default: false.
# chatQaFrontierFallback: false # Opt-in only: falls back to the frontier env.AI chain if env.AI_ADVISORY is unconfigured, instead of declining. Meaningless unless chatQa is also true. 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.
Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1960,6 +1960,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[],
if (typeof rawRouting.planner === "boolean") sparseRouting.planner = validated.planner;
if (typeof rawRouting.summaries === "boolean") sparseRouting.summaries = validated.summaries;
if (typeof rawRouting.chatQa === "boolean") sparseRouting.chatQa = validated.chatQa;
if (typeof rawRouting.chatQaFrontierFallback === "boolean") sparseRouting.chatQaFrontierFallback = validated.chatQaFrontierFallback;
out.advisoryAiRouting = sparseRouting;
} else if (r.advisoryAiRouting !== undefined) {
warnings.push(`Manifest "settings.advisoryAiRouting" must be an object; ignoring it and keeping any existing policy.`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const DEFAULT_ADVISORY_AI_ROUTING: AdvisoryAiRoutingConfig = {
planner: false,
summaries: false,
chatQa: false,
chatQaFrontierFallback: false,
};

function normalizeField(value: unknown, field: keyof AdvisoryAiRoutingConfig, warnings: string[]): boolean {
Expand Down Expand Up @@ -33,5 +34,6 @@ export function normalizeAdvisoryAiRoutingConfig(input: unknown, warnings: strin
planner: normalizeField(record.planner, "planner", warnings),
summaries: normalizeField(record.summaries, "summaries", warnings),
chatQa: normalizeField(record.chatQa, "chatQa", warnings),
chatQaFrontierFallback: normalizeField(record.chatQaFrontierFallback, "chatQaFrontierFallback", warnings),
};
}
8 changes: 6 additions & 2 deletions packages/gittensory-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,13 @@ export type AdvisoryAiRoutingConfig = {
e2eTestGen: boolean;
planner: boolean;
summaries: boolean;
/** Grounded `@gittensory chat <question>` LLM Q&A (#4595). Ollama-only: unlike the four fields above it NEVER
* falls back to the frontier env.AI when off -- it simply declines. Default false. */
/** Grounded `@gittensory chat <question>` LLM Q&A (#4595). Ollama-first: declines when off or when
* env.AI_ADVISORY is unconfigured and {@link chatQaFrontierFallback} is not also enabled. Default false. */
chatQa: boolean;
/** Opt-in ONLY (#4595 follow-up): when true, chat falls back to the shared frontier env.AI chain if
* env.AI_ADVISORY is unconfigured, instead of declining. Meaningless unless {@link chatQa} is also true.
* Default false. */
chatQaFrontierFallback: boolean;
};

export type ContributorBlacklistEntry = {
Expand Down
7 changes: 6 additions & 1 deletion src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,7 +762,12 @@ export const RepositorySettingsSchema = z
chatQa: z
.boolean()
.describe(
"Opt the `@gittensory chat <question>` grounded Q&A surface (#4595) into local Ollama inference. Ollama-only: unlike the four capabilities above it NEVER falls back to the frontier env.AI when off; it declines unless this is true AND env.AI_ADVISORY is configured. Co-requisite: set `commandRateLimitPolicy` to `hold` (it defaults to `off` fleet-wide) so the tighter `commandRateLimitAiMaxPerWindow` ceiling actually throttles this cost-bearing command.",
"Opt the `@gittensory chat <question>` grounded Q&A surface (#4595) into local Ollama inference. Ollama-first: unlike the four capabilities above, it declines instead of falling back to the frontier env.AI when env.AI_ADVISORY is unconfigured, unless `chatQaFrontierFallback` is also enabled. Co-requisite: set `commandRateLimitPolicy` to `hold` (it defaults to `off` fleet-wide) so the tighter `commandRateLimitAiMaxPerWindow` ceiling actually throttles this cost-bearing command.",
),
chatQaFrontierFallback: z
.boolean()
.describe(
"Opt-in only (#4595 follow-up): when true, `@gittensory chat` falls back to the shared frontier env.AI chain if env.AI_ADVISORY is unconfigured, instead of declining. Meaningless unless `chatQa` is also true. Default false -- a self-hoster without a local GPU may enable this to use their own frontier subscription/tokens for chat instead.",
),
})
.optional(),
Expand Down
2 changes: 2 additions & 0 deletions src/review/advisory-ai-routing-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const DEFAULT_ADVISORY_AI_ROUTING: AdvisoryAiRoutingConfig = {
planner: false,
summaries: false,
chatQa: false,
chatQaFrontierFallback: false,
};

function normalizeField(value: unknown, field: keyof AdvisoryAiRoutingConfig, warnings: string[]): boolean {
Expand Down Expand Up @@ -33,5 +34,6 @@ export function normalizeAdvisoryAiRoutingConfig(input: unknown, warnings: strin
planner: normalizeField(record.planner, "planner", warnings),
summaries: normalizeField(record.summaries, "summaries", warnings),
chatQa: normalizeField(record.chatQa, "chatQa", warnings),
chatQaFrontierFallback: normalizeField(record.chatQaFrontierFallback, "chatQaFrontierFallback", warnings),
};
}
47 changes: 32 additions & 15 deletions src/services/ai-chat-qa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import type { AgentRunBundle } from "./agent-orchestrator";
// ALREADY-deterministic decision-pack facts in the bundle (PR verdict, which checks/findings are blocking,
// what a finding means) into natural prose; it never synthesizes new claims.
//
// Ollama-ONLY, by hard requirement (#4595 requirement 5): unlike the four sibling advisoryAiRouting
// capabilities (slop/e2eTestGen/planner/summaries), which silently fall back to the shared frontier env.AI when
// their flag is off, this surface NEVER touches the frontier. It declines whenever advisoryAiRouting.chatQa is
// not true or env.AI_ADVISORY is unconfigured -- it does not call withAdvisoryAiEnv(env, false) and let a
// frontier token be spent.
// Ollama-FIRST by default (#4595 requirement 5): unlike the four sibling advisoryAiRouting capabilities
// (slop/e2eTestGen/planner/summaries), which silently fall back to the shared frontier env.AI whenever their
// OWN flag is off, this surface declines instead of spending a frontier token when env.AI_ADVISORY is
// unconfigured -- UNLESS the operator explicitly opts into advisoryAiRouting.chatQaFrontierFallback (a
// self-hoster without a local GPU may prefer their own frontier subscription over declining outright). Default
// false preserves the original Ollama-only behavior for every existing deployment.

export type ChatQaResult =
| { status: "disabled"; reason: string }
Expand Down Expand Up @@ -80,14 +81,21 @@ type ChatGroundingBundle = {
};

export async function generateChatQaAnswer(env: Env, req: ChatQaRequest): Promise<ChatQaResult> {
// (#4595 req 5) Ollama-only enablement. BOTH gates are hard declines, never a frontier fallback.
if (req.advisoryAiRouting?.chatQa !== true) {
return { status: "disabled", reason: "Chat Q&A is not enabled on this instance (settings.advisoryAiRouting.chatQa is off)." };
}
if (!env.AI_ADVISORY) {
// Ollama-first: env.AI_ADVISORY is always preferred. env.AI (frontier) is only ever touched when the
// operator has explicitly opted in via advisoryAiRouting.chatQaFrontierFallback (#4595 follow-up) --
// otherwise this stays exactly as Ollama-only as it originally shipped.
const frontierFallbackAllowed = req.advisoryAiRouting?.chatQaFrontierFallback === true;
const ai = env.AI_ADVISORY ?? (frontierFallbackAllowed ? env.AI : undefined);
const usedFrontier = !env.AI_ADVISORY && ai !== undefined;
if (!ai) {
return {
status: "unavailable",
reason: "Local advisory inference (env.AI_ADVISORY) is not configured; chat Q&A never falls back to the frontier model.",
reason: frontierFallbackAllowed
? "Neither local advisory inference (env.AI_ADVISORY) nor the frontier model (env.AI) is configured."
: "Local advisory inference (env.AI_ADVISORY) is not configured; chat Q&A does not fall back to the frontier model unless advisoryAiRouting.chatQaFrontierFallback is enabled.",
};
}

Expand Down Expand Up @@ -134,12 +142,13 @@ export async function generateChatQaAnswer(env: Env, req: ChatQaRequest): Promis
status: "quota_exceeded",
estimatedNeurons: 0,
detail: `estimated ${estimatedNeurons} neurons exceeds remaining budget ${remainingBudget}`,
usedFrontier,
});
return { status: "quota_exceeded", model, estimatedNeurons, remainingBudget };
}

try {
const response = await env.AI_ADVISORY.run(model, {
const response = await ai.run(model, {
messages: [
{ role: "system", content: CHAT_QA_SYSTEM_PROMPT },
{ role: "user", content: prompt },
Expand All @@ -150,14 +159,14 @@ export async function generateChatQaAnswer(env: Env, req: ChatQaRequest): Promis
const rawText = extractAiText(response);
if (!rawText) throw new Error("empty_chat_answer");
if (containsPublicForbiddenText(rawText)) {
await recordChatAi(env, req, { model, status: "unsafe", estimatedNeurons, detail: "chat answer failed public sanitizer" });
await recordChatAi(env, req, { model, status: "unsafe", estimatedNeurons, detail: "chat answer failed public sanitizer", usedFrontier });
return { status: "unsafe", model, estimatedNeurons, reason: "chat answer failed public sanitizer" };
}
await recordChatAi(env, req, { model, status: "ok", estimatedNeurons, detail: "chat answer generated" });
await recordChatAi(env, req, { model, status: "ok", estimatedNeurons, detail: "chat answer generated", usedFrontier });
return { status: "ok", model, estimatedNeurons, text: rawText.trim() };
} catch (error) {
const reason = error instanceof Error ? error.message : "chat_answer_failed";
await recordChatAi(env, req, { model, status: "error", estimatedNeurons: 0, detail: reason });
await recordChatAi(env, req, { model, status: "error", estimatedNeurons: 0, detail: reason, usedFrontier });
return { status: "error", model, estimatedNeurons, reason };
}
}
Expand Down Expand Up @@ -240,7 +249,7 @@ function auditOutcomeForAiStatus(status: string): "success" | "denied" | "error"
async function recordChatAi(
env: Env,
req: ChatQaRequest,
event: { model: string; status: string; estimatedNeurons: number; detail: string },
event: { model: string; status: string; estimatedNeurons: number; detail: string; usedFrontier: boolean },
): Promise<void> {
await recordAiUsageEvent(env, {
feature: "chat_qa",
Expand All @@ -250,15 +259,23 @@ async function recordChatAi(
status: event.status,
estimatedNeurons: event.estimatedNeurons,
detail: event.detail,
metadata: { repoFullName: req.repoFullName, issueNumber: req.issueNumber },
// provider is observability-only: lets an operator who opted into chatQaFrontierFallback see, per event,
// whether a given answer actually spent a frontier token or stayed on the (free) local GPU.
metadata: { repoFullName: req.repoFullName, issueNumber: req.issueNumber, provider: event.usedFrontier ? "frontier" : "advisory" },
});
await recordAuditEvent(env, {
eventType: "ai.chat_qa",
actor: req.actor,
route: req.route,
outcome: auditOutcomeForAiStatus(event.status),
detail: event.detail,
metadata: { repoFullName: req.repoFullName, issueNumber: req.issueNumber, model: event.model, estimatedNeurons: event.estimatedNeurons },
metadata: {
repoFullName: req.repoFullName,
issueNumber: req.issueNumber,
model: event.model,
estimatedNeurons: event.estimatedNeurons,
provider: event.usedFrontier ? "frontier" : "advisory",
},
});
}

Expand Down
1 change: 1 addition & 0 deletions src/signals/focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ export function resolveEffectiveSettings(
planner: advisoryAiRoutingOverride.planner ?? base.planner,
summaries: advisoryAiRoutingOverride.summaries ?? base.summaries,
chatQa: advisoryAiRoutingOverride.chatQa ?? base.chatQa,
chatQaFrontierFallback: advisoryAiRoutingOverride.chatQaFrontierFallback ?? base.chatQaFrontierFallback,
};
}
applyGateConfigOverrides(effective, manifest.gate);
Expand Down
19 changes: 13 additions & 6 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1281,18 +1281,25 @@ export type UnlinkedIssueGuardrailConfig = {
* settings.advisoryAiRouting` (global default in shared/root config, per-repo override); defaults all-false
* so an operator must deliberately opt each capability in.
*
* `chatQa` (#4595) is the ONE capability that does NOT share the others' silent-frontier fallback: the four
* cost-optimizing capabilities above quietly fall back to the shared frontier env.AI when their flag is off,
* but the `@gittensory chat` grounded Q&A surface is "Ollama only" -- it declines/skips whenever
* `chatQa !== true` or `env.AI_ADVISORY` is unconfigured rather than ever spending a frontier token. */
* `chatQa` (#4595) is the ONE capability that does NOT share the others' silent-frontier fallback BY DEFAULT:
* the four cost-optimizing capabilities above quietly fall back to the shared frontier env.AI when their flag
* is off, but the `@gittensory chat` grounded Q&A surface declines/skips whenever `chatQa !== true` or
* `env.AI_ADVISORY` is unconfigured, rather than ever spending a frontier token -- UNLESS `chatQaFrontierFallback`
* is also explicitly enabled (a self-hoster without a local GPU may prefer their own frontier subscription
* over an outright decline). */
export type AdvisoryAiRoutingConfig = {
slop: boolean;
e2eTestGen: boolean;
planner: boolean;
summaries: boolean;
/** Grounded `@gittensory chat <question>` LLM Q&A (#4595). Ollama-only: unlike the four fields above it NEVER
* falls back to the frontier env.AI when off -- it simply declines. Default false. */
/** Grounded `@gittensory chat <question>` LLM Q&A (#4595). Ollama-first: declines when off or when
* env.AI_ADVISORY is unconfigured and {@link chatQaFrontierFallback} is not also enabled. Default false. */
chatQa: boolean;
/** Opt-in ONLY (#4595 follow-up): when true, chat falls back to the shared frontier env.AI chain if
* env.AI_ADVISORY is unconfigured, instead of declining. Meaningless unless {@link chatQa} is also true.
* Default false -- preserves the original Ollama-only behavior for every existing deployment; a self-hoster
* without a local GPU may enable this to use their own frontier subscription/tokens for chat instead. */
chatQaFrontierFallback: boolean;
};

/** A blocked contributor (#1425, anti-abuse): a GitHub `login` plus optional maintainer metadata. The converged
Expand Down
4 changes: 2 additions & 2 deletions test/unit/advisory-ai-routing-call-sites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe("runAiSlopForAdvisory routes through AI_ADVISORY (#4364)", () => {
});
await runAiSlopForAdvisory(env, {
mode: "live",
settings: settingsFixture({ slop: true, e2eTestGen: false, planner: false, summaries: false, chatQa: false }),
settings: settingsFixture({ slop: true, e2eTestGen: false, planner: false, summaries: false, chatQa: false, chatQaFrontierFallback: false }),
advisory,
repoFullName: "owner/repo",
pr: { number: 1, title: "t" },
Expand Down Expand Up @@ -80,7 +80,7 @@ describe("runAiSlopForAdvisory routes through AI_ADVISORY (#4364)", () => {
const env = createTestEnv({ AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI: { run: frontierRun } as unknown as Ai });
await runAiSlopForAdvisory(env, {
mode: "live",
settings: settingsFixture({ slop: true, e2eTestGen: false, planner: false, summaries: false, chatQa: false }),
settings: settingsFixture({ slop: true, e2eTestGen: false, planner: false, summaries: false, chatQa: false, chatQaFrontierFallback: false }),
advisory,
repoFullName: "owner/repo",
pr: { number: 3, title: "t" },
Expand Down
Loading
Loading