mirror of
https://github.com/SikongJueluo/pi-extensions.git
synced 2026-10-05 11:52:55 +08:00
feat(ai-judge): global config module with validation and cohort identity
This commit is contained in:
@@ -0,0 +1,153 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS, MIN_TIMEOUT_MS } from "./model";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Judge configuration (PIEXTENSIO-3 Config acceptance, PIEXTENSIO-11
|
||||||
|
* configuration contract).
|
||||||
|
*
|
||||||
|
* The single user-configurable field is `timeoutMs` because acceptable
|
||||||
|
* interactive wait varies by deployment. Mode is read but v0.1 authority is
|
||||||
|
* mechanically fail-closed: `enforce` loads but the judge truth table
|
||||||
|
* (PIEXTENSIO-12) can never produce real authority until the promotion
|
||||||
|
* gates exist upstream, so it always defers.
|
||||||
|
*
|
||||||
|
* Global config path only — no project/env override, matching the trusted
|
||||||
|
* user-global Judge configuration of PIEXTENSIO-10.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type JudgeMode = "shadow" | "enforce";
|
||||||
|
|
||||||
|
export interface EffectiveJudgeConfig {
|
||||||
|
readonly mode: JudgeMode;
|
||||||
|
readonly timeoutMs: number;
|
||||||
|
/**
|
||||||
|
* Cohort identity: a non-default timeout is a distinct configuration
|
||||||
|
* cohort and never inherits the default cohort's calibration
|
||||||
|
* (PIEXTENSIO-11). `default` marks the calibrated default.
|
||||||
|
*/
|
||||||
|
readonly timeoutCohort: "default" | number;
|
||||||
|
/** Validation diagnostics for the loaded raw file, newest wins per key. */
|
||||||
|
readonly diagnostics: readonly ConfigDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigDiagnostic {
|
||||||
|
readonly key: string;
|
||||||
|
readonly problem: string;
|
||||||
|
readonly fallback: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigLoadDeps {
|
||||||
|
/** User-global agent dir (`~/.pi/agent`). */
|
||||||
|
readonly agentDir: string;
|
||||||
|
/** Injectable for tests; defaults to `readFileSync`. */
|
||||||
|
readonly readFile?: (path: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_FILENAME = "pi-permission-ai-judge.config.json";
|
||||||
|
const DEFAULT_CONFIG: EffectiveJudgeConfig = {
|
||||||
|
mode: "shadow",
|
||||||
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||||
|
timeoutCohort: "default",
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and validate the global config. Missing file, malformed JSON,
|
||||||
|
* unknown mode, or out-of-range timeout all fail closed to the documented
|
||||||
|
* defaults (PIEXTENSIO-11: "invalid values fail closed to the documented
|
||||||
|
* default rather than becoming unbounded") and record a diagnostic.
|
||||||
|
*/
|
||||||
|
export function loadJudgeConfig(
|
||||||
|
deps: ConfigLoadDeps,
|
||||||
|
): EffectiveJudgeConfig {
|
||||||
|
const read = deps.readFile ?? ((p: string) => readFileSync(p, "utf-8"));
|
||||||
|
const path = join(deps.agentDir, CONFIG_FILENAME);
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = read(path);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
key: "file",
|
||||||
|
problem: `config not readable at ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
fallback: "all defaults",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
key: "file",
|
||||||
|
problem: `malformed JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
fallback: "all defaults",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||||
|
return {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
key: "file",
|
||||||
|
problem: "top-level value is not an object",
|
||||||
|
fallback: "all defaults",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = parsed as Record<string, unknown>;
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
|
||||||
|
// Mode: unknown or missing resolves to shadow (fail-closed).
|
||||||
|
let mode: JudgeMode = "shadow";
|
||||||
|
if (record.mode !== undefined) {
|
||||||
|
if (record.mode === "shadow" || record.mode === "enforce") {
|
||||||
|
mode = record.mode;
|
||||||
|
} else {
|
||||||
|
diagnostics.push({
|
||||||
|
key: "mode",
|
||||||
|
problem: `unknown mode ${JSON.stringify(record.mode)}`,
|
||||||
|
fallback: "shadow",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout: integers in [5_000, 30_000]; anything else falls back to the
|
||||||
|
// documented 15,000 ms default. Boundary semantics: 4,999 and 30,001
|
||||||
|
// are invalid, 5,000 and 30,000 are valid (PIEXTENSIO-3 boundaries).
|
||||||
|
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
||||||
|
let timeoutCohort: EffectiveJudgeConfig["timeoutCohort"] = "default";
|
||||||
|
if (record.timeoutMs !== undefined) {
|
||||||
|
const value = record.timeoutMs;
|
||||||
|
if (
|
||||||
|
typeof value === "number" &&
|
||||||
|
Number.isInteger(value) &&
|
||||||
|
value >= MIN_TIMEOUT_MS &&
|
||||||
|
value <= MAX_TIMEOUT_MS
|
||||||
|
) {
|
||||||
|
timeoutMs = value;
|
||||||
|
timeoutCohort = value === DEFAULT_TIMEOUT_MS ? "default" : value;
|
||||||
|
} else {
|
||||||
|
diagnostics.push({
|
||||||
|
key: "timeoutMs",
|
||||||
|
problem: `invalid timeoutMs ${JSON.stringify(value)}`,
|
||||||
|
fallback: `${DEFAULT_TIMEOUT_MS} (default)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({ mode, timeoutMs, timeoutCohort, diagnostics });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||||
import {
|
import {
|
||||||
getPermissionsService,
|
getPermissionsService,
|
||||||
PERMISSIONS_READY_CHANNEL,
|
PERMISSIONS_READY_CHANNEL,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
type ModelAvailability,
|
type ModelAvailability,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
import { PROMPT_VERSION, TOOL_SCHEMA_VERSION } from "./prompt";
|
import { PROMPT_VERSION, TOOL_SCHEMA_VERSION } from "./prompt";
|
||||||
|
import { loadJudgeConfig, type EffectiveJudgeConfig } from "./config";
|
||||||
|
|
||||||
const LINK_NAME = "ai-bash-judge";
|
const LINK_NAME = "ai-bash-judge";
|
||||||
const REVIEW_SCHEMA_VERSION = 1;
|
const REVIEW_SCHEMA_VERSION = 1;
|
||||||
@@ -22,6 +24,9 @@ interface RootSession {
|
|||||||
readonly shutdown: AbortController;
|
readonly shutdown: AbortController;
|
||||||
/** Opaque per-runtime identity for cohort segmentation. */
|
/** Opaque per-runtime identity for cohort segmentation. */
|
||||||
readonly judgeRuntimeId: string;
|
readonly judgeRuntimeId: string;
|
||||||
|
/** Immutable effective-config snapshot captured at session start
|
||||||
|
* (reload-only application: a config edit lands on the next session). */
|
||||||
|
readonly config: EffectiveJudgeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reasonLength(reason: string): number {
|
function reasonLength(reason: string): number {
|
||||||
@@ -61,12 +66,17 @@ function resultBase(
|
|||||||
judgeRuntimeId: string,
|
judgeRuntimeId: string,
|
||||||
details: PromptPermissionDetails,
|
details: PromptPermissionDetails,
|
||||||
startedAt: number,
|
startedAt: number,
|
||||||
|
config: EffectiveJudgeConfig,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
schemaVersion: REVIEW_SCHEMA_VERSION,
|
schemaVersion: REVIEW_SCHEMA_VERSION,
|
||||||
requestId: details.requestId,
|
requestId: details.requestId,
|
||||||
judgeRuntimeId,
|
judgeRuntimeId,
|
||||||
mode: "shadow",
|
mode: config.mode,
|
||||||
|
// v0.1 fail-closed: `enforce` loads here but the truth table can
|
||||||
|
// never grant real authority, so the effective verdict below stays
|
||||||
|
// defer; the configured mode is recorded for audit.
|
||||||
|
timeoutCohort: config.timeoutCohort,
|
||||||
origin:
|
origin:
|
||||||
details.forwarding !== undefined ||
|
details.forwarding !== undefined ||
|
||||||
details.payload.kind === "forwarded"
|
details.payload.kind === "forwarded"
|
||||||
@@ -113,6 +123,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
captured.judgeRuntimeId,
|
captured.judgeRuntimeId,
|
||||||
details,
|
details,
|
||||||
startedAt,
|
startedAt,
|
||||||
|
captured.config,
|
||||||
),
|
),
|
||||||
resultKind: "preflight_defer",
|
resultKind: "preflight_defer",
|
||||||
verdict: null,
|
verdict: null,
|
||||||
@@ -139,6 +150,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
captured.judgeRuntimeId,
|
captured.judgeRuntimeId,
|
||||||
details,
|
details,
|
||||||
startedAt,
|
startedAt,
|
||||||
|
captured.config,
|
||||||
),
|
),
|
||||||
resultKind: "preflight_defer",
|
resultKind: "preflight_defer",
|
||||||
verdict: null,
|
verdict: null,
|
||||||
@@ -157,6 +169,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
captured.judgeRuntimeId,
|
captured.judgeRuntimeId,
|
||||||
details,
|
details,
|
||||||
startedAt,
|
startedAt,
|
||||||
|
captured.config,
|
||||||
),
|
),
|
||||||
resultKind: "preflight_defer",
|
resultKind: "preflight_defer",
|
||||||
verdict: null,
|
verdict: null,
|
||||||
@@ -174,6 +187,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
captured.model,
|
captured.model,
|
||||||
evidence,
|
evidence,
|
||||||
captured.shutdown.signal,
|
captured.shutdown.signal,
|
||||||
|
captured.config.timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.kind === "judgment") {
|
if (result.kind === "judgment") {
|
||||||
@@ -182,6 +196,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
captured.judgeRuntimeId,
|
captured.judgeRuntimeId,
|
||||||
details,
|
details,
|
||||||
startedAt,
|
startedAt,
|
||||||
|
captured.config,
|
||||||
),
|
),
|
||||||
resultKind: "judgment",
|
resultKind: "judgment",
|
||||||
verdict: result.verdict,
|
verdict: result.verdict,
|
||||||
@@ -207,6 +222,7 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
captured.judgeRuntimeId,
|
captured.judgeRuntimeId,
|
||||||
details,
|
details,
|
||||||
startedAt,
|
startedAt,
|
||||||
|
captured.config,
|
||||||
),
|
),
|
||||||
resultKind: "infrastructure_failure",
|
resultKind: "infrastructure_failure",
|
||||||
verdict: null,
|
verdict: null,
|
||||||
@@ -253,7 +269,14 @@ export default function permissionAiJudge(pi: ExtensionAPI): void {
|
|||||||
model: createModelAvailability(ctx.model, ctx.modelRegistry),
|
model: createModelAvailability(ctx.model, ctx.modelRegistry),
|
||||||
shutdown: new AbortController(),
|
shutdown: new AbortController(),
|
||||||
judgeRuntimeId: crypto.randomUUID(),
|
judgeRuntimeId: crypto.randomUUID(),
|
||||||
|
config: loadJudgeConfig({ agentDir: getAgentDir() }),
|
||||||
};
|
};
|
||||||
|
for (const diagnostic of root.config.diagnostics) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`ai-bash-judge config: ${diagnostic.key} — ${diagnostic.problem}; using ${diagnostic.fallback}`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
}
|
||||||
tryRegister();
|
tryRegister();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { BashJudgmentEvidence } from "./evidence";
|
|||||||
// provider segments (e.g. zai glm-5.2, observed racing the deadline at
|
// provider segments (e.g. zai glm-5.2, observed racing the deadline at
|
||||||
// `thinking: high`) should configure a non-default timeoutMs within the range
|
// `thinking: high`) should configure a non-default timeoutMs within the range
|
||||||
// and be treated as a distinct configuration cohort.
|
// and be treated as a distinct configuration cohort.
|
||||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
export const DEFAULT_TIMEOUT_MS = 15_000;
|
||||||
export const MIN_TIMEOUT_MS = 5_000;
|
export const MIN_TIMEOUT_MS = 5_000;
|
||||||
export const MAX_TIMEOUT_MS = 30_000;
|
export const MAX_TIMEOUT_MS = 30_000;
|
||||||
// Reasoning-token aware cap. Providers that bill chain-of-thought inside
|
// Reasoning-token aware cap. Providers that bill chain-of-thought inside
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { loadJudgeConfig, type ConfigLoadDeps } from "../src/config";
|
||||||
|
|
||||||
|
function deps(files: Record<string, string> = {}): ConfigLoadDeps {
|
||||||
|
return {
|
||||||
|
agentDir: "/agent",
|
||||||
|
readFile: (path: string) => {
|
||||||
|
const content = files[path];
|
||||||
|
if (content === undefined) {
|
||||||
|
throw new Error("ENOENT");
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_PATH = "/agent/pi-permission-ai-judge.config.json";
|
||||||
|
|
||||||
|
describe("loadJudgeConfig — missing and malformed", () => {
|
||||||
|
it("resolves a missing file to all defaults with one diagnostic", () => {
|
||||||
|
const config = loadJudgeConfig(deps());
|
||||||
|
expect(config).toEqual({
|
||||||
|
mode: "shadow",
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
timeoutCohort: "default",
|
||||||
|
diagnostics: [
|
||||||
|
expect.objectContaining({ key: "file", fallback: "all defaults" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves malformed JSON to defaults", () => {
|
||||||
|
const config = loadJudgeConfig(
|
||||||
|
deps({ [CONFIG_PATH]: "{ not json" }),
|
||||||
|
);
|
||||||
|
expect(config.mode).toBe("shadow");
|
||||||
|
expect(config.timeoutMs).toBe(15_000);
|
||||||
|
expect(config.diagnostics[0]?.key).toBe("file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a non-object top level to defaults", () => {
|
||||||
|
const config = loadJudgeConfig(deps({ [CONFIG_PATH]: "[1,2,3]" }));
|
||||||
|
expect(config.mode).toBe("shadow");
|
||||||
|
expect(config.diagnostics[0]?.key).toBe("file");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadJudgeConfig — mode", () => {
|
||||||
|
it("accepts shadow and enforce", () => {
|
||||||
|
expect(
|
||||||
|
loadJudgeConfig(deps({ [CONFIG_PATH]: '{"mode":"shadow"}' })).mode,
|
||||||
|
).toBe("shadow");
|
||||||
|
expect(
|
||||||
|
loadJudgeConfig(deps({ [CONFIG_PATH]: '{"mode":"enforce"}' })).mode,
|
||||||
|
).toBe("enforce");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an unknown mode to shadow with a diagnostic", () => {
|
||||||
|
const config = loadJudgeConfig(
|
||||||
|
deps({ [CONFIG_PATH]: '{"mode":"yolo"}' }),
|
||||||
|
);
|
||||||
|
expect(config.mode).toBe("shadow");
|
||||||
|
expect(config.diagnostics).toEqual([
|
||||||
|
{
|
||||||
|
key: "mode",
|
||||||
|
problem: 'unknown mode "yolo"',
|
||||||
|
fallback: "shadow",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a missing mode to shadow without diagnostics", () => {
|
||||||
|
const config = loadJudgeConfig(deps({ [CONFIG_PATH]: "{}" }));
|
||||||
|
expect(config.mode).toBe("shadow");
|
||||||
|
expect(config.diagnostics).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadJudgeConfig — timeout boundaries", () => {
|
||||||
|
it.each([4_999, 30_001, 0, -5_000, 15.5, NaN, Infinity, "20000"])(
|
||||||
|
"rejects invalid timeoutMs %p with fallback to 15,000",
|
||||||
|
(value) => {
|
||||||
|
const config = loadJudgeConfig(
|
||||||
|
deps({ [CONFIG_PATH]: JSON.stringify({ timeoutMs: value }) }),
|
||||||
|
);
|
||||||
|
expect(config.timeoutMs).toBe(15_000);
|
||||||
|
expect(config.timeoutCohort).toBe("default");
|
||||||
|
expect(config.diagnostics[0]?.key).toBe("timeoutMs");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("accepts the inclusive boundaries 5,000 and 30,000", () => {
|
||||||
|
expect(
|
||||||
|
loadJudgeConfig(deps({ [CONFIG_PATH]: '{"timeoutMs":5000}' })),
|
||||||
|
).toMatchObject({ timeoutMs: 5_000, timeoutCohort: 5_000 });
|
||||||
|
expect(
|
||||||
|
loadJudgeConfig(deps({ [CONFIG_PATH]: '{"timeoutMs":30000}' })),
|
||||||
|
).toMatchObject({ timeoutMs: 30_000, timeoutCohort: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an explicit default timeout as the default cohort", () => {
|
||||||
|
const config = loadJudgeConfig(
|
||||||
|
deps({ [CONFIG_PATH]: '{"timeoutMs":15000}' }),
|
||||||
|
);
|
||||||
|
expect(config.timeoutCohort).toBe("default");
|
||||||
|
expect(config.diagnostics).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a non-default timeout as a distinct cohort", () => {
|
||||||
|
const config = loadJudgeConfig(
|
||||||
|
deps({ [CONFIG_PATH]: '{"timeoutMs":30000}' }),
|
||||||
|
);
|
||||||
|
expect(config.timeoutCohort).toBe(30_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadJudgeConfig — snapshot immutability", () => {
|
||||||
|
it("returns an immutable effective-config snapshot", () => {
|
||||||
|
const config = loadJudgeConfig(
|
||||||
|
deps({ [CONFIG_PATH]: '{"mode":"enforce","timeoutMs":20000}' }),
|
||||||
|
);
|
||||||
|
expect(Object.isFrozen(config)).toBe(true);
|
||||||
|
expect(config).toEqual({
|
||||||
|
mode: "enforce",
|
||||||
|
timeoutMs: 20_000,
|
||||||
|
timeoutCohort: 20_000,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -179,6 +179,7 @@ describe("AI judge lifecycle", () => {
|
|||||||
sessionManager,
|
sessionManager,
|
||||||
model,
|
model,
|
||||||
modelRegistry: { complete },
|
modelRegistry: { complete },
|
||||||
|
ui: { notify: vi.fn() },
|
||||||
} as unknown as ExtensionContext;
|
} as unknown as ExtensionContext;
|
||||||
|
|
||||||
const harness = createFakePi();
|
const harness = createFakePi();
|
||||||
@@ -264,6 +265,7 @@ describe("AI judge lifecycle", () => {
|
|||||||
api: "openai-codex-responses",
|
api: "openai-codex-responses",
|
||||||
} as Model<any>,
|
} as Model<any>,
|
||||||
modelRegistry: { complete },
|
modelRegistry: { complete },
|
||||||
|
ui: { notify: vi.fn() },
|
||||||
} as unknown as ExtensionContext;
|
} as unknown as ExtensionContext;
|
||||||
|
|
||||||
const harness = createFakePi();
|
const harness = createFakePi();
|
||||||
|
|||||||
Reference in New Issue
Block a user