Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/command-handbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ Use these directly in OpenCode:
/gateway doctor
/gateway watchdog status
/gateway watchdog doctor
/gateway watchdog set --warning-threshold-seconds 300 --tool-call-threshold 50
/gateway watchdog set --warning-threshold-seconds 60 --tool-call-threshold 12 --reminder-cooldown-seconds 60
/gateway watchdog disable
/gateway continuation report --minutes 120 --limit 10 --json
/gateway tune memory --json
Expand Down
6 changes: 3 additions & 3 deletions plugin/gateway-core/dist/config/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,9 @@ export const DEFAULT_GATEWAY_CONFIG = {
},
longTurnWatchdog: {
enabled: true,
warningThresholdMs: 300000,
toolCallWarningThreshold: 50,
reminderCooldownMs: 120000,
warningThresholdMs: 60000,
toolCallWarningThreshold: 12,
reminderCooldownMs: 60000,
maxSessionStateEntries: 1024,
prefix: "[Turn Watchdog]:",
},
Expand Down
14 changes: 11 additions & 3 deletions plugin/gateway-core/dist/hooks/agent-denied-tool-enforcer/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { writeGatewayEventAudit } from "../../audit/event-audit.js";
import { loadAgentMetadata } from "../shared/agent-metadata.js";
import { resolveDelegationTraceId } from "../shared/delegation-trace.js";
import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js";
import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js";
function sessionId(payload) {
return String(payload.input?.sessionID ?? payload.input?.sessionId ?? "").trim();
}
Expand Down Expand Up @@ -171,7 +171,11 @@ export function createAgentDeniedToolEnforcerHook(options) {
R: "read_only_safe",
N: "unclear",
},
cacheKey: `mutation:${subagentType}:${combinedText}`,
cacheKey: buildCompactDecisionCacheKey({
prefix: "mutation",
parts: [subagentType || "none"],
text: compactDecisionText(promptText, descriptionText),
}),
});
if (mutationDecision.accepted && mutationDecision.char === "M") {
writeDecisionComparisonAudit({
Expand Down Expand Up @@ -217,7 +221,11 @@ export function createAgentDeniedToolEnforcerHook(options) {
A: "allowed_or_no_issue",
N: "unclear",
},
cacheKey: `tool:${subagentType}:${denied.join(",")}:${combinedText}`,
cacheKey: buildCompactDecisionCacheKey({
prefix: "tool",
parts: [subagentType || "none", denied.join(",") || "none"],
text: compactDecisionText(promptText, descriptionText),
}),
});
if (toolDecision.accepted && toolDecision.char === "D") {
const suggestion = suggestAllowedTool(String(denied[0]), allowed);
Expand Down
8 changes: 6 additions & 2 deletions plugin/gateway-core/dist/hooks/agent-model-resolver/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { writeGatewayEventAudit } from "../../audit/event-audit.js";
import { loadAgentMetadata } from "../shared/agent-metadata.js";
import { annotateDelegationMetadata, resolveDelegationTraceId } from "../shared/delegation-trace.js";
import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js";
import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js";
const MODEL_BY_CATEGORY = {
quick: { model: "openai/gpt-5.1-codex-mini", reasoning: "low" },
balanced: { model: "openai/gpt-5.3-codex", reasoning: "medium" },
Expand Down Expand Up @@ -387,7 +387,11 @@ export function createAgentModelResolverHook(options) {
context: buildRoutingContext(String(args.prompt ?? ""), String(args.description ?? ""), originalExplicitSubagent, aiInferred.name, aiInferred.score, explicitScore),
allowedChars: alphabet,
decisionMeaning: buildRoutingDecisionMeaning(aiInferred.name, originalExplicitSubagent),
cacheKey: `route:${originalExplicitSubagent || "none"}:${aiInferred.name}:${combinedText}`,
cacheKey: buildCompactDecisionCacheKey({
prefix: "route",
parts: [originalExplicitSubagent || "none", aiInferred.name],
text: buildRoutingContext(String(args.prompt ?? ""), String(args.description ?? ""), originalExplicitSubagent, aiInferred.name, aiInferred.score, explicitScore),
}),
});
if (decision.accepted) {
const resolvedChar = decision.char.toUpperCase();
Expand Down
40 changes: 23 additions & 17 deletions plugin/gateway-core/dist/hooks/auto-slash-command/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { writeGatewayEventAudit } from "../../audit/event-audit.js";
import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js";
import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js";
const AUTO_SLASH_COMMAND_TAG_OPEN = "<auto-slash-command>";
const AUTO_SLASH_COMMAND_TAG_CLOSE = "</auto-slash-command>";
const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/;
const EXCLUDED_COMMANDS = new Set(["ulw-loop"]);
const INLINE_SLASH_TOKEN_PATTERN = /(^|\s)\/([a-zA-Z][\w-]*)\b/g;
const HIGH_RISK_SKIP_PATTERN = /\b(install|npm\s+install|brew\s+install|setup|configure|deploy|production)\b/i;
const DETERMINISTIC_DOCTOR_PATTERN = /\b(doctor|diagnos(?:e|is|tic|tics))\b/i;
const DIAGNOSTIC_CUE_PATTERN = /\b(doctor|diagnos(?:e|is|tic|tics)|health(?:\s+check)?|debug|investigat(?:e|ion)|inspect)\b/i;
const ACTION_VERB_PATTERN = /\b(run|open|use|launch|start|check|perform|do|inspect|investigate|debug|review|analy[sz]e|look\s+into|tell\s+me|show\s+me|help\s+me\s+understand)\b/i;
const META_DISCUSSION_SKIP_PATTERN = /\b(last session|previous session|instruction command|prompt wording|prompt text|slash doctor|auto[-\s]?slash|why did|why does|routed to|route to|activated \/doctor|triggered \/doctor|command behavior)\b/i;
const INVESTIGATION_CONTEXT_PATTERN = /\b(issue|environment|state|problem|wrong|error|failure|symptom|health)\b/i;
const AI_AUTO_SLASH_CHAR_TO_COMMAND = {
D: "/doctor",
};
Expand Down Expand Up @@ -151,19 +156,24 @@ function detectSlash(prompt) {
return { slash: explicit.raw, excludedExplicit: false };
}
const text = cleaned.toLowerCase();
if (text.includes("doctor") || text.includes("diagnose") || text.includes("health check")) {
if (!META_DISCUSSION_SKIP_PATTERN.test(text) && DETERMINISTIC_DOCTOR_PATTERN.test(text) && ACTION_VERB_PATTERN.test(text)) {
return { slash: "/doctor", excludedExplicit: false };
}
return { slash: null, excludedExplicit: false };
}
function shouldSkipAiAutoSlash(prompt) {
return HIGH_RISK_SKIP_PATTERN.test(prompt);
const hasInvestigativeIntent = ACTION_VERB_PATTERN.test(prompt);
const hasEligibleContext = DIAGNOSTIC_CUE_PATTERN.test(prompt) || INVESTIGATION_CONTEXT_PATTERN.test(prompt);
return (HIGH_RISK_SKIP_PATTERN.test(prompt) ||
META_DISCUSSION_SKIP_PATTERN.test(prompt) ||
!hasInvestigativeIntent ||
!hasEligibleContext);
}
function shouldSkipAutoSlash(prompt) {
return HIGH_RISK_SKIP_PATTERN.test(prompt);
}
function buildAiSlashInstruction() {
return "Classify only the sanitized user request text for diagnostics intent. D=diagnostics_or_health_check, N=not_diagnostics.";
return "Classify only the sanitized user request text for explicit diagnostics intent. Return D only when the user is clearly asking to run or perform diagnostics or health checks now. Return N for meta discussion about prompts, routing, commands, past sessions, or instruction wording.";
}
function buildAiSlashContext(prompt) {
return `request=${normalizePromptForAi(prompt) || "(empty)"}`;
Expand Down Expand Up @@ -210,7 +220,10 @@ export function createAutoSlashCommandHook(options) {
D: "route_doctor",
N: "no_slash",
},
cacheKey: `auto-slash:${prompt.trim().toLowerCase()}`,
cacheKey: buildCompactDecisionCacheKey({
prefix: "auto-slash",
text: normalizePromptForAi(prompt),
}),
});
}
catch (error) {
Expand All @@ -236,27 +249,20 @@ export function createAutoSlashCommandHook(options) {
deterministicValue: "none",
aiValue: aiSlash ?? "none",
});
const shadowDeferred = options.decisionRuntime.config.mode === "shadow" && aiSlash;
writeGatewayEventAudit(directory, {
hook: "auto-slash-command",
stage: "state",
reason_code: "llm_auto_slash_decision_recorded",
reason_code: shadowDeferred
? "llm_auto_slash_shadow_deferred"
: "llm_auto_slash_decision_recorded",
session_id: sessionId,
llm_decision_char: decision.char,
llm_decision_meaning: decision.meaning,
llm_decision_mode: options.decisionRuntime.config.mode,
slash_command: aiSlash ?? undefined,
});
if (options.decisionRuntime.config.mode === "shadow" && aiSlash) {
writeGatewayEventAudit(directory, {
hook: "auto-slash-command",
stage: "state",
reason_code: "llm_auto_slash_shadow_deferred",
session_id: sessionId,
llm_decision_char: decision.char,
llm_decision_meaning: decision.meaning,
llm_decision_mode: options.decisionRuntime.config.mode,
slash_command: aiSlash,
});
if (shadowDeferred) {
}
else {
slash = aiSlash;
Expand Down
18 changes: 0 additions & 18 deletions plugin/gateway-core/dist/hooks/context-window-monitor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,34 +141,16 @@ export function createContextWindowMonitorHook(options) {
});
const actualUsage = totalInputTokens / actualLimit;
if (actualUsage < options.warningThreshold) {
writeGatewayEventAudit(directory, {
hook: "context-window-monitor",
stage: "skip",
reason_code: "below_warning_threshold",
session_id: sessionId,
});
return;
}
const hasPriorReminder = nextState.lastWarnedAtToolCall > 0;
if (hasPriorReminder) {
const cooldownElapsed = nextState.toolCalls - nextState.lastWarnedAtToolCall >= options.reminderCooldownToolCalls;
const tokenDeltaEnough = totalInputTokens - nextState.lastWarnedTokens >= options.minTokenDeltaForReminder;
if (!cooldownElapsed) {
writeGatewayEventAudit(directory, {
hook: "context-window-monitor",
stage: "skip",
reason_code: "reminder_cooldown_not_elapsed",
session_id: sessionId,
});
return;
}
if (!tokenDeltaEnough) {
writeGatewayEventAudit(directory, {
hook: "context-window-monitor",
stage: "skip",
reason_code: "reminder_token_delta_too_small",
session_id: sessionId,
});
return;
}
}
Expand Down
36 changes: 0 additions & 36 deletions plugin/gateway-core/dist/hooks/continuation/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,6 @@ export function createContinuationHook(options) {
: options.directory;
const sessionId = resolveSessionId(eventPayload);
if (!sessionId) {
writeGatewayEventAudit(directory, {
hook: "continuation",
stage: "skip",
reason_code: "missing_session_id",
});
return;
}
let state = loadGatewayState(directory);
Expand All @@ -240,29 +235,12 @@ export function createContinuationHook(options) {
}
}
if (!state || !active || active.active !== true) {
writeGatewayEventAudit(directory, {
hook: "continuation",
stage: "skip",
reason_code: "no_active_loop",
});
return;
}
if (options.stopGuard?.isStopped(sessionId)) {
writeGatewayEventAudit(directory, {
hook: "continuation",
stage: "skip",
reason_code: "stop_guard_active",
session_id: sessionId,
});
return;
}
if (!sessionId || sessionId !== active.sessionId) {
writeGatewayEventAudit(directory, {
hook: "continuation",
stage: "skip",
reason_code: "session_mismatch",
has_session_id: sessionId.length > 0,
});
return;
}
const client = options.client?.session;
Expand Down Expand Up @@ -296,13 +274,6 @@ export function createContinuationHook(options) {
}
state.lastUpdatedAt = nowIso();
saveGatewayState(directory, state);
writeGatewayEventAudit(directory, {
hook: "continuation",
stage: "skip",
reason_code: REASON_CODES.LOOP_COMPLETION_IGNORED_INCOMPLETE_RUNTIME,
session_id: sessionId,
ignored_completion_cycles: ignoredCycles,
});
}
else {
active.active = false;
Expand Down Expand Up @@ -360,13 +331,6 @@ export function createContinuationHook(options) {
directory,
});
if (!safety.safe) {
writeGatewayEventAudit(directory, {
hook: "continuation",
stage: "skip",
reason_code: `idle_prompt_${safety.reason}`,
session_id: sessionId,
iteration: active.iteration,
});
return;
}
const mode = options.keywordDetector?.modeForSession(sessionId) ?? null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { writeGatewayEventAudit } from "../../audit/event-audit.js";
import { writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js";
import { buildCompactDecisionCacheKey, writeDecisionComparisonAudit, } from "../shared/llm-decision-runtime.js";
import { annotateDelegationMetadata, extractDelegationTraceId, resolveDelegationTraceId, } from "../shared/delegation-trace.js";
const FAILURE_REASON_BY_CHAR = {
U: "delegation_unknown_agent",
Expand Down Expand Up @@ -171,7 +171,11 @@ export function createDelegationFallbackOrchestratorHook(options) {
R: "delegation_runtime_error",
N: "no_match",
},
cacheKey: `delegation-failure:${subagentType}:${category}:${String(eventPayload.output.output ?? "").trim().toLowerCase()}`,
cacheKey: buildCompactDecisionCacheKey({
prefix: "delegation-failure",
parts: [subagentType || "none", category || "none"],
text: buildFailureContext(String(eventPayload.output.output ?? ""), String(args?.prompt ?? ""), String(args?.description ?? "")),
}),
});
if (decision.accepted) {
const aiReason = FAILURE_REASON_BY_CHAR[decision.char] ?? null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { GatewayHook } from "../registry.js";
import type { LlmDecisionRuntime } from "../shared/llm-decision-runtime.js";
import { type LlmDecisionRuntime } from "../shared/llm-decision-runtime.js";
export declare function createDoneProofEnforcerHook(options: {
enabled: boolean;
requiredMarkers: string[];
Expand Down
6 changes: 5 additions & 1 deletion plugin/gateway-core/dist/hooks/done-proof-enforcer/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { markerCategory, validationEvidenceStatus } from "../validation-evidence-ledger/evidence.js";
import { buildCompactDecisionCacheKey } from "../shared/llm-decision-runtime.js";
import { writeGatewayEventAudit } from "../../audit/event-audit.js";
import { writeDecisionComparisonAudit } from "../shared/llm-decision-runtime.js";
import { listToolAfterOutputTexts, readCombinedToolAfterOutputText, writeToolAfterOutputChannelText, } from "../shared/tool-after-output.js";
Expand Down Expand Up @@ -50,7 +51,10 @@ export function createDoneProofEnforcerHook(options) {
context: buildMarkerContext(text),
allowedChars: ["Y", "N"],
decisionMeaning: { Y: `${marker}_present`, N: `${marker}_missing` },
cacheKey: `done-proof:${marker}:${text.trim().toLowerCase()}`,
cacheKey: buildCompactDecisionCacheKey({
prefix: `done-proof:${marker}`,
text: buildMarkerContext(text),
}),
});
if (decision.accepted) {
writeDecisionComparisonAudit({
Expand Down
44 changes: 1 addition & 43 deletions plugin/gateway-core/dist/hooks/long-turn-watchdog/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,7 @@
import type { GatewayHook } from "../registry.js";
interface GatewayClient {
session?: {
messages?(args: {
path: {
id: string;
};
query?: {
directory?: string;
};
}): Promise<{
data?: Array<{
info?: {
role?: string;
error?: unknown;
time?: {
completed?: number;
};
};
parts?: Array<{
type?: string;
text?: string;
synthetic?: boolean;
}>;
}>;
}>;
promptAsync(args: {
path: {
id: string;
};
body: {
parts: Array<{
type: string;
text: string;
}>;
};
query?: {
directory?: string;
};
}): Promise<void>;
};
}
export declare function createLongTurnWatchdogHook(options: {
directory: string;
client?: GatewayClient;
client?: unknown;
enabled: boolean;
warningThresholdMs: number;
toolCallWarningThreshold: number;
Expand All @@ -51,4 +10,3 @@ export declare function createLongTurnWatchdogHook(options: {
prefix: string;
now?: () => number;
}): GatewayHook;
export {};
Loading