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
1 change: 1 addition & 0 deletions .gittensory.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,7 @@ settings:
# summaries: false # AI summaries/rewrite text. 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.
# intentRouting: false # Closed-set intent classifier for unrecognized @gittensory mentions -> existing Q&A commands only. Ollama-ONLY, same as chatQa (never uses chatQaFrontierFallback). Co-requisite: commandRateLimitPolicy: hold. 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
7 changes: 6 additions & 1 deletion apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9175,6 +9175,10 @@
"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."
},
"intentRouting": {
"type": "boolean",
"description": "Opt a closed-set intent-classification router (#4596) into unrecognized `@gittensory` mentions: maps a free-text question to the closest existing Q&A command (never an action command) instead of the plain did-you-mean hint. Ollama-only, same as chatQa. Co-requisite: set `commandRateLimitPolicy` to `hold`."
}
},
"required": [
Expand All @@ -9183,7 +9187,8 @@
"planner",
"summaries",
"chatQa",
"chatQaFrontierFallback"
"chatQaFrontierFallback",
"intentRouting"
]
},
"gittensorLabel": {
Expand Down
1 change: 1 addition & 0 deletions config/examples/gittensory.full.yml
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,7 @@ settings:
# summaries: false # AI summaries/rewrite text. 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.
# intentRouting: false # Closed-set intent classifier for unrecognized @gittensory mentions -> existing Q&A commands only. Ollama-ONLY, same as chatQa (never uses chatQaFrontierFallback). Co-requisite: commandRateLimitPolicy: hold. 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 @@ -1961,6 +1961,7 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[],
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;
if (typeof rawRouting.intentRouting === "boolean") sparseRouting.intentRouting = validated.intentRouting;
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 @@ -7,6 +7,7 @@ export const DEFAULT_ADVISORY_AI_ROUTING: AdvisoryAiRoutingConfig = {
summaries: false,
chatQa: false,
chatQaFrontierFallback: false,
intentRouting: false,
};

function normalizeField(value: unknown, field: keyof AdvisoryAiRoutingConfig, warnings: string[]): boolean {
Expand Down Expand Up @@ -35,5 +36,6 @@ export function normalizeAdvisoryAiRoutingConfig(input: unknown, warnings: strin
summaries: normalizeField(record.summaries, "summaries", warnings),
chatQa: normalizeField(record.chatQa, "chatQa", warnings),
chatQaFrontierFallback: normalizeField(record.chatQaFrontierFallback, "chatQaFrontierFallback", warnings),
intentRouting: normalizeField(record.intentRouting, "intentRouting", warnings),
};
}
3 changes: 3 additions & 0 deletions packages/gittensory-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ export type AdvisoryAiRoutingConfig = {
* env.AI_ADVISORY is unconfigured, instead of declining. Meaningless unless {@link chatQa} is also true.
* Default false. */
chatQaFrontierFallback: boolean;
/** Closed-set intent-classification router for unrecognized `@gittensory` mentions (#4596). Ollama-only,
* same as chatQa. Default false. */
intentRouting: boolean;
};

export type ContributorBlacklistEntry = {
Expand Down
48 changes: 46 additions & 2 deletions src/github/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ export type MaintainerQueueDigestCommandName = (typeof MAINTAINER_QUEUE_DIGEST_C
// not the deterministic snapshot-section path, so it needs no REFRESH_/EMPTY_SECTION_TITLES entry.
type SnapshotCommandName = Exclude<GittensoryMentionCommandName, "help" | "miner-context" | "chat" | MaintainerQueueDigestCommandName>;

// Closed set the intent-classification router (#4596) may EVER route to: existing Q&A commands with real,
// already-tested answer content. Deliberately excludes help (that IS the fallback this replaces),
// miner-context (a narrow lookup, not natural-language-answerable), and every maintainer-queue-digest
// command (dashboard listings). This is the HARD runtime allowlist a classifier's raw output is filtered
// through before ever being trusted (req 3): never the action catalog, never anything outside this list,
// no matter what the model claims.
export const INTENT_ROUTABLE_COMMANDS = ["preflight", "blockers", "duplicate-check", "next-action", "reviewability", "repo-fit", "packet", "ask", "chat"] as const;
export type IntentRoutableCommandName = (typeof INTENT_ROUTABLE_COMMANDS)[number];
const INTENT_ROUTABLE_COMMAND_SET: ReadonlySet<string> = new Set(INTENT_ROUTABLE_COMMANDS);

/** The hard runtime allowlist check itself (req 3) -- a plain Set membership test, not a prompt instruction.
* Exhaustively testable: any value that is not EXACTLY one of the 9 literal strings above returns false,
* including every action-command name, every maintainer-only command name, "help", arbitrary strings, and
* non-string values a malformed/adversarial model response might produce. */
export function isIntentRoutableCommand(value: unknown): value is IntentRoutableCommandName {
return typeof value === "string" && INTENT_ROUTABLE_COMMAND_SET.has(value);
}

// Action commands are NOT Q&A: they perform a side effect (handled before the mention-command path) rather
// than producing a public answer card. They are intentionally kept OUT of the Q&A catalog/unions so the
// exhaustive Q&A switches stay total, but parseGittensoryMentionCommand still recognizes them (so a bare
Expand Down Expand Up @@ -129,6 +147,12 @@ export type GittensoryMentionCommand = {
argument?: string | undefined;
/** Present when a non-empty verb was unrecognized and downgraded to `help` (#2170). */
unknownVerb?: string | undefined;
/** The full free-form text after `@gittensory` (unrecognized verb token plus any trailing words, or the
* whole trailing text when there was no verb-shaped token at all), present whenever the mention downgrades
* to `help` with non-trivial trailing content -- e.g. "@gittensory why is this stuck?" yields
* "why is this stuck?". Feeds the intent-classification router (#4596); `undefined` for a bare
* "@gittensory help" with nothing else to classify. */
unrecognizedText?: string | undefined;
};

type PublicAnswerCard = {
Expand Down Expand Up @@ -256,7 +280,12 @@ export function parseGittensoryMentionCommand(body: string | null | undefined):
if (!match) return null;
const rawVerbToken = match[1]?.toLowerCase();
if (!rawVerbToken) {
return { name: "help", raw: match[0].trim() };
// match[2] is always defined for the same reason as the branches below (a `*`-quantified group outside
// any optional wrapper) -- it holds whatever followed "@gittensory" when nothing verb-shaped matched at
// all (e.g. "@gittensory 123 why is this stuck" or a bare "@gittensory" with only punctuation after it).
/* v8 ignore next */
const bareTrailing = (match[2] ?? "").trim();
return { name: "help", raw: match[0].trim(), unrecognizedText: bareTrailing.length > 0 ? bareTrailing : undefined };
}
const requested = (GITTENSORY_ACTION_COMMAND_ALIASES[rawVerbToken] ?? rawVerbToken) as GittensoryMentionCommandName | GittensoryActionCommandName;
if (ACTION_COMMANDS.has(requested as GittensoryActionCommandName)) {
Expand All @@ -281,7 +310,13 @@ export function parseGittensoryMentionCommand(body: string | null | undefined):
question: question && question.length > 0 ? question : undefined,
};
}
return { name: "help", raw: match[0].trim(), unknownVerb: rawVerbToken };
// match[2] is always defined for the same reason as the branches above; concatenated onto the unrecognized
// verb token itself, it reconstructs the full free-form text a contributor actually typed (e.g. "why is
// this stuck?" -> verb "why" + trailing " is this stuck?"), which is what the intent router (#4596)
// classifies -- the verb token alone is rarely enough context.
/* v8 ignore next */
const unrecognizedText = `${rawVerbToken}${match[2] ?? ""}`.trim();
return { name: "help", raw: match[0].trim(), unknownVerb: rawVerbToken, unrecognizedText };
}

export function isMaintainerAssociation(association: string | null | undefined): boolean {
Expand Down Expand Up @@ -378,6 +413,10 @@ export function buildPublicAgentCommandComment(args: {
/** Grounded `@gittensory chat` answer (#4595). Only read when `command.name === "chat"`; the dispatcher
* resolves it via generateChatQaAnswer before composing the card. */
chatAnswer?: ChatQaResult | null | undefined;
/** Set by the dispatcher when the intent-classification router (#4596) re-routed an unrecognized-verb
* mention to `matchedCommand` -- shown as a visible "interpreted as" note (req 6) so a wrong match is
* immediately correctable, rather than silently answering a different question than the one asked. */
interpretedFrom?: { question: string; matchedCommand: GittensoryMentionCommandName } | undefined;
/** Resolved by the caller from `env.PUBLIC_SITE_ORIGIN` -- see `gittensoryFooter` (#4613). */
env: GittensoryFooterEnv;
}): string {
Expand Down Expand Up @@ -418,6 +457,11 @@ export function buildPublicAgentCommandComment(args: {
"",
`Command: \`@gittensory ${commandName}\``,
"",
// (#4596 req 6) Free-form contributor text, same neutralization as the chat question line (#2457) --
// this is the first place a re-routed mention's own text is echoed back into a trusted bot comment.
...(args.interpretedFrom
? [`> 🎯 Interpreted "${neutralizePublicMarkdownText(sanitizePublicComment(args.interpretedFrom.question))}" as \`@gittensory ${args.interpretedFrom.matchedCommand}\`. Use the exact command if this is wrong.`, ""]
: []),
"<details>",
"<summary>Command result</summary>",
"",
Expand Down
5 changes: 5 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,11 @@ export const RepositorySettingsSchema = z
.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.",
),
intentRouting: z
.boolean()
.describe(
"Opt a closed-set intent-classification router (#4596) into unrecognized `@gittensory` mentions: maps a free-text question to the closest existing Q&A command (never an action command) instead of the plain did-you-mean hint. Ollama-only, same as chatQa. Co-requisite: set `commandRateLimitPolicy` to `hold`.",
),
})
.optional(),
gittensorLabel: z.string(),
Expand Down
97 changes: 96 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,7 @@ import { buildFixHandoffBlocks } from "../review/fix-handoff-render";
import { buildE2eTestGenCommentBody, type E2eTestGenCommitOutcome } from "../review/e2e-test-gen-render";
import { resolveE2eTestGenInstructions, runGittensoryE2eTestGeneration } from "../services/ai-e2e-test-gen";
import { generateChatQaAnswer } from "../services/ai-chat-qa";
import { classifyGittensoryIntent } from "../services/ai-intent-router";
import { commitE2eTestToPrBranch } from "../github/e2e-test-commit";
import { shouldApplyRepoCultureProfile } from "../review/repo-culture-profile-wire";
import { applyReviewMemorySuppression, getCachedReviewSuppressions, invalidateReviewSuppressionCache, shouldApplyReviewMemory } from "../review/review-memory-wire";
Expand Down Expand Up @@ -12271,6 +12272,62 @@ async function maybeThrottleGittensoryCommand(
return true;
}

const INTENT_ROUTING_RATE_LIMIT_EVENT_TYPE = "github_app.intent_routing_invocation";

/**
* Dedicated rate limit for the intent-classification router (#4596): every unrecognized-verb mention with
* non-trivial trailing text that reaches the classifier consumes ONE tick here, using the SAME AI-cost-bearing
* ceiling (`commandRateLimitAiMaxPerWindow`) and "off"/"hold" policy switch as every other AI-cost-bearing
* command -- kept as its OWN counter (not folded into any single command's bucket via
* `maybeThrottleGittensoryCommand`) because an unrecognized-verb mention isn't attributable to any one command
* until AFTER classification runs, and a "no match" classification must still count for budget-ledger
* consistency (req 5) even though it never becomes a real command dispatch. Fails OPEN on any throttle: this
* only ever skips the classifier call itself, never blocks the existing did-you-mean fallback it would
* otherwise replace -- a contributor still gets a reply either way.
*/
async function maybeThrottleIntentRouting(
env: Env,
args: {
deliveryId: string;
repoFullName: string;
issueNumber: number;
commenter: string;
settings: RepositorySettings;
},
): Promise<boolean> {
/* v8 ignore next -- resolveRepositorySettings always resolves a concrete "off"/"hold"; the undefined side is defensive against the field's optional TS type. */
const policy = args.settings.commandRateLimitPolicy ?? "off";
if (policy === "off") return false;

const targetKey = `${args.repoFullName}#${args.issueNumber}#intent-routing`;
const redeliverySinceIso = new Date(Date.now() - COMMAND_RATE_LIMIT_REDELIVERY_WINDOW_MS).toISOString();
const alreadySeen = await hasAuditEventForDelivery(env, args.commenter, INTENT_ROUTING_RATE_LIMIT_EVENT_TYPE, targetKey, args.deliveryId, redeliverySinceIso);
// A redelivered webhook must not re-classify (and re-spend shared neuron budget) for one real mention.
if (alreadySeen) return true;

/* v8 ignore next -- resolveRepositorySettings always resolves a concrete positive integer; the undefined side is defensive against the field's optional TS type. */
const maxPerWindow = args.settings.commandRateLimitAiMaxPerWindow ?? 5;
/* v8 ignore next -- resolveRepositorySettings always resolves a concrete positive integer; the undefined side is defensive against the field's optional TS type. */
const windowHours = args.settings.commandRateLimitWindowHours ?? 24;
const sinceIso = new Date(Date.now() - windowHours * 60 * 60 * 1000).toISOString();
const priorInvocations = await countRecentAuditEventsForActorAndTarget(env, args.commenter, INTENT_ROUTING_RATE_LIMIT_EVENT_TYPE, targetKey, sinceIso);
const invocationCount = priorInvocations + 1;

await recordAuditEvent(env, {
eventType: INTENT_ROUTING_RATE_LIMIT_EVENT_TYPE,
actor: args.commenter,
targetKey,
outcome: "completed",
detail: `intent-routing invocation ${invocationCount}/${maxPerWindow} within ${windowHours}h window`,
metadata: { deliveryId: args.deliveryId, repoFullName: args.repoFullName },
}).catch(
/* v8 ignore next -- fail-safe: an audit write failure never blocks the classifier attempt */
() => undefined,
);

return invocationCount > maxPerWindow;
}

async function maybeProcessGittensoryMentionCommand(
env: Env,
deliveryId: string,
Expand All @@ -12280,7 +12337,7 @@ async function maybeProcessGittensoryMentionCommand(
// this an `edited` comment re-runs the agent + rewrites the card, and a `deleted` command still posts an answer
// card for a command that no longer exists (#review-audit).
if (payload.action !== "created") return false;
const command = parseGittensoryMentionCommand(payload.comment?.body);
let command = parseGittensoryMentionCommand(payload.comment?.body);
if (!command) return false;
// Action commands (gate-override + the #1960 PR control-surface verbs) are handled by their own dispatch
// earlier in processGitHubWebhook; they never produce a Q&A answer card here. Bail so the rest of this
Expand Down Expand Up @@ -12393,6 +12450,43 @@ async function maybeProcessGittensoryMentionCommand(
commenter,
),
]);

// Intent-classification router (#4596): an unrecognized-verb mention with real trailing text (e.g. "why is
// this stuck?") gets ONE chance to be re-routed to an existing Q&A command BEFORE authorization/rate-limit/
// dispatch run, so the rest of this handler proceeds completely normally for whatever it resolves to -- the
// matched command's OWN authorization, rate limit, and rendering all apply unchanged, exactly as if the
// contributor had typed the exact verb. A no-match (or anything not enabled/available) leaves `command`
// untouched and the existing did-you-mean fallback renders exactly as it always has.
let interpretedFrom: { question: string; matchedCommand: GittensoryMentionCommandName } | undefined;
if (command.name === "help" && command.unrecognizedText && settings.advisoryAiRouting?.intentRouting === true) {
const throttled = await maybeThrottleIntentRouting(env, { deliveryId, repoFullName, issueNumber: issue.number, commenter, settings });
if (!throttled) {
const classification = await classifyGittensoryIntent(env, {
text: command.unrecognizedText,
advisoryAiRouting: settings.advisoryAiRouting,
repoFullName,
issueNumber: issue.number,
actor: commenter,
route: "github_app.intent_routing",
});
if (classification.status === "matched") {
const matchedCommand = classification.command;
interpretedFrom = { question: command.unrecognizedText, matchedCommand };
command = {
name: matchedCommand,
raw: command.raw,
question: matchedCommand === "ask" || matchedCommand === "chat" ? command.unrecognizedText : undefined,
};
}
}
}
// Re-assert the action-command exclusion TypeScript's control-flow narrowing loses across the `let`
// reassignment above: dead code by construction (INTENT_ROUTABLE_COMMANDS, github/commands.ts, never
// contains an action-command name, so `command.name` can never actually be one here), but restores
// `command.name`'s narrowed type for every reference below.
/* v8 ignore next */
if (isGittensoryActionCommand(command.name)) return false;

// Respect pause/dry-run/global-freeze like every other agent-driven write in this file (#2258) — the answer
// card is a live public comment post, same as gate-override's confirmation comment.
const mentionMode = resolveAgentActionMode({
Expand Down Expand Up @@ -12529,6 +12623,7 @@ async function maybeProcessGittensoryMentionCommand(
bundle,
maintainerDigest,
chatAnswer,
interpretedFrom,
env,
});
const responseComment = await createOrUpdateAgentCommandComment(
Expand Down
Loading
Loading