Files
oh-my-pi/packages/coding-agent/src/advisor/config.ts
T
can1357 bc39ffa265 feat: introduced omptype validation package and migrated workspace dependencies
- Introduce `@oh-my-pi/omptype` as a new ArkType-compatible schema validation package featuring a lazy JIT runtime, JSON Schema emission, and compatibility adapters.
- Replace `arktype` across workspace packages and test utilities with `@oh-my-pi/omptype`.
- Add benchmark suites, tests, and documentation for the new validation engine and adapters.
- Update workspace build, test runner, and release configurations to include the new package.
2026-08-03 21:56:48 +02:00

342 lines
13 KiB
TypeScript

import * as fs from "node:fs/promises";
import * as path from "node:path";
import { type } from "@oh-my-pi/omptype";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
import { expandAtImports } from "../discovery/at-imports";
import { BUILTIN_TOOL_NAMES, normalizeToolNames } from "../tools/builtin-names";
import { collectConfigCandidates } from "./watchdog";
/**
* One advisor declared in a `WATCHDOG.yml` file. `model` is a model selector
* with an optional `:level` thinking suffix (e.g. `x-ai/grok-code-fast:high`),
* resolved exactly like any other model override; `tools` is a subset of
* `BUILTIN_TOOL_NAMES` — any built-in name, including mutating tools such as
* `edit`/`write`/`bash` (the advisor is a full agent). Omitted falls back to
* the default `read`/`grep`/`glob` subset; an explicit empty list grants no
* tools. `instructions` is the advisor's specialization, appended to the shared
* baseline.
*/
export interface AdvisorConfig {
name: string;
model?: string;
tools?: string[];
instructions?: string;
/** Per-advisor on/off toggle (default `true`). When `false`, the advisor
* stays in the roster but its runtime is never built — it shows `○` in
* the status line and `/advisor status` rather than disappearing. */
enabled?: boolean;
}
/**
* Runtime health of a single advisor, surfaced in stats and the status line.
* - `running` — actively processing primary turns
* - `paused` — user-toggled off via per-advisor switch (runtime disposed)
* - `quota_exhausted` — provider returned a quota/rate-limit error; the
* runtime auto-retries after a cooldown so it can resume without user action
* - `error` — repeated transient failures; backlog dropped to prevent stall
* - `no_model` — no model resolved for this advisor's role/explicit model
*/
export type AdvisorRuntimeStatus = "running" | "paused" | "quota_exhausted" | "error" | "no_model";
/**
* The result of walking the `WATCHDOG.yml`/`WATCHDOG.yaml` search path: the
* deduped advisor roster plus the concatenated top-level `instructions` baseline
* that is prepended (alongside `WATCHDOG.md`) to every advisor.
*/
export interface DiscoveredAdvisors {
advisors: AdvisorConfig[];
sharedInstructions: string | undefined;
}
const advisorEntrySchema = type({
name: "string",
"model?": "string",
"tools?": "string[]",
"instructions?": "string",
"enabled?": "boolean",
});
const watchdogYamlSchema = type({
"instructions?": "string",
"advisors?": advisorEntrySchema.array(),
});
/**
* Normalize an advisor name into a filesystem-/id-safe slug used for its
* transcript filename and session id: lowercase, non-alphanumerics collapsed to
* `-`, leading/trailing `-` trimmed. Falls back to `"advisor"` when nothing
* survives; callers dedupe collisions.
*/
export function slugifyAdvisorName(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "advisor";
}
const UUID_V7_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ADVISOR_PROVIDER_SESSION_KEY_SEPARATOR = "\u0000";
/**
* Returns a stable provider-facing UUIDv7 for one advisor within one primary session.
*
* Codex treats `session_id`/`conversation_id` as a UUID-shaped routing identity,
* so advisor labels such as `-advisor` stay local-only.
*/
export function getOrCreateAdvisorProviderSessionId(
ids: Map<string, string>,
primarySessionId: string | undefined,
slug: string,
randomSessionId: () => string = () => Bun.randomUUIDv7(),
): string | undefined {
if (!primarySessionId) return undefined;
const key = `${primarySessionId}${ADVISOR_PROVIDER_SESSION_KEY_SEPARATOR}${slug}`;
const existing = ids.get(key);
if (existing) return existing;
const next = randomSessionId();
if (!UUID_V7_PATTERN.test(next)) {
throw new Error("Advisor provider session id generator returned a non-UUIDv7 value");
}
ids.set(key, next);
return next;
}
/** Built tool names, for validating an advisor's `tools` list. */
const KNOWN_TOOL_NAMES = new Set<string>(BUILTIN_TOOL_NAMES);
/**
* Keep only valid tool names from an advisor's `tools` list, dropping unknowns
* with a warning. The advisor is a full agent, so any built tool may be granted;
* the runtime further filters to what's actually available this session.
* `undefined` means "use the default subset" (read/grep/glob); only an explicit
* raw empty list means "no tools".
*/
function filterAdvisorTools(tools: string[] | undefined, sourcePath: string): string[] | undefined {
if (tools === undefined) return undefined;
if (tools.length === 0) return [];
// Normalize legacy aliases (search→grep, find→glob) and dedupe before validating.
const filtered = normalizeToolNames(tools).filter(name => {
if (KNOWN_TOOL_NAMES.has(name)) return true;
logger.warn("Advisor config: dropping unknown tool", { path: sourcePath, tool: name });
return false;
});
return filtered.length > 0 ? filtered : undefined;
}
/**
* Discover advisor configs from `WATCHDOG.yml`/`WATCHDOG.yaml` files on the same
* user + project search path as `WATCHDOG.md`. Advisors are keyed by slug; a
* more-specific file (project leaf > project ancestor > user) replaces an earlier
* entry with the same slug. Top-level `instructions` across all files concatenate
* into the shared baseline. A malformed file is logged and skipped — never
* thrown — so a bad project config can't kill the session.
*/
export async function discoverAdvisorConfigs(cwd: string, agentDir?: string): Promise<DiscoveredAdvisors> {
const items = await collectConfigCandidates(cwd, agentDir, ["WATCHDOG.yml", "WATCHDOG.yaml"]);
const advisors = new Map<string, AdvisorConfig>();
const sharedParts: string[] = [];
for (const item of items) {
let parsed: unknown;
try {
parsed = YAML.parse(item.content);
} catch (err) {
logger.warn("Advisor config: failed to parse YAML", { path: item.path, error: String(err) });
continue;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
logger.warn("Advisor config: expected a YAML mapping", { path: item.path });
continue;
}
const result = watchdogYamlSchema(parsed);
if (result instanceof type.errors) {
logger.warn("Advisor config: invalid schema", { path: item.path, error: result.summary });
continue;
}
if (result.instructions?.trim()) {
const expanded = (await expandAtImports(result.instructions, item.path)).trim();
if (expanded) sharedParts.push(expanded);
}
for (const entry of result.advisors ?? []) {
const slug = slugifyAdvisorName(entry.name);
const instructions = entry.instructions?.trim()
? (await expandAtImports(entry.instructions, item.path)).trim() || undefined
: undefined;
advisors.set(slug, {
name: entry.name,
model: entry.model?.trim() || undefined,
tools: filterAdvisorTools(entry.tools, item.path),
instructions,
enabled: entry.enabled,
});
}
}
return {
advisors: [...advisors.values()],
sharedInstructions: sharedParts.length > 0 ? sharedParts.join("\n\n") : undefined,
};
}
/** Which level a `WATCHDOG.yml` lives at: the project root or the user agent dir. */
export type AdvisorConfigScope = "project" | "user";
/**
* The editable contents of a single `WATCHDOG.yml` file: the shared top-level
* `instructions` plus the advisor roster. Unlike {@link DiscoveredAdvisors}, this
* is one file's raw view (no cross-level merge, no `@import` expansion) so the
* config editor round-trips exactly what the user wrote.
*/
export interface WatchdogConfigDoc {
instructions?: string;
advisors: AdvisorConfig[];
}
/**
* Resolve the `WATCHDOG.yml` path for a scope: `project` → `<projectDir>/WATCHDOG.yml`
* (discovered by the project-level walk), `user` → `<agentDir>/WATCHDOG.yml` (the
* user-level candidate).
*/
export function advisorConfigFilePath(
scope: AdvisorConfigScope,
dirs: { projectDir: string; agentDir: string },
): string {
return path.join(scope === "user" ? dirs.agentDir : dirs.projectDir, "WATCHDOG.yml");
}
/**
* Resolve which `WATCHDOG.{yml,yaml}` to edit for a scope: prefer the canonical
* `.yml`, but when only a `.yaml` exists for that scope, edit it in place so an
* existing `.yaml` user isn't shown a blank editor and left with two files at the
* same precedence. Falls back to `.yml` when neither exists.
*/
export async function resolveAdvisorConfigEditPath(
scope: AdvisorConfigScope,
dirs: { projectDir: string; agentDir: string },
): Promise<string> {
const dir = scope === "user" ? dirs.agentDir : dirs.projectDir;
const yml = path.join(dir, "WATCHDOG.yml");
const yaml = path.join(dir, "WATCHDOG.yaml");
if (!(await Bun.file(yml).exists()) && (await Bun.file(yaml).exists())) return yaml;
return yml;
}
/**
* Load one `WATCHDOG.yml` file for editing — raw, un-merged, un-expanded. Missing,
* unparseable, or schema-invalid files yield an empty doc (never throws) so the
* editor opens cleanly on a fresh or broken file.
*/
export async function loadWatchdogConfigFile(filePath: string): Promise<WatchdogConfigDoc> {
let text: string;
try {
text = await Bun.file(filePath).text();
} catch (err) {
if (!isEnoent(err))
logger.warn("Advisor config: failed to read for edit", { path: filePath, error: String(err) });
return { advisors: [] };
}
let parsed: unknown;
try {
parsed = YAML.parse(text);
} catch (err) {
logger.warn("Advisor config: failed to parse for edit", { path: filePath, error: String(err) });
return { advisors: [] };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { advisors: [] };
const result = watchdogYamlSchema(parsed);
if (result instanceof type.errors) {
logger.warn("Advisor config: invalid schema for edit", { path: filePath, error: result.summary });
return { advisors: [] };
}
const advisors = (result.advisors ?? []).map(a => {
const advisor: AdvisorConfig = { name: a.name };
if (a.model?.trim()) advisor.model = a.model;
if (a.tools !== undefined) advisor.tools = [...a.tools];
if (a.instructions?.trim()) advisor.instructions = a.instructions;
if (a.enabled !== undefined) advisor.enabled = a.enabled;
return advisor;
});
const doc: WatchdogConfigDoc = { advisors };
if (result.instructions?.trim()) doc.instructions = result.instructions;
return doc;
}
/**
* Serialize an editable doc back to canonical, hand-editable `WATCHDOG.yml`.
* Multiline instruction fields use literal block scalars while scalar quoting
* delegates to Bun's YAML encoder. Round-trips through {@link loadWatchdogConfigFile}.
* Returns `""` for an empty doc.
*/
function appendYamlString(lines: string[], indent: string, key: string, value: string): void {
const hasSignificantLeadingWhitespace = value.split("\n").some(line => /^[ \t]/.test(line));
if (!value.includes("\n") || hasSignificantLeadingWhitespace) {
lines.push(`${indent}${key}: ${YAML.stringify(value)}`);
return;
}
const normalized = value.replaceAll("\r\n", "\n");
let trailingNewlines = 0;
for (let index = normalized.length - 1; index >= 0 && normalized[index] === "\n"; index--) {
trailingNewlines++;
}
const chomp = trailingNewlines === 0 ? "|2-" : trailingNewlines === 1 ? "|2" : "|2+";
const body = trailingNewlines === 0 ? normalized : normalized.slice(0, -trailingNewlines);
lines.push(`${indent}${key}: ${chomp}`);
for (const line of body.split("\n")) {
lines.push(`${indent} ${line}`);
}
for (let index = 1; index < trailingNewlines; index++) {
lines.push(`${indent} `);
}
}
export function serializeWatchdogConfig(doc: WatchdogConfigDoc): string {
const lines: string[] = [];
if (doc.instructions?.trim()) appendYamlString(lines, "", "instructions", doc.instructions);
if (doc.advisors.length > 0) {
lines.push("advisors:");
for (const advisor of doc.advisors) {
lines.push(` - name: ${YAML.stringify(advisor.name)}`);
if (advisor.model?.trim()) lines.push(` model: ${YAML.stringify(advisor.model)}`);
if (advisor.tools !== undefined) {
if (advisor.tools.length === 0) {
lines.push(" tools: []");
} else {
lines.push(" tools:");
for (const tool of advisor.tools) {
lines.push(` - ${YAML.stringify(tool)}`);
}
}
}
if (advisor.instructions?.trim()) {
appendYamlString(lines, " ", "instructions", advisor.instructions);
}
if (advisor.enabled !== undefined) lines.push(` enabled: ${advisor.enabled}`);
}
}
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
}
/**
* Write an editable doc to `WATCHDOG.yml`. An empty doc removes the file so
* discovery falls back to the legacy single-advisor path rather than leaving an
* empty config behind.
*/
export async function saveWatchdogConfigFile(filePath: string, doc: WatchdogConfigDoc): Promise<void> {
const content = serializeWatchdogConfig(doc);
if (!content.trim()) {
try {
await fs.rm(filePath, { force: true });
} catch (err) {
if (!isEnoent(err)) throw err;
}
return;
}
await Bun.write(filePath, content);
}