@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/dist/cli.js +3621 -3579
- package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
- package/dist/types/advisor/advise-tool.d.ts +8 -4
- package/dist/types/advisor/config.d.ts +88 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/runtime.d.ts +15 -1
- package/dist/types/advisor/transcript-recorder.d.ts +13 -2
- package/dist/types/advisor/watchdog.d.ts +20 -0
- package/dist/types/collab/guest.d.ts +29 -0
- package/dist/types/config/model-roles.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +113 -12
- package/dist/types/debug/log-viewer.d.ts +1 -0
- package/dist/types/debug/raw-sse.d.ts +1 -0
- package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
- package/dist/types/edit/hashline/diff.d.ts +0 -11
- package/dist/types/edit/index.d.ts +18 -0
- package/dist/types/edit/streaming.d.ts +30 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/extensibility/tool-event-input.d.ts +7 -0
- package/dist/types/extensibility/utils.d.ts +12 -0
- package/dist/types/mcp/oauth-discovery.d.ts +0 -11
- package/dist/types/mcp/transports/index.d.ts +1 -0
- package/dist/types/mcp/transports/sse.d.ts +20 -0
- package/dist/types/modes/components/advisor-config.d.ts +59 -0
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +9 -1
- package/dist/types/modes/components/settings-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +30 -3
- package/dist/types/modes/components/status-line/types.d.ts +13 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +10 -4
- package/dist/types/modes/skill-command.d.ts +32 -0
- package/dist/types/modes/types.d.ts +7 -2
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +84 -12
- package/dist/types/session/indexed-session-storage.d.ts +7 -1
- package/dist/types/session/messages.d.ts +32 -7
- package/dist/types/session/messages.test.d.ts +1 -0
- package/dist/types/session/session-entries.d.ts +31 -3
- package/dist/types/session/session-history-format.d.ts +6 -0
- package/dist/types/session/session-loader.d.ts +9 -1
- package/dist/types/session/session-manager.d.ts +6 -4
- package/dist/types/session/session-storage.d.ts +11 -0
- package/dist/types/session/session-title-slot.d.ts +19 -0
- package/dist/types/session/settings-stream-fn.d.ts +21 -0
- package/dist/types/session/turn-persistence.d.ts +88 -0
- package/dist/types/ssh/connection-manager.d.ts +47 -0
- package/dist/types/ssh/utils.d.ts +16 -0
- package/dist/types/task/executor.d.ts +3 -16
- package/dist/types/task/render.d.ts +0 -5
- package/dist/types/task/renderer.d.ts +13 -0
- package/dist/types/task/types.d.ts +16 -0
- package/dist/types/task/yield-assembly.d.ts +28 -0
- package/dist/types/tiny/text.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/review.d.ts +6 -4
- package/dist/types/tools/ssh.d.ts +1 -1
- package/dist/types/tools/todo.d.ts +6 -0
- package/dist/types/tools/yield.d.ts +8 -3
- package/dist/types/utils/thinking-display.d.ts +4 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +438 -10
- package/src/advisor/__tests__/config.test.ts +173 -0
- package/src/advisor/advise-tool.ts +11 -6
- package/src/advisor/config.ts +256 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +77 -4
- package/src/advisor/transcript-recorder.ts +25 -2
- package/src/advisor/watchdog.ts +57 -31
- package/src/auto-thinking/classifier.ts +2 -2
- package/src/autoresearch/index.ts +7 -2
- package/src/cli/gc-cli.ts +17 -10
- package/src/collab/guest.ts +43 -7
- package/src/config/model-registry.ts +80 -18
- package/src/config/model-resolver.ts +5 -1
- package/src/config/model-roles.ts +3 -3
- package/src/config/settings-schema.ts +107 -8
- package/src/debug/index.ts +32 -7
- package/src/debug/log-viewer.ts +111 -53
- package/src/debug/raw-sse.ts +68 -48
- package/src/discovery/codex.ts +13 -5
- package/src/discovery/omp-extension-roots.ts +38 -13
- package/src/edit/hashline/diff.ts +57 -4
- package/src/edit/index.ts +21 -0
- package/src/edit/streaming.ts +170 -0
- package/src/eval/js/shared/local-module-loader.ts +23 -1
- package/src/export/html/template.js +13 -7
- package/src/extensibility/custom-tools/types.ts +1 -0
- package/src/extensibility/extensions/loader.ts +5 -3
- package/src/extensibility/extensions/wrapper.ts +9 -3
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/hooks/tool-wrapper.ts +13 -4
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
- package/src/extensibility/plugins/manager.ts +76 -5
- package/src/extensibility/shared-events.ts +1 -0
- package/src/extensibility/tool-event-input.ts +23 -0
- package/src/extensibility/utils.ts +74 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/client.ts +3 -1
- package/src/mcp/manager.ts +12 -5
- package/src/mcp/oauth-discovery.ts +5 -29
- package/src/mcp/transports/http.ts +3 -1
- package/src/mcp/transports/index.ts +1 -0
- package/src/mcp/transports/sse.ts +377 -0
- package/src/memories/index.ts +15 -6
- package/src/mnemopi/backend.ts +2 -2
- package/src/modes/acp/acp-agent.ts +1 -1
- package/src/modes/components/advisor-config.ts +555 -0
- package/src/modes/components/advisor-message.ts +9 -2
- package/src/modes/components/agent-hub.ts +9 -4
- package/src/modes/components/assistant-message.ts +5 -5
- package/src/modes/components/index.ts +2 -0
- package/src/modes/components/model-selector.ts +79 -48
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/component.ts +145 -14
- package/src/modes/components/status-line/segments.ts +47 -22
- package/src/modes/components/status-line/types.ts +13 -1
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/controllers/command-controller.ts +23 -2
- package/src/modes/controllers/event-controller.ts +114 -11
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +61 -61
- package/src/modes/controllers/selector-controller.ts +100 -9
- package/src/modes/interactive-mode.ts +65 -10
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/skill-command.ts +116 -0
- package/src/modes/types.ts +7 -2
- package/src/modes/utils/transcript-render-helpers.ts +2 -2
- package/src/modes/utils/ui-helpers.ts +46 -27
- package/src/prompts/agents/reviewer.md +11 -10
- package/src/prompts/review-custom-request.md +1 -2
- package/src/prompts/review-request.md +1 -2
- package/src/prompts/system/interrupted-thinking.md +7 -0
- package/src/prompts/system/recap-user.md +9 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -5
- package/src/prompts/system/subagent-yield-reminder.md +6 -5
- package/src/prompts/system/system-prompt.md +0 -1
- package/src/prompts/tools/irc.md +2 -2
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +40 -50
- package/src/session/agent-session.ts +1139 -600
- package/src/session/indexed-session-storage.ts +86 -13
- package/src/session/messages.test.ts +125 -0
- package/src/session/messages.ts +192 -21
- package/src/session/redis-session-storage.ts +49 -2
- package/src/session/session-entries.ts +39 -2
- package/src/session/session-history-format.ts +29 -2
- package/src/session/session-listing.ts +54 -24
- package/src/session/session-loader.ts +66 -3
- package/src/session/session-manager.ts +113 -19
- package/src/session/session-persistence.ts +96 -3
- package/src/session/session-storage.ts +36 -0
- package/src/session/session-title-slot.ts +141 -0
- package/src/session/settings-stream-fn.ts +49 -0
- package/src/session/sql-session-storage.ts +71 -11
- package/src/session/turn-persistence.ts +142 -0
- package/src/session/unexpected-stop-classifier.ts +2 -2
- package/src/slash-commands/builtin-registry.ts +16 -3
- package/src/slash-commands/helpers/mcp.ts +2 -1
- package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
- package/src/ssh/connection-manager.ts +139 -12
- package/src/ssh/file-transfer.ts +23 -18
- package/src/ssh/ssh-executor.ts +2 -13
- package/src/ssh/utils.ts +19 -0
- package/src/task/executor.ts +21 -23
- package/src/task/render.ts +162 -20
- package/src/task/renderer.ts +14 -0
- package/src/task/types.ts +17 -0
- package/src/task/yield-assembly.ts +207 -0
- package/src/tiny/models.ts +8 -6
- package/src/tiny/text.ts +37 -7
- package/src/tools/ask.ts +55 -4
- package/src/tools/image-gen.ts +2 -1
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/renderers.ts +8 -2
- package/src/tools/review.ts +17 -7
- package/src/tools/ssh.ts +8 -4
- package/src/tools/todo.ts +17 -1
- package/src/tools/tts.ts +2 -1
- package/src/tools/yield.ts +140 -31
- package/src/utils/thinking-display.ts +15 -0
- package/src/utils/title-generator.ts +1 -1
- package/src/web/search/providers/tavily.ts +36 -19
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
advisorConfigFilePath,
|
|
7
|
+
discoverAdvisorConfigs,
|
|
8
|
+
loadWatchdogConfigFile,
|
|
9
|
+
resolveAdvisorConfigEditPath,
|
|
10
|
+
saveWatchdogConfigFile,
|
|
11
|
+
serializeWatchdogConfig,
|
|
12
|
+
slugifyAdvisorName,
|
|
13
|
+
type WatchdogConfigDoc,
|
|
14
|
+
} from "../config";
|
|
15
|
+
|
|
16
|
+
describe("discoverAdvisorConfigs", () => {
|
|
17
|
+
let tmp: string;
|
|
18
|
+
let agentDir: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-config-"));
|
|
22
|
+
// Empty agent dir so the user-level search path can't pick up a real ~/.omp/WATCHDOG.yml.
|
|
23
|
+
agentDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-agentdir-"));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
28
|
+
await fsp.rm(agentDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("parses advisors, the model thinking suffix, tool filtering, and shared instructions", async () => {
|
|
32
|
+
const yaml = [
|
|
33
|
+
"instructions: Shared baseline for all advisors.",
|
|
34
|
+
"advisors:",
|
|
35
|
+
" - name: Architecture",
|
|
36
|
+
" model: x-ai/grok-code-fast:high",
|
|
37
|
+
" instructions: Watch module boundaries.",
|
|
38
|
+
" - name: Security Reviewer",
|
|
39
|
+
" tools: [read, definitely-not-a-tool]",
|
|
40
|
+
].join("\n");
|
|
41
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yml"), yaml);
|
|
42
|
+
|
|
43
|
+
const { advisors, sharedInstructions } = await discoverAdvisorConfigs(tmp, agentDir);
|
|
44
|
+
expect(advisors).toHaveLength(2);
|
|
45
|
+
const [arch, sec] = advisors;
|
|
46
|
+
expect(arch.name).toBe("Architecture");
|
|
47
|
+
// The model selector (incl. the `:high` thinking suffix) is stored verbatim;
|
|
48
|
+
// resolution happens later in the session, not here.
|
|
49
|
+
expect(arch.model).toBe("x-ai/grok-code-fast:high");
|
|
50
|
+
expect(arch.instructions).toBe("Watch module boundaries.");
|
|
51
|
+
expect(sec.name).toBe("Security Reviewer");
|
|
52
|
+
expect(sec.model).toBeUndefined();
|
|
53
|
+
// The unknown/non-read-only tool is dropped; only `read` survives.
|
|
54
|
+
expect(sec.tools).toEqual(["read"]);
|
|
55
|
+
expect(sharedInstructions).toBe("Shared baseline for all advisors.");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ignores a malformed YAML file without throwing", async () => {
|
|
59
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: [unclosed bracket");
|
|
60
|
+
const result = await discoverAdvisorConfigs(tmp, agentDir);
|
|
61
|
+
expect(result.advisors).toEqual([]);
|
|
62
|
+
expect(result.sharedInstructions).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("skips a file whose shape fails the schema (advisors must be a list)", async () => {
|
|
66
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: not-an-array");
|
|
67
|
+
const result = await discoverAdvisorConfigs(tmp, agentDir);
|
|
68
|
+
expect(result.advisors).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns an empty roster when no config file exists", async () => {
|
|
72
|
+
const result = await discoverAdvisorConfigs(tmp, agentDir);
|
|
73
|
+
expect(result.advisors).toEqual([]);
|
|
74
|
+
expect(result.sharedInstructions).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("slugifyAdvisorName", () => {
|
|
79
|
+
it("lowercases and collapses non-alphanumeric runs to single hyphens", () => {
|
|
80
|
+
expect(slugifyAdvisorName("Security Reviewer")).toBe("security-reviewer");
|
|
81
|
+
expect(slugifyAdvisorName(" Arch/Boundaries! ")).toBe("arch-boundaries");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("falls back to 'advisor' when nothing alphanumeric survives", () => {
|
|
85
|
+
expect(slugifyAdvisorName("!!!")).toBe("advisor");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("WATCHDOG.yml file round-trip", () => {
|
|
90
|
+
let tmp: string;
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-file-"));
|
|
93
|
+
});
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const doc: WatchdogConfigDoc = {
|
|
99
|
+
instructions: 'Shared baseline.\nSecond line with: a colon and "quotes".',
|
|
100
|
+
advisors: [
|
|
101
|
+
{ name: "Architecture", model: "x-ai/grok-code-fast:high", instructions: "Watch module boundaries." },
|
|
102
|
+
{ name: "Security", tools: ["read", "grep"] },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
it("saves and reloads a doc byte-equivalently (incl. multiline and special chars)", async () => {
|
|
107
|
+
const file = path.join(tmp, "WATCHDOG.yml");
|
|
108
|
+
await saveWatchdogConfigFile(file, doc);
|
|
109
|
+
const loaded = await loadWatchdogConfigFile(file);
|
|
110
|
+
expect(loaded).toEqual(doc);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("serializes block-style YAML that the discovery path also parses", async () => {
|
|
114
|
+
const file = path.join(tmp, "WATCHDOG.yml");
|
|
115
|
+
await saveWatchdogConfigFile(file, doc);
|
|
116
|
+
const text = await Bun.file(file).text();
|
|
117
|
+
// Block style (not the flow `{...}` form), so it stays hand-editable.
|
|
118
|
+
expect(text).toContain("advisors:");
|
|
119
|
+
expect(text).not.toMatch(/^\{/);
|
|
120
|
+
const { advisors, sharedInstructions } = await discoverAdvisorConfigs(tmp, tmp);
|
|
121
|
+
expect(advisors.map(a => a.name)).toEqual(["Architecture", "Security"]);
|
|
122
|
+
expect(sharedInstructions).toContain("Shared baseline.");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("removes the file when the doc is empty so legacy discovery resumes", async () => {
|
|
126
|
+
const file = path.join(tmp, "WATCHDOG.yml");
|
|
127
|
+
await saveWatchdogConfigFile(file, doc);
|
|
128
|
+
await saveWatchdogConfigFile(file, { advisors: [] });
|
|
129
|
+
expect(await Bun.file(file).exists()).toBe(false);
|
|
130
|
+
// Loading a missing file yields an empty doc, never throws.
|
|
131
|
+
expect(await loadWatchdogConfigFile(file)).toEqual({ advisors: [] });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns an empty serialization for an empty doc", () => {
|
|
135
|
+
expect(serializeWatchdogConfig({ advisors: [] })).toBe("");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("resolves project and user scope paths", () => {
|
|
139
|
+
expect(advisorConfigFilePath("project", { projectDir: "/repo", agentDir: "/home/.omp" })).toBe(
|
|
140
|
+
path.join("/repo", "WATCHDOG.yml"),
|
|
141
|
+
);
|
|
142
|
+
expect(advisorConfigFilePath("user", { projectDir: "/repo", agentDir: "/home/.omp" })).toBe(
|
|
143
|
+
path.join("/home/.omp", "WATCHDOG.yml"),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("resolveAdvisorConfigEditPath", () => {
|
|
149
|
+
let tmp: string;
|
|
150
|
+
beforeEach(async () => {
|
|
151
|
+
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-resolve-"));
|
|
152
|
+
});
|
|
153
|
+
afterEach(async () => {
|
|
154
|
+
await fsp.rm(tmp, { recursive: true, force: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const dirs = (d: string) => ({ projectDir: d, agentDir: d });
|
|
158
|
+
|
|
159
|
+
it("defaults to .yml when neither file exists", async () => {
|
|
160
|
+
expect(await resolveAdvisorConfigEditPath("project", dirs(tmp))).toBe(path.join(tmp, "WATCHDOG.yml"));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("edits an existing .yaml in place when only it exists", async () => {
|
|
164
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yaml"), "advisors: []\n");
|
|
165
|
+
expect(await resolveAdvisorConfigEditPath("project", dirs(tmp))).toBe(path.join(tmp, "WATCHDOG.yaml"));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("prefers the canonical .yml when both exist", async () => {
|
|
169
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: []\n");
|
|
170
|
+
await Bun.write(path.join(tmp, "WATCHDOG.yaml"), "advisors: []\n");
|
|
171
|
+
expect(await resolveAdvisorConfigEditPath("project", dirs(tmp))).toBe(path.join(tmp, "WATCHDOG.yml"));
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
AgentToolResult,
|
|
7
7
|
AgentToolUpdateCallback,
|
|
8
8
|
} from "@oh-my-pi/pi-agent-core";
|
|
9
|
-
import { escapeXmlText } from "@oh-my-pi/pi-utils";
|
|
9
|
+
import { escapeXmlAttribute, escapeXmlText } from "@oh-my-pi/pi-utils";
|
|
10
10
|
import { type } from "arktype";
|
|
11
11
|
import adviseDescription from "../prompts/advisor/advise-tool.md" with { type: "text" };
|
|
12
12
|
|
|
@@ -24,12 +24,16 @@ export type AdvisorSeverity = "nit" | "concern" | "blocker";
|
|
|
24
24
|
export interface AdviseDetails {
|
|
25
25
|
note: string;
|
|
26
26
|
severity?: AdvisorSeverity;
|
|
27
|
+
/** Which configured advisor produced this note (omitted for the default advisor). */
|
|
28
|
+
advisor?: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** One queued advice note. */
|
|
30
32
|
export interface AdvisorNote {
|
|
31
33
|
note: string;
|
|
32
34
|
severity?: AdvisorSeverity;
|
|
35
|
+
/** Which configured advisor produced this note (omitted for the default advisor). */
|
|
36
|
+
advisor?: string;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
/** Details payload on the batched `advisor` custom message rendered in the transcript. */
|
|
@@ -55,7 +59,8 @@ export function formatAdvisorBatchContent(notes: readonly AdvisorNote[]): string
|
|
|
55
59
|
return notes
|
|
56
60
|
.map(n => {
|
|
57
61
|
const severity = n.severity ? ` severity="${n.severity}"` : "";
|
|
58
|
-
|
|
62
|
+
const who = n.advisor ? ` advisor="${escapeXmlAttribute(n.advisor)}"` : "";
|
|
63
|
+
return `<advisory${who}${severity} guidance="${ADVISOR_GUIDANCE}">\n${escapeXmlText(n.note)}\n</advisory>`;
|
|
59
64
|
})
|
|
60
65
|
.join("\n");
|
|
61
66
|
}
|
|
@@ -133,11 +138,11 @@ export function deriveAdvisorTelemetry(
|
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
141
|
+
* The tools an advisor receives by default when its config omits `tools` — the
|
|
142
|
+
* read-only investigative set. The full available pool is every built tool the
|
|
143
|
+
* session has (the advisor is a full agent); a config's `tools` selects from it.
|
|
139
144
|
*/
|
|
140
|
-
export const
|
|
145
|
+
export const ADVISOR_DEFAULT_TOOL_NAMES: ReadonlySet<string> = new Set(["read", "grep", "glob"]);
|
|
141
146
|
|
|
142
147
|
function advisorNoteDedupeKey(note: string): string {
|
|
143
148
|
return note.trim().replace(/\s+/g, " ");
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { type } from "arktype";
|
|
5
|
+
import { YAML } from "bun";
|
|
6
|
+
import { expandAtImports } from "../discovery/at-imports";
|
|
7
|
+
import { BUILTIN_TOOL_NAMES, normalizeToolNames } from "../tools/builtin-names";
|
|
8
|
+
import { collectConfigCandidates } from "./watchdog";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One advisor declared in a `WATCHDOG.yml` file. `model` is a model selector
|
|
12
|
+
* with an optional `:level` thinking suffix (e.g. `x-ai/grok-code-fast:high`),
|
|
13
|
+
* resolved exactly like any other model override; `tools` is a subset of the
|
|
14
|
+
* read-only investigative tool names (default: all). `instructions` is the
|
|
15
|
+
* advisor's specialization, appended to the shared baseline.
|
|
16
|
+
*/
|
|
17
|
+
export interface AdvisorConfig {
|
|
18
|
+
name: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
tools?: string[];
|
|
21
|
+
instructions?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The result of walking the `WATCHDOG.yml`/`WATCHDOG.yaml` search path: the
|
|
26
|
+
* deduped advisor roster plus the concatenated top-level `instructions` baseline
|
|
27
|
+
* that is prepended (alongside `WATCHDOG.md`) to every advisor.
|
|
28
|
+
*/
|
|
29
|
+
export interface DiscoveredAdvisors {
|
|
30
|
+
advisors: AdvisorConfig[];
|
|
31
|
+
sharedInstructions: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const advisorEntrySchema = type({
|
|
35
|
+
name: "string",
|
|
36
|
+
"model?": "string",
|
|
37
|
+
"tools?": "string[]",
|
|
38
|
+
"instructions?": "string",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const watchdogYamlSchema = type({
|
|
42
|
+
"instructions?": "string",
|
|
43
|
+
"advisors?": advisorEntrySchema.array(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize an advisor name into a filesystem-/id-safe slug used for its
|
|
48
|
+
* transcript filename and session id: lowercase, non-alphanumerics collapsed to
|
|
49
|
+
* `-`, leading/trailing `-` trimmed. Falls back to `"advisor"` when nothing
|
|
50
|
+
* survives; callers dedupe collisions.
|
|
51
|
+
*/
|
|
52
|
+
export function slugifyAdvisorName(name: string): string {
|
|
53
|
+
const slug = name
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
56
|
+
.replace(/^-+|-+$/g, "");
|
|
57
|
+
return slug || "advisor";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Built tool names, for validating an advisor's `tools` list. */
|
|
61
|
+
const KNOWN_TOOL_NAMES = new Set<string>(BUILTIN_TOOL_NAMES);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Keep only valid tool names from an advisor's `tools` list, dropping unknowns
|
|
65
|
+
* with a warning. The advisor is a full agent, so any built tool may be granted;
|
|
66
|
+
* the runtime further filters to what's actually available this session. An empty
|
|
67
|
+
* result (or no list) means "use the default subset" (read/grep/glob).
|
|
68
|
+
*/
|
|
69
|
+
function filterAdvisorTools(tools: string[] | undefined, sourcePath: string): string[] | undefined {
|
|
70
|
+
if (!tools || tools.length === 0) return undefined;
|
|
71
|
+
// Normalize legacy aliases (search→grep, find→glob) and dedupe before validating.
|
|
72
|
+
const filtered = normalizeToolNames(tools).filter(name => {
|
|
73
|
+
if (KNOWN_TOOL_NAMES.has(name)) return true;
|
|
74
|
+
logger.warn("Advisor config: dropping unknown tool", { path: sourcePath, tool: name });
|
|
75
|
+
return false;
|
|
76
|
+
});
|
|
77
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Discover advisor configs from `WATCHDOG.yml`/`WATCHDOG.yaml` files on the same
|
|
82
|
+
* user + project search path as `WATCHDOG.md`. Advisors are keyed by slug; a
|
|
83
|
+
* more-specific file (project leaf > project ancestor > user) replaces an earlier
|
|
84
|
+
* entry with the same slug. Top-level `instructions` across all files concatenate
|
|
85
|
+
* into the shared baseline. A malformed file is logged and skipped — never
|
|
86
|
+
* thrown — so a bad project config can't kill the session.
|
|
87
|
+
*/
|
|
88
|
+
export async function discoverAdvisorConfigs(cwd: string, agentDir?: string): Promise<DiscoveredAdvisors> {
|
|
89
|
+
const items = await collectConfigCandidates(cwd, agentDir, ["WATCHDOG.yml", "WATCHDOG.yaml"]);
|
|
90
|
+
const advisors = new Map<string, AdvisorConfig>();
|
|
91
|
+
const sharedParts: string[] = [];
|
|
92
|
+
|
|
93
|
+
for (const item of items) {
|
|
94
|
+
let parsed: unknown;
|
|
95
|
+
try {
|
|
96
|
+
parsed = YAML.parse(item.content);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger.warn("Advisor config: failed to parse YAML", { path: item.path, error: String(err) });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
102
|
+
logger.warn("Advisor config: expected a YAML mapping", { path: item.path });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const result = watchdogYamlSchema(parsed);
|
|
106
|
+
if (result instanceof type.errors) {
|
|
107
|
+
logger.warn("Advisor config: invalid schema", { path: item.path, error: result.summary });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (result.instructions?.trim()) {
|
|
112
|
+
const expanded = (await expandAtImports(result.instructions, item.path)).trim();
|
|
113
|
+
if (expanded) sharedParts.push(expanded);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const entry of result.advisors ?? []) {
|
|
117
|
+
const slug = slugifyAdvisorName(entry.name);
|
|
118
|
+
const instructions = entry.instructions?.trim()
|
|
119
|
+
? (await expandAtImports(entry.instructions, item.path)).trim() || undefined
|
|
120
|
+
: undefined;
|
|
121
|
+
advisors.set(slug, {
|
|
122
|
+
name: entry.name,
|
|
123
|
+
model: entry.model?.trim() || undefined,
|
|
124
|
+
tools: filterAdvisorTools(entry.tools, item.path),
|
|
125
|
+
instructions,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
advisors: [...advisors.values()],
|
|
132
|
+
sharedInstructions: sharedParts.length > 0 ? sharedParts.join("\n\n") : undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Which level a `WATCHDOG.yml` lives at: the project root or the user agent dir. */
|
|
137
|
+
export type AdvisorConfigScope = "project" | "user";
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The editable contents of a single `WATCHDOG.yml` file: the shared top-level
|
|
141
|
+
* `instructions` plus the advisor roster. Unlike {@link DiscoveredAdvisors}, this
|
|
142
|
+
* is one file's raw view (no cross-level merge, no `@import` expansion) so the
|
|
143
|
+
* config editor round-trips exactly what the user wrote.
|
|
144
|
+
*/
|
|
145
|
+
export interface WatchdogConfigDoc {
|
|
146
|
+
instructions?: string;
|
|
147
|
+
advisors: AdvisorConfig[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the `WATCHDOG.yml` path for a scope: `project` → `<projectDir>/WATCHDOG.yml`
|
|
152
|
+
* (discovered by the project-level walk), `user` → `<agentDir>/WATCHDOG.yml` (the
|
|
153
|
+
* user-level candidate).
|
|
154
|
+
*/
|
|
155
|
+
export function advisorConfigFilePath(
|
|
156
|
+
scope: AdvisorConfigScope,
|
|
157
|
+
dirs: { projectDir: string; agentDir: string },
|
|
158
|
+
): string {
|
|
159
|
+
return path.join(scope === "user" ? dirs.agentDir : dirs.projectDir, "WATCHDOG.yml");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve which `WATCHDOG.{yml,yaml}` to edit for a scope: prefer the canonical
|
|
164
|
+
* `.yml`, but when only a `.yaml` exists for that scope, edit it in place so an
|
|
165
|
+
* existing `.yaml` user isn't shown a blank editor and left with two files at the
|
|
166
|
+
* same precedence. Falls back to `.yml` when neither exists.
|
|
167
|
+
*/
|
|
168
|
+
export async function resolveAdvisorConfigEditPath(
|
|
169
|
+
scope: AdvisorConfigScope,
|
|
170
|
+
dirs: { projectDir: string; agentDir: string },
|
|
171
|
+
): Promise<string> {
|
|
172
|
+
const dir = scope === "user" ? dirs.agentDir : dirs.projectDir;
|
|
173
|
+
const yml = path.join(dir, "WATCHDOG.yml");
|
|
174
|
+
const yaml = path.join(dir, "WATCHDOG.yaml");
|
|
175
|
+
if (!(await Bun.file(yml).exists()) && (await Bun.file(yaml).exists())) return yaml;
|
|
176
|
+
return yml;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Load one `WATCHDOG.yml` file for editing — raw, un-merged, un-expanded. Missing,
|
|
181
|
+
* unparseable, or schema-invalid files yield an empty doc (never throws) so the
|
|
182
|
+
* editor opens cleanly on a fresh or broken file.
|
|
183
|
+
*/
|
|
184
|
+
export async function loadWatchdogConfigFile(filePath: string): Promise<WatchdogConfigDoc> {
|
|
185
|
+
let text: string;
|
|
186
|
+
try {
|
|
187
|
+
text = await Bun.file(filePath).text();
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (!isEnoent(err))
|
|
190
|
+
logger.warn("Advisor config: failed to read for edit", { path: filePath, error: String(err) });
|
|
191
|
+
return { advisors: [] };
|
|
192
|
+
}
|
|
193
|
+
let parsed: unknown;
|
|
194
|
+
try {
|
|
195
|
+
parsed = YAML.parse(text);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logger.warn("Advisor config: failed to parse for edit", { path: filePath, error: String(err) });
|
|
198
|
+
return { advisors: [] };
|
|
199
|
+
}
|
|
200
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { advisors: [] };
|
|
201
|
+
const result = watchdogYamlSchema(parsed);
|
|
202
|
+
if (result instanceof type.errors) {
|
|
203
|
+
logger.warn("Advisor config: invalid schema for edit", { path: filePath, error: result.summary });
|
|
204
|
+
return { advisors: [] };
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
instructions: result.instructions?.trim() ? result.instructions : undefined,
|
|
208
|
+
advisors: (result.advisors ?? []).map(a => ({
|
|
209
|
+
name: a.name,
|
|
210
|
+
model: a.model?.trim() || undefined,
|
|
211
|
+
tools: a.tools?.length ? [...a.tools] : undefined,
|
|
212
|
+
instructions: a.instructions?.trim() ? a.instructions : undefined,
|
|
213
|
+
})),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Serialize an editable doc back to block-style `WATCHDOG.yml` text via Bun's
|
|
219
|
+
* `YAML.stringify` (the same API the repo uses for other hand-editable config),
|
|
220
|
+
* omitting empty fields. Round-trips through {@link loadWatchdogConfigFile}.
|
|
221
|
+
* Returns `""` for an empty doc.
|
|
222
|
+
*/
|
|
223
|
+
export function serializeWatchdogConfig(doc: WatchdogConfigDoc): string {
|
|
224
|
+
const out: { instructions?: string; advisors?: AdvisorConfig[] } = {};
|
|
225
|
+
if (doc.instructions?.trim()) out.instructions = doc.instructions;
|
|
226
|
+
if (doc.advisors.length > 0) {
|
|
227
|
+
out.advisors = doc.advisors.map(a => {
|
|
228
|
+
const entry: AdvisorConfig = { name: a.name };
|
|
229
|
+
if (a.model?.trim()) entry.model = a.model;
|
|
230
|
+
if (a.tools?.length) entry.tools = [...a.tools];
|
|
231
|
+
if (a.instructions?.trim()) entry.instructions = a.instructions;
|
|
232
|
+
return entry;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (out.instructions === undefined && out.advisors === undefined) return "";
|
|
236
|
+
const text = YAML.stringify(out, null, 2);
|
|
237
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Write an editable doc to `WATCHDOG.yml`. An empty doc removes the file so
|
|
242
|
+
* discovery falls back to the legacy single-advisor path rather than leaving an
|
|
243
|
+
* empty config behind.
|
|
244
|
+
*/
|
|
245
|
+
export async function saveWatchdogConfigFile(filePath: string, doc: WatchdogConfigDoc): Promise<void> {
|
|
246
|
+
const content = serializeWatchdogConfig(doc);
|
|
247
|
+
if (!content.trim()) {
|
|
248
|
+
try {
|
|
249
|
+
await fs.rm(filePath, { force: true });
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if (!isEnoent(err)) throw err;
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await Bun.write(filePath, content);
|
|
256
|
+
}
|
package/src/advisor/index.ts
CHANGED
package/src/advisor/runtime.ts
CHANGED
|
@@ -5,12 +5,23 @@ import { logger } from "@oh-my-pi/pi-utils";
|
|
|
5
5
|
import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator";
|
|
6
6
|
import { formatSessionHistoryMarkdown, PRIMARY_CONTEXT_CUSTOM_TYPES } from "../session/session-history-format";
|
|
7
7
|
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core
|
|
10
|
+
* `Agent`. `state.error` mirrors `Agent.state.error`: provider/stream failures
|
|
11
|
+
* the loop catches internally never reject `prompt()`, so the runtime reads
|
|
12
|
+
* this field after every prompt to detect a failed turn.
|
|
13
|
+
*/
|
|
9
14
|
export interface AdvisorAgent {
|
|
10
15
|
prompt(input: string): Promise<void>;
|
|
11
16
|
abort(reason?: unknown): void;
|
|
12
17
|
reset(): void;
|
|
13
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Drop messages appended past `count`. Called after a failed `prompt()` so a
|
|
20
|
+
* retry doesn't replay the failed user batch + synthetic assistant-error
|
|
21
|
+
* turn `Agent.#runLoop` records on its internal state.
|
|
22
|
+
*/
|
|
23
|
+
rollbackTo?(count: number): void;
|
|
24
|
+
readonly state: { messages: AgentMessage[]; error?: string };
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
export interface AdvisorRuntimeHost {
|
|
@@ -37,6 +48,8 @@ export interface AdvisorRuntimeHost {
|
|
|
37
48
|
* one that routes `advise()` results back to the primary.
|
|
38
49
|
*/
|
|
39
50
|
beginAdvisorUpdate?(): void;
|
|
51
|
+
/** Surface a non-recovering advisor failure to the host UI without adding model-visible context. */
|
|
52
|
+
notifyFailure?(error: unknown): void;
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
interface PendingDelta {
|
|
@@ -63,6 +76,7 @@ export class AdvisorRuntime {
|
|
|
63
76
|
#busy = false;
|
|
64
77
|
#backlog = 0;
|
|
65
78
|
#consecutiveFailures = 0;
|
|
79
|
+
#failureNotified = false;
|
|
66
80
|
#latestMessages?: AgentMessage[];
|
|
67
81
|
#waiters: CatchupWaiter[] = [];
|
|
68
82
|
/** Bumped by every external {@link reset}/{@link dispose}. A drain iteration
|
|
@@ -121,6 +135,7 @@ export class AdvisorRuntime {
|
|
|
121
135
|
this.#pending = [];
|
|
122
136
|
this.#backlog = 0;
|
|
123
137
|
this.#consecutiveFailures = 0;
|
|
138
|
+
this.#failureNotified = false;
|
|
124
139
|
this.#wakeAllWaiters();
|
|
125
140
|
try {
|
|
126
141
|
this.agent.abort("advisor disposed");
|
|
@@ -131,6 +146,7 @@ export class AdvisorRuntime {
|
|
|
131
146
|
this.#lastCount = 0;
|
|
132
147
|
this.#pending = [];
|
|
133
148
|
this.#consecutiveFailures = 0;
|
|
149
|
+
this.#failureNotified = false;
|
|
134
150
|
this.#seenContext.clear();
|
|
135
151
|
if (clearBacklog) {
|
|
136
152
|
this.#backlog = 0;
|
|
@@ -168,6 +184,7 @@ export class AdvisorRuntime {
|
|
|
168
184
|
this.#pending = [];
|
|
169
185
|
this.#backlog = 0;
|
|
170
186
|
this.#consecutiveFailures = 0;
|
|
187
|
+
this.#failureNotified = false;
|
|
171
188
|
this.#seenContext.clear();
|
|
172
189
|
this.#wakeAllWaiters();
|
|
173
190
|
}
|
|
@@ -192,6 +209,7 @@ export class AdvisorRuntime {
|
|
|
192
209
|
includeToolIntent: true,
|
|
193
210
|
watchedRoles: true,
|
|
194
211
|
expandPrimaryContext: true,
|
|
212
|
+
expandEditDiffs: true,
|
|
195
213
|
});
|
|
196
214
|
if (!md.trim()) return null;
|
|
197
215
|
return `### Session update\n\n${md}`;
|
|
@@ -233,6 +251,28 @@ export class AdvisorRuntime {
|
|
|
233
251
|
}
|
|
234
252
|
}
|
|
235
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Drop the user batch + synthetic assistant-error turn `Agent.#runLoop`
|
|
256
|
+
* appended for a failed prompt so a retry replays a clean baseline and the
|
|
257
|
+
* dropped-after-3 path never leaks orphan failures into the next successful
|
|
258
|
+
* run. Prefers the agent's own `rollbackTo` (which also re-syncs its
|
|
259
|
+
* append-only context); falls back to truncating `state.messages` for tests
|
|
260
|
+
* that hand-roll a minimal facade.
|
|
261
|
+
*/
|
|
262
|
+
#rollbackFailedTurn(snapshot: number): void {
|
|
263
|
+
const messages = this.agent.state.messages;
|
|
264
|
+
if (messages.length <= snapshot) return;
|
|
265
|
+
try {
|
|
266
|
+
if (this.agent.rollbackTo) {
|
|
267
|
+
this.agent.rollbackTo(snapshot);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
messages.length = snapshot;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
logger.debug("advisor rollback failed", { err: String(err) });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
236
276
|
async #drain(): Promise<void> {
|
|
237
277
|
if (this.#busy) return;
|
|
238
278
|
this.#busy = true;
|
|
@@ -281,24 +321,48 @@ export class AdvisorRuntime {
|
|
|
281
321
|
}
|
|
282
322
|
|
|
283
323
|
let success = false;
|
|
324
|
+
// Capture the advisor's message count BEFORE the prompt so a failure can
|
|
325
|
+
// roll back the user batch + synthetic assistant-error turn `Agent.#runLoop`
|
|
326
|
+
// appends to internal state. Without this, a retry would replay the
|
|
327
|
+
// failed batch on top of the stale turns and the dropped-after-3 path
|
|
328
|
+
// would leak orphan failures into the next successful run's context.
|
|
329
|
+
const messageSnapshot = this.agent.state.messages.length;
|
|
284
330
|
try {
|
|
285
331
|
// Reset the host's per-update advisor state (one-advise-per-update
|
|
286
332
|
// gate) before each model cycle, so the new batch starts with a
|
|
287
333
|
// fresh budget. Dedupe history persists across cycles.
|
|
288
334
|
this.host.beginAdvisorUpdate?.();
|
|
289
335
|
await this.agent.prompt(batch);
|
|
336
|
+
// `Agent.#runLoop` catches provider/stream failures internally and
|
|
337
|
+
// resolves `prompt()` cleanly with the assistant turn ending in
|
|
338
|
+
// `stopReason: "error"` and the message recorded on `state.error`.
|
|
339
|
+
// Treat that as a failed turn so OpenRouter ZDR-style endpoint
|
|
340
|
+
// rejections trip the retry/notify path instead of looking like a
|
|
341
|
+
// successful empty cycle.
|
|
342
|
+
const promptError = this.agent.state.error;
|
|
343
|
+
if (promptError) throw new Error(promptError);
|
|
290
344
|
success = true;
|
|
291
345
|
this.#consecutiveFailures = 0;
|
|
346
|
+
this.#failureNotified = false;
|
|
292
347
|
} catch (err) {
|
|
293
348
|
// reset()/dispose() aborts the in-flight prompt; the rejection is the
|
|
294
349
|
// reset itself, not a transient advisor failure. Drop the stale batch
|
|
295
350
|
// (reset already cleared #pending and rewound the cursor) instead of
|
|
296
351
|
// requeuing it into the post-reset conversation.
|
|
297
352
|
if (this.#epoch !== epoch) continue;
|
|
353
|
+
this.#rollbackFailedTurn(messageSnapshot);
|
|
298
354
|
logger.debug("advisor turn failed", { err: String(err) });
|
|
299
355
|
this.#consecutiveFailures++;
|
|
300
356
|
if (this.#consecutiveFailures >= 3) {
|
|
301
357
|
logger.warn("advisor failed consecutively 3 times; dropping backlog to prevent stall");
|
|
358
|
+
if (!this.#failureNotified) {
|
|
359
|
+
this.#failureNotified = true;
|
|
360
|
+
try {
|
|
361
|
+
this.host.notifyFailure?.(err);
|
|
362
|
+
} catch (notifyErr) {
|
|
363
|
+
logger.warn("advisor failure notification failed", { err: String(notifyErr) });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
302
366
|
this.#consecutiveFailures = 0;
|
|
303
367
|
// The dropped batch may carry primary-context we never delivered; drop
|
|
304
368
|
// the seen-state too so the next turn re-expands it instead of marking
|
|
@@ -371,11 +435,20 @@ function obfuscateDetails(
|
|
|
371
435
|
function obfuscateAdvisorMessage(obfuscator: SecretObfuscator, message: AgentMessage): AgentMessage {
|
|
372
436
|
switch (message.role) {
|
|
373
437
|
case "user":
|
|
374
|
-
case "developer":
|
|
375
|
-
case "toolResult": {
|
|
438
|
+
case "developer": {
|
|
376
439
|
const content = obfuscateTextualContent(obfuscator, message.content as TextualContent);
|
|
377
440
|
return content === message.content ? message : ({ ...(message as object), content } as AgentMessage);
|
|
378
441
|
}
|
|
442
|
+
case "toolResult": {
|
|
443
|
+
const msg = message as AgentMessage & {
|
|
444
|
+
content: TextualContent;
|
|
445
|
+
details?: Record<string, unknown>;
|
|
446
|
+
};
|
|
447
|
+
const content = obfuscateTextualContent(obfuscator, msg.content);
|
|
448
|
+
const details = obfuscateDetails(obfuscator, msg.details);
|
|
449
|
+
if (content === msg.content && details === msg.details) return message;
|
|
450
|
+
return { ...(message as object), content, details } as AgentMessage;
|
|
451
|
+
}
|
|
379
452
|
case "assistant":
|
|
380
453
|
return obfuscateAssistantMessage(obfuscator, message as AssistantMessage) as AgentMessage;
|
|
381
454
|
case "custom":
|