feat(coding-agent): added WATCHDOG.md discovery and advisor startup behavior updates
- Added discovery of local, user, and ancestor `WATCHDOG.md` files via `discoverWatchdogFiles`. - Appended discovered watchdog prompts to advisor system prompts during session setup. - Added protocol startup defaults that force `advisor.enabled` and `advisor.subagents` false. - Handled `maintainContext` failures and drained pending updates before token estimation.
This commit is contained in:
@@ -183,6 +183,83 @@ describe("advisor", () => {
|
||||
expect(promptInputs[1]).toContain("second");
|
||||
});
|
||||
|
||||
it("budgets only the batch sent after async context maintenance", async () => {
|
||||
const promptInputs: string[] = [];
|
||||
const { promise: firstMaintainStarted, resolve: startFirstMaintain } = Promise.withResolvers<void>();
|
||||
const { promise: finishFirstMaintain, resolve: releaseFirstMaintain } = Promise.withResolvers<boolean>();
|
||||
const { promise: firstPromptStarted, resolve: startFirstPrompt } = Promise.withResolvers<void>();
|
||||
const { promise: secondPromptStarted, resolve: startSecondPrompt } = Promise.withResolvers<void>();
|
||||
const { promise: finishFirstPrompt, resolve: releaseFirstPrompt } = Promise.withResolvers<void>();
|
||||
let maintainCalls = 0;
|
||||
let promptCalls = 0;
|
||||
const agent: AdvisorAgent = {
|
||||
prompt: async input => {
|
||||
promptInputs.push(input);
|
||||
promptCalls++;
|
||||
if (promptCalls === 1) {
|
||||
startFirstPrompt();
|
||||
await finishFirstPrompt;
|
||||
} else if (promptCalls === 2) {
|
||||
startSecondPrompt();
|
||||
}
|
||||
},
|
||||
abort: () => {},
|
||||
reset: () => {},
|
||||
state: { messages: [] },
|
||||
};
|
||||
const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
|
||||
const host: AdvisorRuntimeHost = {
|
||||
snapshotMessages: () => messages,
|
||||
enqueueAdvice: () => {},
|
||||
maintainContext: async () => {
|
||||
maintainCalls++;
|
||||
if (maintainCalls === 1) {
|
||||
startFirstMaintain();
|
||||
return await finishFirstMaintain;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
const runtime = new AdvisorRuntime(agent, host);
|
||||
|
||||
runtime.onTurnEnd();
|
||||
await firstMaintainStarted;
|
||||
messages.push({ role: "user", content: "second", timestamp: 2 } as AgentMessage);
|
||||
runtime.onTurnEnd();
|
||||
|
||||
releaseFirstMaintain(false);
|
||||
await firstPromptStarted;
|
||||
expect(promptInputs).toHaveLength(1);
|
||||
expect(promptInputs[0]).toContain("first");
|
||||
expect(promptInputs[0]).not.toContain("second");
|
||||
|
||||
releaseFirstPrompt();
|
||||
await secondPromptStarted;
|
||||
expect(promptInputs).toHaveLength(2);
|
||||
expect(promptInputs[1]).toContain("second");
|
||||
});
|
||||
|
||||
it("sends the batch when context maintenance fails", async () => {
|
||||
const promptInputs: string[] = [];
|
||||
const agent = makeAgent(promptInputs);
|
||||
const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
|
||||
const host: AdvisorRuntimeHost = {
|
||||
snapshotMessages: () => messages,
|
||||
enqueueAdvice: () => {},
|
||||
maintainContext: async () => {
|
||||
throw new Error("maintenance failed");
|
||||
},
|
||||
};
|
||||
const runtime = new AdvisorRuntime(agent, host);
|
||||
|
||||
runtime.onTurnEnd();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(promptInputs).toHaveLength(1);
|
||||
expect(promptInputs[0]).toContain("first");
|
||||
});
|
||||
|
||||
it("excludes advisor custom messages from the rendered delta", () => {
|
||||
const promptInputs: string[] = [];
|
||||
const agent = makeAgent(promptInputs);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./advise-tool";
|
||||
export * from "./runtime";
|
||||
export * from "./watchdog";
|
||||
|
||||
@@ -104,22 +104,29 @@ export class AdvisorRuntime {
|
||||
this.#busy = true;
|
||||
try {
|
||||
while (!this.#disposed && this.#pending.length) {
|
||||
const candidateBatch = this.#pending.join("\n\n---\n\n");
|
||||
const pendingBatch = this.#pending.splice(0).join("\n\n---\n\n");
|
||||
const incomingTokens = estimateTokens({
|
||||
role: "user",
|
||||
content: candidateBatch,
|
||||
content: pendingBatch,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
let batch: string | null;
|
||||
if (this.host.maintainContext && (await this.host.maintainContext(incomingTokens))) {
|
||||
let shouldReprime = false;
|
||||
if (this.host.maintainContext) {
|
||||
try {
|
||||
shouldReprime = await this.host.maintainContext(incomingTokens);
|
||||
} catch (err) {
|
||||
logger.debug("advisor context maintenance failed", { err: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
let batch: string | null = pendingBatch;
|
||||
if (shouldReprime) {
|
||||
// Promotion could not fit the advisor's context — re-prime: drop the
|
||||
// accumulated review history and replay the current (primary-bounded)
|
||||
// transcript so the next turn resumes from a fresh, in-window context.
|
||||
this.reset();
|
||||
batch = this.#renderDelta();
|
||||
} else {
|
||||
batch = this.#pending.splice(0).join("\n\n---\n\n");
|
||||
}
|
||||
if (this.#disposed || batch === null) continue;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
import { expandAtImports } from "../discovery/at-imports";
|
||||
import { repo } from "../utils/git";
|
||||
|
||||
/**
|
||||
* Discover and load WATCHDOG.md files walking up from cwd, project .omp folder, and user agent dir.
|
||||
* Returns formatted watchdog file blocks ready to be appended to the advisor system prompt.
|
||||
*/
|
||||
export async function discoverWatchdogFiles(cwd: string, agentDir?: string): Promise<string[]> {
|
||||
const home = os.homedir();
|
||||
const resolvedAgentDir = agentDir ?? getAgentDir();
|
||||
const userPath = resolvedAgentDir ? path.resolve(resolvedAgentDir, "WATCHDOG.md") : null;
|
||||
let repoRoot: string | null = null;
|
||||
try {
|
||||
repoRoot = await repo.root(cwd);
|
||||
} catch (err) {
|
||||
logger.debug("Failed to resolve git root for watchdog discovery", { err: String(err) });
|
||||
}
|
||||
|
||||
const candidates = new Set<string>();
|
||||
|
||||
// 1. User level: ~/.omp/WATCHDOG.md (or active profile agent dir)
|
||||
if (resolvedAgentDir) {
|
||||
candidates.add(path.resolve(resolvedAgentDir, "WATCHDOG.md"));
|
||||
}
|
||||
|
||||
// 2. Project levels (both standalone and native config .omp/): walk up from cwd to repoRoot / home
|
||||
let current = cwd;
|
||||
while (true) {
|
||||
candidates.add(path.resolve(current, ".omp", "WATCHDOG.md"));
|
||||
candidates.add(path.resolve(current, "WATCHDOG.md"));
|
||||
|
||||
if (current === (repoRoot ?? home)) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
const items: Array<{ path: string; content: string; level: "user" | "project"; depth: number }> = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const content = await Bun.file(candidate).text();
|
||||
const expanded = await expandAtImports(content, candidate);
|
||||
const parent = path.dirname(candidate);
|
||||
const baseName = parent.split(path.sep).pop() ?? "";
|
||||
|
||||
const isUser = userPath !== null && candidate === userPath;
|
||||
const ownerDir = baseName === ".omp" ? path.dirname(parent) : parent;
|
||||
const ownerBaseName = ownerDir.split(path.sep).pop() ?? "";
|
||||
|
||||
if (isUser || !ownerBaseName.startsWith(".") || baseName === ".omp") {
|
||||
const relative = path.relative(cwd, ownerDir);
|
||||
const depth = relative === "" ? 0 : relative.split(path.sep).filter(Boolean).length;
|
||||
items.push({
|
||||
path: candidate,
|
||||
content: expanded,
|
||||
level: isUser ? "user" : "project",
|
||||
depth,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isEnoent(err)) {
|
||||
logger.warn("Failed to read WATCHDOG.md candidate", { path: candidate, error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort files so that user level comes first, then project level sorted by depth (descending).
|
||||
// This means user-level rules are first, then project-level rules from ancestor directories down to the leaf directory (depth 0 is last/most prominent).
|
||||
items.sort((a, b) => {
|
||||
if (a.level !== b.level) {
|
||||
return a.level === "user" ? -1 : 1;
|
||||
}
|
||||
return b.depth - a.depth;
|
||||
});
|
||||
|
||||
return items.map(item => {
|
||||
return `Especially pay attention to:\n<attention>\n${item.content}\n</attention>`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user