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:
@@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed the SDK tool format type and resolver from `ToolCallFormat`/`resolveToolCallSyntax` to `DialectFormat`/`resolveDialect`, and the agent option from `toolCallSyntax` to `dialect`.
|
||||
@@ -8,6 +9,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Added automatic discovery of WATCHDOG.md files from user and project locations so advisor guidance from local watchdog instructions is appended to its system prompt
|
||||
- Added `/advisor on`, `/advisor off`, `/advisor status`, and `/advisor dump [raw]` slash-command subcommands to manage the advisor at runtime
|
||||
- Added `advisor.enabled` and `advisor.subagents` settings to enable the advisor and extend it to spawned task/eval subagents
|
||||
- Added advisor status badge (`++` in success color) to the status line when an advisor is active
|
||||
@@ -17,14 +19,18 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed advisor prompting guidance to emit at most one `advise` per update and to prefer silence when the agent is on track
|
||||
- Changed `/dump` default output to compact markdown format; use `/dump raw` for the legacy uncompact format
|
||||
- Changed `/dump` and `/advisor dump` to default to compact transcript output and accept an optional `raw` flag for the legacy uncompact format
|
||||
- Session dump output now renders message history using the model's native dialect turn envelope instead of markdown role headings
|
||||
- Changed RPC, RPC-UI, and ACP hosts to default the advisor settings off instead of inheriting a user's interactive-session advisor preference.
|
||||
|
||||
### Fixed
|
||||
- Fixed advisor message card notes getting truncated to two lines when the card is collapsed.
|
||||
|
||||
- Fixed advisor context handling to automatically maintain its token budget by promoting the advisor model or compacting/restarting advisor context when needed, preventing advice from degrading on long sessions
|
||||
- Fixed advisor context batching to still send pending review updates when context maintenance fails instead of dropping the batch
|
||||
- Fixed explicit advisor enablement to clear protocol-default overrides so enabling the advisor applies immediately
|
||||
- Fixed advisor message card notes getting truncated to two lines when the card is collapsed.
|
||||
- Fixed advisor context handling to maintain its token budget by promoting or compacting/restarting advisor context while preserving advisor reasoning-off settings.
|
||||
- Fixed `startup.quiet` leaving MCP and LSP startup status events visible during launch ([#2639](https://github.com/can1357/oh-my-pi/issues/2639)).
|
||||
- Registered the `Advisor` group in the `model` settings tab so advisor settings render correctly in the settings panel.
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
});
|
||||
}
|
||||
@@ -140,6 +140,10 @@ const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [
|
||||
// memory should opt in explicitly through their own settings layer.
|
||||
"memory.backend",
|
||||
"memories.enabled",
|
||||
// Advisor is interactive-session assistance. Protocol hosts opt in explicitly
|
||||
// instead of inheriting a user's globally-enabled local preference.
|
||||
"advisor.enabled",
|
||||
"advisor.subagents",
|
||||
];
|
||||
|
||||
const RPC_BACKGROUND_DEFAULTED_SETTING_PATHS: SettingPath[] = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<system-conventions>
|
||||
RFC 2119 applies: MUST, SHOULD, AVOID, NEVER (= NEVER). You are a pair programmer with independent perspective — your only output is the `advise` tool.
|
||||
RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` and `AVOID` are aliases for `MUST NOT` and `SHOULD NOT`.
|
||||
You can explore the workspace; budget is 2–3 tool calls per advise (exception: critical bugs warrant deeper verification before raising a blocker).
|
||||
</system-conventions>
|
||||
|
||||
@@ -14,7 +14,7 @@ Keep exploration lean — 2–3 calls per advise unless you've spotted a critica
|
||||
</workflow>
|
||||
|
||||
<communication>
|
||||
One `advise` per update. Address the agent directly. Offer alternatives, not lectures. Never restate what they know; never explain how to use the advisor.
|
||||
At most one `advise` per update. Prefer silence when the agent is on track. Address the agent directly. Offer alternatives, not lectures. Never restate what they know; never explain how to use the advisor.
|
||||
</communication>
|
||||
|
||||
<critical>
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
prompt,
|
||||
Snowflake,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import { ADVISOR_READONLY_TOOL_NAMES } from "./advisor";
|
||||
import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles } from "./advisor";
|
||||
import { type AsyncJob, AsyncJobManager } from "./async";
|
||||
import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller";
|
||||
import { loadCapability } from "./capability";
|
||||
@@ -1142,6 +1142,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
? Promise.resolve(options.contextFiles)
|
||||
: logger.time("discoverContextFiles", discoverContextFiles, cwd, agentDir);
|
||||
contextFilesPromise.catch(() => {});
|
||||
const watchdogFilesPromise = logger.time("discoverWatchdogFiles", () => discoverWatchdogFiles(cwd, agentDir));
|
||||
watchdogFilesPromise.catch(() => {});
|
||||
const promptTemplatesPromise = options.promptTemplates
|
||||
? Promise.resolve(options.promptTemplates)
|
||||
: logger.time("discoverPromptTemplates", discoverPromptTemplates, cwd, agentDir);
|
||||
@@ -1371,9 +1373,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const [contextFiles, resolvedWorkspaceTree] = await Promise.all([
|
||||
const [contextFiles, resolvedWorkspaceTree, watchdogFiles] = await Promise.all([
|
||||
contextFilesPromise,
|
||||
raceWithDeadline("buildWorkspaceTree", workspaceTreePromise),
|
||||
watchdogFilesPromise,
|
||||
]);
|
||||
|
||||
let agent: Agent;
|
||||
@@ -2567,7 +2570,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
.filter((tool): tool is Tool => tool != null)
|
||||
.map(wrapToolWithMetaNotice);
|
||||
|
||||
let advisorWatchdogPrompt: string | undefined;
|
||||
if (watchdogFiles && watchdogFiles.length > 0) {
|
||||
advisorWatchdogPrompt = watchdogFiles.join("\n\n");
|
||||
}
|
||||
session = new AgentSession({
|
||||
advisorWatchdogPrompt,
|
||||
agent,
|
||||
thinkingLevel: autoThinking ? AUTO_THINKING : effectiveThinkingLevel,
|
||||
sessionManager,
|
||||
|
||||
@@ -480,6 +480,8 @@ export interface AgentSessionConfig {
|
||||
* caches. Undefined when the advisor is disabled.
|
||||
*/
|
||||
advisorReadOnlyTools?: AgentTool[];
|
||||
/** Preloaded watchdog prompt content for the advisor. */
|
||||
advisorWatchdogPrompt?: string;
|
||||
}
|
||||
|
||||
/** Options for AgentSession.prompt() */
|
||||
@@ -993,6 +995,7 @@ export class AgentSession {
|
||||
/** The advisor's own agent, retained so `/dump advisor` can serialize its transcript. Undefined when no advisor is active. */
|
||||
#advisorAgent?: Agent;
|
||||
#advisorReadOnlyTools?: AgentTool[];
|
||||
#advisorWatchdogPrompt?: string;
|
||||
#advisorYieldQueueUnsubscribe?: () => void;
|
||||
#goalTurnCounter = 0;
|
||||
#planReferenceSent = false;
|
||||
@@ -1285,6 +1288,7 @@ export class AgentSession {
|
||||
this.#skillsSettings = config.skillsSettings;
|
||||
this.#modelRegistry = config.modelRegistry;
|
||||
this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
|
||||
this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
|
||||
this.#validateRetryFallbackChains();
|
||||
this.#toolRegistry = config.toolRegistry ?? new Map();
|
||||
this.#requestedToolNames = config.requestedToolNames;
|
||||
@@ -1484,9 +1488,13 @@ export class AgentSession {
|
||||
|
||||
const appendOnlyContext = new AppendOnlyContextManager();
|
||||
const advisorThinkingLevel = advisorSel.thinkingLevel ?? ThinkingLevel.Medium;
|
||||
const systemPrompt = [advisorSystemPrompt];
|
||||
if (this.#advisorWatchdogPrompt) {
|
||||
systemPrompt.push(this.#advisorWatchdogPrompt);
|
||||
}
|
||||
const advisorAgent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: [advisorSystemPrompt],
|
||||
systemPrompt,
|
||||
model: advisorSel.model,
|
||||
thinkingLevel: toReasoningEffort(advisorThinkingLevel),
|
||||
tools: [adviseTool, ...advisorReadOnlyTools],
|
||||
@@ -1660,90 +1668,59 @@ export class AgentSession {
|
||||
return true;
|
||||
}
|
||||
|
||||
let action: "context-full" | "snapcompact" =
|
||||
compactionSettings.strategy === "snapcompact" && advisorModel.input.includes("image")
|
||||
? "snapcompact"
|
||||
: "context-full";
|
||||
const advisorCompactionThinkingLevel: ThinkingLevel | undefined = advisor.state.disableReasoning
|
||||
? ThinkingLevel.Off
|
||||
: advisor.state.thinkingLevel;
|
||||
|
||||
let summary: string;
|
||||
let shortSummary: string | undefined;
|
||||
let firstKeptEntryId: string;
|
||||
let tokensBefore: number;
|
||||
// Advisor state is in-memory-only, so snapcompact's frame archive has no
|
||||
// stable SessionEntry preserveData slot to carry across future advisor
|
||||
// maintenance runs. Use an LLM summary even when the primary session is
|
||||
// configured for snapcompact.
|
||||
const availableModels = this.#modelRegistry.getAvailable();
|
||||
const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels);
|
||||
if (candidates.length === 0) {
|
||||
// No compaction candidates, fallback to re-prime
|
||||
return true;
|
||||
}
|
||||
|
||||
let compactResult: CompactionResult | undefined;
|
||||
let lastError: unknown;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const apiKey = await this.#modelRegistry.getApiKey(
|
||||
candidate,
|
||||
this.sessionId ? `${this.sessionId}-advisor` : undefined,
|
||||
);
|
||||
if (!apiKey) continue;
|
||||
|
||||
// Try snapcompact first if vision model
|
||||
let snapcompactResult: snapcompact.CompactionResult | undefined;
|
||||
if (action === "snapcompact") {
|
||||
try {
|
||||
snapcompactResult = await snapcompact.compact(preparation, {
|
||||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||||
model: advisorModel,
|
||||
maxFrames: snapcompact.providerFrameBudget(advisorModel.provider),
|
||||
});
|
||||
const budget = contextWindow - effectiveReserveTokens(contextWindow, compactionSettings);
|
||||
const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
|
||||
if (projected > budget) {
|
||||
action = "context-full";
|
||||
snapcompactResult = undefined;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Advisor snapcompact failed, falling back to LLM summary", { error: String(err) });
|
||||
action = "context-full";
|
||||
}
|
||||
}
|
||||
|
||||
if (snapcompactResult) {
|
||||
summary = snapcompactResult.summary;
|
||||
shortSummary = snapcompactResult.shortSummary;
|
||||
firstKeptEntryId = snapcompactResult.firstKeptEntryId;
|
||||
tokensBefore = snapcompactResult.tokensBefore;
|
||||
} else {
|
||||
// Run LLM-summary compaction
|
||||
const availableModels = this.#modelRegistry.getAvailable();
|
||||
const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels);
|
||||
if (candidates.length === 0) {
|
||||
// No compaction candidates, fallback to re-prime
|
||||
return true;
|
||||
}
|
||||
|
||||
let compactResult: CompactionResult | undefined;
|
||||
let lastError: unknown;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const apiKey = await this.#modelRegistry.getApiKey(
|
||||
compactResult = await compact(
|
||||
preparation,
|
||||
candidate,
|
||||
this.sessionId ? `${this.sessionId}-advisor` : undefined,
|
||||
this.#modelRegistry.resolver(candidate, this.sessionId ? `${this.sessionId}-advisor` : undefined),
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
thinkingLevel: advisorCompactionThinkingLevel,
|
||||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||||
},
|
||||
);
|
||||
if (!apiKey) continue;
|
||||
|
||||
try {
|
||||
compactResult = await compact(
|
||||
preparation,
|
||||
candidate,
|
||||
this.#modelRegistry.resolver(candidate, this.sessionId ? `${this.sessionId}-advisor` : undefined),
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
thinkingLevel: toReasoningEffort(advisor.state.thinkingLevel),
|
||||
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
||||
},
|
||||
);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
if (!compactResult) {
|
||||
logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) });
|
||||
return true;
|
||||
}
|
||||
|
||||
summary = compactResult.summary;
|
||||
shortSummary = compactResult.shortSummary;
|
||||
firstKeptEntryId = compactResult.firstKeptEntryId;
|
||||
tokensBefore = compactResult.tokensBefore;
|
||||
}
|
||||
|
||||
if (!compactResult) {
|
||||
logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) });
|
||||
return true;
|
||||
}
|
||||
|
||||
const summary = compactResult.summary;
|
||||
const shortSummary = compactResult.shortSummary;
|
||||
const firstKeptEntryId = compactResult.firstKeptEntryId;
|
||||
const tokensBefore = compactResult.tokensBefore;
|
||||
|
||||
// Rebuild messages with the compaction summary
|
||||
const summaryMessage = {
|
||||
...createCompactionSummaryMessage(summary, tokensBefore, new Date().toISOString(), shortSummary),
|
||||
@@ -11214,6 +11191,7 @@ export class AgentSession {
|
||||
*/
|
||||
setAdvisorEnabled(enabled: boolean): boolean {
|
||||
if (enabled) {
|
||||
this.settings.clearOverride("advisor.enabled");
|
||||
this.settings.set("advisor.enabled", true);
|
||||
return this.#buildAdvisorRuntime(true);
|
||||
}
|
||||
|
||||
@@ -241,6 +241,82 @@ describe("ACP lazy startup", () => {
|
||||
bashAutoBackgroundThresholdMs: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
it("default-disables advisor for protocol hosts", async () => {
|
||||
const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main");
|
||||
|
||||
type ObservedAdvisorSettings = {
|
||||
enabled: boolean;
|
||||
subagents: boolean;
|
||||
};
|
||||
|
||||
const runProtocolStartup = async (mode: "rpc" | "rpc-ui" | "acp"): Promise<ObservedAdvisorSettings> => {
|
||||
using tempDir = TempDir.createSync("@omp-protocol-advisor-settings-");
|
||||
const cwd = tempDir.path();
|
||||
const authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
|
||||
const settings = Settings.isolated({
|
||||
"advisor.enabled": true,
|
||||
"advisor.subagents": true,
|
||||
});
|
||||
let observed: ObservedAdvisorSettings | undefined;
|
||||
const stopMessage = "stop test protocol mode";
|
||||
|
||||
try {
|
||||
await runRootCommand(
|
||||
{
|
||||
mode,
|
||||
messages: [],
|
||||
fileArgs: [],
|
||||
unknownFlags: new Map(),
|
||||
unrecognizedFlags: [],
|
||||
noSkills: true,
|
||||
noRules: true,
|
||||
noTools: true,
|
||||
noLsp: true,
|
||||
noExtensions: true,
|
||||
sessionDir: cwd,
|
||||
},
|
||||
[],
|
||||
{
|
||||
discoverAuthStorage: async () => authStorage,
|
||||
settings,
|
||||
createAgentSession: async () => {
|
||||
observed = {
|
||||
enabled: settings.get("advisor.enabled"),
|
||||
subagents: settings.get("advisor.subagents"),
|
||||
};
|
||||
throw new Error(stopMessage);
|
||||
},
|
||||
runAcpMode: async () => {
|
||||
observed = {
|
||||
enabled: settings.get("advisor.enabled"),
|
||||
subagents: settings.get("advisor.subagents"),
|
||||
};
|
||||
throw new Error(stopMessage);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message !== stopMessage) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
authStorage.close();
|
||||
}
|
||||
|
||||
if (!observed) {
|
||||
throw new Error("Expected protocol mode to start");
|
||||
}
|
||||
return observed;
|
||||
};
|
||||
|
||||
for (const mode of ["rpc", "rpc-ui", "acp"] as const) {
|
||||
await expect(runProtocolStartup(mode)).resolves.toEqual({
|
||||
enabled: false,
|
||||
subagents: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
it("answers initialize before creating the first AgentSession", async () => {
|
||||
const clientToAgent = new TransformStream();
|
||||
const agentToClient = new TransformStream();
|
||||
|
||||
@@ -80,6 +80,17 @@ describe("AgentSession advisor toggle", () => {
|
||||
expect(session.formatAdvisorStatus()).toContain("Advisor is enabled (anthropic/claude-sonnet-4-5)");
|
||||
});
|
||||
|
||||
it("explicit enable clears protocol default-off override", () => {
|
||||
session.settings.setModelRole("advisor", "anthropic/claude-sonnet-4-5");
|
||||
session.settings.override("advisor.enabled", false);
|
||||
|
||||
const active = session.setAdvisorEnabled(true);
|
||||
|
||||
expect(active).toBe(true);
|
||||
expect(session.isAdvisorActive()).toBe(true);
|
||||
expect(session.settings.get("advisor.enabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggle disables the advisor and runtime", () => {
|
||||
session.settings.setModelRole("advisor", "anthropic/claude-sonnet-4-5");
|
||||
session.toggleAdvisorEnabled();
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { Snowflake } from "@oh-my-pi/pi-utils";
|
||||
|
||||
describe("advisor watchdog prompt discovery", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("discovers and appends WATCHDOG.md to the advisor prompt", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-advisor-watchdog-${Snowflake.next()}-`));
|
||||
tempDirs.push(tempDir);
|
||||
const cwd = path.join(tempDir, "project-root");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
|
||||
// Write a WATCHDOG.md file
|
||||
const watchdogContent = "Watchdog rule: Watch out for cheating on edits.";
|
||||
fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8");
|
||||
|
||||
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
|
||||
authStorage.setRuntimeApiKey("openai", "test-key");
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
const sessionManager = SessionManager.create(cwd, path.join(tempDir, "sessions"));
|
||||
const { session } = await createAgentSession({
|
||||
cwd,
|
||||
agentDir: tempDir,
|
||||
sessionManager,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settings: (() => {
|
||||
const s = Settings.isolated({
|
||||
"async.enabled": false,
|
||||
"advisor.enabled": true,
|
||||
});
|
||||
s.setModelRole("advisor", "openai/gpt-4o-mini");
|
||||
return s;
|
||||
})(),
|
||||
model: getBundledModel("openai", "gpt-4o-mini"),
|
||||
disableExtensionDiscovery: true,
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
promptTemplates: [],
|
||||
slashCommands: [],
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(session.isAdvisorActive()).toBe(true);
|
||||
const dump = session.formatAdvisorHistoryAsText();
|
||||
expect(dump).not.toBeNull();
|
||||
expect(dump).toContain("Especially pay attention to:");
|
||||
expect(dump).toContain("<attention>");
|
||||
expect(dump).toContain(watchdogContent);
|
||||
expect(dump).toContain("</attention>");
|
||||
} finally {
|
||||
await session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves nested folders and sorts by depth", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-advisor-watchdog-${Snowflake.next()}-`));
|
||||
tempDirs.push(tempDir);
|
||||
const parentCwd = path.join(tempDir, "project-root");
|
||||
const childCwd = path.join(parentCwd, "subfolder");
|
||||
fs.mkdirSync(childCwd, { recursive: true });
|
||||
|
||||
// Write two WATCHDOG.md files
|
||||
const parentWatchdogContent = "Parent watchdog rule.";
|
||||
const childWatchdogContent = "Child watchdog rule.";
|
||||
fs.writeFileSync(path.join(parentCwd, "WATCHDOG.md"), parentWatchdogContent, "utf8");
|
||||
fs.writeFileSync(path.join(childCwd, "WATCHDOG.md"), childWatchdogContent, "utf8");
|
||||
|
||||
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
|
||||
authStorage.setRuntimeApiKey("openai", "test-key");
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
const sessionManager = SessionManager.create(childCwd, path.join(tempDir, "sessions"));
|
||||
const { session } = await createAgentSession({
|
||||
cwd: childCwd,
|
||||
agentDir: tempDir,
|
||||
sessionManager,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settings: (() => {
|
||||
const s = Settings.isolated({
|
||||
"async.enabled": false,
|
||||
"advisor.enabled": true,
|
||||
});
|
||||
s.setModelRole("advisor", "openai/gpt-4o-mini");
|
||||
return s;
|
||||
})(),
|
||||
model: getBundledModel("openai", "gpt-4o-mini"),
|
||||
disableExtensionDiscovery: true,
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
promptTemplates: [],
|
||||
slashCommands: [],
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(session.isAdvisorActive()).toBe(true);
|
||||
const dump = session.formatAdvisorHistoryAsText();
|
||||
expect(dump).not.toBeNull();
|
||||
expect(dump).toContain("Especially pay attention to:");
|
||||
expect(dump).toContain("<attention>");
|
||||
expect(dump).toContain("</attention>");
|
||||
expect(dump).toContain(parentWatchdogContent);
|
||||
expect(dump).toContain(childWatchdogContent);
|
||||
// Check ordering: parent is farther (depth 1), child is closer (depth 0).
|
||||
// So parent watchdog should appear first, followed by child watchdog.
|
||||
const parentIndex = dump!.indexOf(parentWatchdogContent);
|
||||
const childIndex = dump!.indexOf(childWatchdogContent);
|
||||
expect(parentIndex).toBeGreaterThan(-1);
|
||||
expect(childIndex).toBeGreaterThan(-1);
|
||||
expect(parentIndex).toBeLessThan(childIndex);
|
||||
} finally {
|
||||
await session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("discovers user-level and native project-level watchdog files", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-advisor-watchdog-${Snowflake.next()}-`));
|
||||
tempDirs.push(tempDir);
|
||||
const cwd = path.join(tempDir, "project-root");
|
||||
const ompDir = path.join(cwd, ".omp");
|
||||
const userAgentDir = path.join(tempDir, "user-agent");
|
||||
fs.mkdirSync(cwd, { recursive: true });
|
||||
fs.mkdirSync(ompDir, { recursive: true });
|
||||
fs.mkdirSync(userAgentDir, { recursive: true });
|
||||
|
||||
const userWatchdogContent = "User-level watchdog rule.";
|
||||
const nativeWatchdogContent = "Native project watchdog rule.";
|
||||
const standaloneWatchdogContent = "Standalone project watchdog rule.";
|
||||
|
||||
fs.writeFileSync(path.join(userAgentDir, "WATCHDOG.md"), userWatchdogContent, "utf8");
|
||||
fs.writeFileSync(path.join(ompDir, "WATCHDOG.md"), nativeWatchdogContent, "utf8");
|
||||
fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), standaloneWatchdogContent, "utf8");
|
||||
|
||||
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
|
||||
authStorage.setRuntimeApiKey("openai", "test-key");
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
|
||||
const sessionManager = SessionManager.create(cwd, path.join(tempDir, "sessions"));
|
||||
const { session } = await createAgentSession({
|
||||
cwd,
|
||||
agentDir: userAgentDir,
|
||||
sessionManager,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settings: (() => {
|
||||
const s = Settings.isolated({
|
||||
"async.enabled": false,
|
||||
"advisor.enabled": true,
|
||||
});
|
||||
s.setModelRole("advisor", "openai/gpt-4o-mini");
|
||||
return s;
|
||||
})(),
|
||||
model: getBundledModel("openai", "gpt-4o-mini"),
|
||||
disableExtensionDiscovery: true,
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
promptTemplates: [],
|
||||
slashCommands: [],
|
||||
enableMCP: false,
|
||||
enableLsp: false,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(session.isAdvisorActive()).toBe(true);
|
||||
const dump = session.formatAdvisorHistoryAsText();
|
||||
expect(dump).not.toBeNull();
|
||||
expect(dump).toContain(userWatchdogContent);
|
||||
expect(dump).toContain(nativeWatchdogContent);
|
||||
expect(dump).toContain(standaloneWatchdogContent);
|
||||
|
||||
// Check ordering: user-level should appear first, then native project level (.omp/WATCHDOG.md has depth 0),
|
||||
// then standalone project level (cwd/WATCHDOG.md has depth 0).
|
||||
// Between native and standalone, they both have depth 0, so their relative order doesn't strictly matter
|
||||
// as long as user-level comes before both of them.
|
||||
const userIndex = dump!.indexOf(userWatchdogContent);
|
||||
const nativeIndex = dump!.indexOf(nativeWatchdogContent);
|
||||
const standaloneIndex = dump!.indexOf(standaloneWatchdogContent);
|
||||
|
||||
expect(userIndex).toBeGreaterThan(-1);
|
||||
expect(nativeIndex).toBeGreaterThan(-1);
|
||||
expect(standaloneIndex).toBeGreaterThan(-1);
|
||||
|
||||
expect(userIndex).toBeLessThan(nativeIndex);
|
||||
expect(userIndex).toBeLessThan(standaloneIndex);
|
||||
} finally {
|
||||
await session.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user