pi-advisor-flow 0.5.1 → 0.5.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 +21 -0
- package/README.md +3 -2
- package/extensions/index.ts +7 -7
- package/package.json +2 -1
- package/src/commands/activation-preparation.ts +117 -0
- package/src/commands/activation.ts +135 -0
- package/src/commands/lifecycle.ts +83 -0
- package/src/commands/manual-command.ts +107 -0
- package/src/commands/manual-consultation.ts +144 -0
- package/src/commands/manual-progress.ts +93 -0
- package/src/commands/model-commands.ts +66 -0
- package/src/commands/model-options.ts +181 -0
- package/src/commands/model-picker.ts +110 -0
- package/src/commands/registration.ts +27 -0
- package/src/commands/renderers.ts +76 -0
- package/src/commands/runtime.ts +114 -0
- package/src/commands/settings-commands.ts +80 -0
- package/src/commands/settings-persistence.ts +81 -0
- package/src/commands/types.ts +78 -0
- package/src/commands.ts +9 -1259
- package/src/config/args.ts +37 -0
- package/src/config/defaults.ts +190 -0
- package/src/config/state.ts +199 -0
- package/src/config/storage.ts +224 -0
- package/src/config/types.ts +63 -0
- package/src/config/validation.ts +234 -0
- package/src/config.ts +104 -814
- package/src/conversation.ts +4 -2
- package/src/herdr.ts +1 -1
- package/src/model-stream.ts +15 -1
- package/src/scout-context.ts +2 -2
- package/src/scout.ts +1 -1
- package/src/tools/consultation.ts +337 -0
- package/src/tools/gate-policy.ts +132 -0
- package/src/tools/gate-protocol.ts +127 -0
- package/src/tools/loop-gate.ts +210 -0
- package/src/tools/prompts.ts +159 -0
- package/src/tools/register-ask-advisor.ts +247 -0
- package/src/tools/register-lifecycle.ts +119 -0
- package/src/tools/register-outcome.ts +75 -0
- package/src/tools/register-renderers.ts +88 -0
- package/src/tools/registration.ts +35 -0
- package/src/tools/render-advisor-result.ts +168 -0
- package/src/tools/render-common.ts +120 -0
- package/src/tools/scout-status.ts +206 -0
- package/src/tools/session.ts +3 -0
- package/src/tools/types.ts +119 -0
- package/src/tools.ts +42 -1865
- package/src/ui/manual-dialog-render.ts +150 -0
- package/src/ui/manual-dialog.ts +305 -0
- package/src/ui/model-selector.ts +166 -0
- package/src/ui/settings-formatting.ts +133 -0
- package/src/ui/settings-items.ts +323 -0
- package/src/ui/settings-list-adapter.ts +103 -0
- package/src/ui/settings-mutations.ts +80 -0
- package/src/ui/settings-selector.ts +161 -0
- package/src/ui/text-setting-submenu.ts +66 -0
- package/src/ui/types.ts +111 -0
- package/src/ui.ts +9 -1418
package/src/conversation.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
|
-
type AdvisorToolPolicies,
|
|
4
3
|
advisorRedactSecretsRef,
|
|
5
4
|
advisorToolPoliciesRef,
|
|
6
5
|
advisorToolResultMaxBytesRef,
|
|
7
6
|
advisorToolResultMaxLinesRef,
|
|
7
|
+
} from "./config/state.js";
|
|
8
|
+
import type { AdvisorToolPolicies } from "./config/types.js";
|
|
9
|
+
import {
|
|
8
10
|
DEFAULT_ADVISOR_TOOL_RESULT_MAX_BYTES,
|
|
9
11
|
DEFAULT_ADVISOR_TOOL_RESULT_MAX_LINES,
|
|
10
|
-
} from "./config.js";
|
|
12
|
+
} from "./config/types.js";
|
|
11
13
|
|
|
12
14
|
type RecordValue = Record<string, unknown>;
|
|
13
15
|
|
package/src/herdr.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import net from "node:net";
|
|
2
|
-
import { getAdvisorSettings } from "./config.js";
|
|
2
|
+
import { getAdvisorSettings } from "./config/state.js";
|
|
3
3
|
import { redactSecrets } from "./conversation.js";
|
|
4
4
|
|
|
5
5
|
// Keep the JSON-RPC method components separate from Socket's URL-string heuristic.
|
package/src/model-stream.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
stream,
|
|
7
7
|
} from "@earendil-works/pi-ai/compat";
|
|
8
8
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { splitRef } from "./config.js";
|
|
9
|
+
import { splitRef } from "./config/state.js";
|
|
10
10
|
|
|
11
11
|
export interface ResolvedConfiguredModel {
|
|
12
12
|
apiKey: string;
|
|
@@ -218,9 +218,23 @@ export const collectTextStream = async (
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
const response = await eventStream.result();
|
|
221
|
+
if (options.signal?.aborted) {
|
|
222
|
+
throw options.signal.reason instanceof Error
|
|
223
|
+
? options.signal.reason
|
|
224
|
+
: new Error("Advisor operation cancelled.");
|
|
225
|
+
}
|
|
221
226
|
const lastAssistant = [response].find(
|
|
222
227
|
(message): message is AssistantMessage => message.role === "assistant"
|
|
223
228
|
);
|
|
229
|
+
if (
|
|
230
|
+
lastAssistant?.stopReason === "error" ||
|
|
231
|
+
lastAssistant?.stopReason === "aborted"
|
|
232
|
+
) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
lastAssistant.errorMessage ??
|
|
235
|
+
`Advisor stream ended with ${lastAssistant.stopReason}.`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
224
238
|
const finalText =
|
|
225
239
|
lastAssistant?.content
|
|
226
240
|
.filter(
|
package/src/scout-context.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import {
|
|
4
|
-
type AdvisorToolPolicies,
|
|
5
4
|
advisorRedactSecretsRef,
|
|
6
5
|
advisorToolPoliciesRef,
|
|
7
6
|
advisorToolResultMaxBytesRef,
|
|
8
7
|
advisorToolResultMaxLinesRef,
|
|
9
|
-
} from "./config.js";
|
|
8
|
+
} from "./config/state.js";
|
|
9
|
+
import type { AdvisorToolPolicies } from "./config/types.js";
|
|
10
10
|
import { conversationEntry, textFrom } from "./conversation.js";
|
|
11
11
|
|
|
12
12
|
export const SCOUT_MANIFEST_MAX_BYTES = 64 * 1024;
|
package/src/scout.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Message } from "@earendil-works/pi-ai/compat";
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { executorEffortRef, executorRef } from "./config.js";
|
|
3
|
+
import { executorEffortRef, executorRef } from "./config/state.js";
|
|
4
4
|
import {
|
|
5
5
|
type CollectedTextStream,
|
|
6
6
|
collectTextStream,
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Message } from "@earendil-works/pi-ai/compat";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
advisorEffortRef,
|
|
6
|
+
advisorGitContextMaxCharsRef,
|
|
7
|
+
advisorGitContextRef,
|
|
8
|
+
advisorRedactSecretsRef,
|
|
9
|
+
advisorRef,
|
|
10
|
+
advisorScoutEnabledRef,
|
|
11
|
+
advisorTrackedFileContentRef,
|
|
12
|
+
advisorUntrackedContentRef,
|
|
13
|
+
contextMaxCharsRef,
|
|
14
|
+
executorRef,
|
|
15
|
+
} from "../config/state.js";
|
|
16
|
+
import { loadConfig } from "../config/storage.js";
|
|
17
|
+
import { redactAndCapText, redactSecrets } from "../conversation.js";
|
|
18
|
+
import {
|
|
19
|
+
clampGitContextLevel,
|
|
20
|
+
collectGitContext,
|
|
21
|
+
type GitContextLevel,
|
|
22
|
+
} from "../git.js";
|
|
23
|
+
import { collectTextStream, resolveConfiguredModel } from "../model-stream.js";
|
|
24
|
+
import { readProjectPreferences } from "../preferences.js";
|
|
25
|
+
import {
|
|
26
|
+
runAdvisorScout,
|
|
27
|
+
type ScoutLifecycleEvent,
|
|
28
|
+
type ScoutOutcome,
|
|
29
|
+
} from "../scout.js";
|
|
30
|
+
import {
|
|
31
|
+
buildScoutManifest,
|
|
32
|
+
reconstructScoutConversation,
|
|
33
|
+
SCOUT_MANIFEST_MAX_BYTES,
|
|
34
|
+
} from "../scout-context.js";
|
|
35
|
+
import type { ConsultationTrigger, GateTrigger } from "../session-state.js";
|
|
36
|
+
import { readTrackedFiles, readUntrackedFiles } from "../untracked.js";
|
|
37
|
+
import { parseAutomaticDecision } from "./gate-protocol.js";
|
|
38
|
+
import {
|
|
39
|
+
ADVISOR_DECISION_SYSTEM,
|
|
40
|
+
ADVISOR_SYSTEM,
|
|
41
|
+
advisorGitContextBudget,
|
|
42
|
+
advisorMessageText,
|
|
43
|
+
advisorRepositoryContext,
|
|
44
|
+
advisorRequestConversation,
|
|
45
|
+
} from "./prompts.js";
|
|
46
|
+
import type { AdvisorConsultationResult, AdvisorGateOutcome } from "./types.js";
|
|
47
|
+
|
|
48
|
+
export const curateAdvisorConversation = async (
|
|
49
|
+
ctx: ExtensionContext,
|
|
50
|
+
legacyConversation: string,
|
|
51
|
+
signal?: AbortSignal,
|
|
52
|
+
onScout?: (event: ScoutLifecycleEvent) => void,
|
|
53
|
+
enabled = advisorScoutEnabledRef,
|
|
54
|
+
runScout: typeof runAdvisorScout = runAdvisorScout,
|
|
55
|
+
currentInvocationId?: string,
|
|
56
|
+
maxChars?: number
|
|
57
|
+
): Promise<{
|
|
58
|
+
conversation: string;
|
|
59
|
+
scout?: Exclude<ScoutOutcome, { cancelled: true }>;
|
|
60
|
+
}> => {
|
|
61
|
+
if (!enabled) {
|
|
62
|
+
return { conversation: legacyConversation };
|
|
63
|
+
}
|
|
64
|
+
if (maxChars !== undefined && maxChars <= 0) {
|
|
65
|
+
return { conversation: "" };
|
|
66
|
+
}
|
|
67
|
+
const built = buildScoutManifest(ctx, {
|
|
68
|
+
currentInvocationId,
|
|
69
|
+
maxConversationChars: maxChars,
|
|
70
|
+
maxManifestBytes: SCOUT_MANIFEST_MAX_BYTES,
|
|
71
|
+
});
|
|
72
|
+
if (!built.ok) {
|
|
73
|
+
const scout: Exclude<ScoutOutcome, { cancelled: true }> = {
|
|
74
|
+
category: built.reason,
|
|
75
|
+
message: built.message,
|
|
76
|
+
metrics: {
|
|
77
|
+
availableCount: 0,
|
|
78
|
+
inputBytes: 0,
|
|
79
|
+
latencyMs: 0,
|
|
80
|
+
omittedBeforeScout: 0,
|
|
81
|
+
selectedCount: 0,
|
|
82
|
+
},
|
|
83
|
+
model: executorRef,
|
|
84
|
+
ok: false,
|
|
85
|
+
};
|
|
86
|
+
onScout?.({ outcome: scout, type: "fallback" });
|
|
87
|
+
return { conversation: legacyConversation, scout };
|
|
88
|
+
}
|
|
89
|
+
const outcome = await runScout(
|
|
90
|
+
ctx,
|
|
91
|
+
built.manifest,
|
|
92
|
+
signal,
|
|
93
|
+
onScout,
|
|
94
|
+
undefined,
|
|
95
|
+
undefined
|
|
96
|
+
);
|
|
97
|
+
if (!outcome.ok && outcome.cancelled) {
|
|
98
|
+
throw signal?.reason instanceof Error
|
|
99
|
+
? signal.reason
|
|
100
|
+
: new Error("Advisor operation cancelled during Scout.");
|
|
101
|
+
}
|
|
102
|
+
let conversation = legacyConversation;
|
|
103
|
+
if (outcome.ok) {
|
|
104
|
+
conversation =
|
|
105
|
+
maxChars === undefined
|
|
106
|
+
? outcome.conversation
|
|
107
|
+
: reconstructScoutConversation(
|
|
108
|
+
built.manifest,
|
|
109
|
+
outcome.selection.selectedIds,
|
|
110
|
+
outcome.selection.synthesis,
|
|
111
|
+
maxChars
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return { conversation, scout: outcome };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const collectAdvisorResponse = async (
|
|
118
|
+
ctx: ExtensionContext,
|
|
119
|
+
systemPrompt: string,
|
|
120
|
+
question: string | undefined,
|
|
121
|
+
signal: AbortSignal | undefined,
|
|
122
|
+
onChunk: ((thinking: string, text: string) => void) | undefined,
|
|
123
|
+
gitContext?: GitContextLevel,
|
|
124
|
+
draft?: string,
|
|
125
|
+
includeUntracked?: string[],
|
|
126
|
+
includeTracked?: string[],
|
|
127
|
+
onScout?: (event: ScoutLifecycleEvent) => void,
|
|
128
|
+
currentInvocationId?: string
|
|
129
|
+
) => {
|
|
130
|
+
loadConfig(ctx);
|
|
131
|
+
const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor");
|
|
132
|
+
|
|
133
|
+
// The user setting is the ceiling; the Executor may only narrow it.
|
|
134
|
+
const allowed = advisorGitContextRef;
|
|
135
|
+
const level = clampGitContextLevel(gitContext ?? allowed, allowed);
|
|
136
|
+
const gitBudget = advisorGitContextBudget(
|
|
137
|
+
contextMaxCharsRef,
|
|
138
|
+
advisorGitContextMaxCharsRef
|
|
139
|
+
);
|
|
140
|
+
const changes = collectGitContext(
|
|
141
|
+
ctx.cwd,
|
|
142
|
+
level,
|
|
143
|
+
gitBudget,
|
|
144
|
+
advisorRedactSecretsRef ? redactSecrets : undefined
|
|
145
|
+
);
|
|
146
|
+
// The note is placed first so a cap can never drop the statement that the
|
|
147
|
+
// Advisor's view of the repository is limited.
|
|
148
|
+
// The disclosure warning is control metadata, not repository payload. Keep it
|
|
149
|
+
// outside the zero-byte Git budget so disabling disclosure cannot erase it.
|
|
150
|
+
const changeText = advisorRepositoryContext(
|
|
151
|
+
changes,
|
|
152
|
+
gitContext ?? allowed,
|
|
153
|
+
level,
|
|
154
|
+
gitBudget
|
|
155
|
+
);
|
|
156
|
+
// Repository context spends part of the shared budget, so a large patch
|
|
157
|
+
// cannot silently push the conversation past the model's context window.
|
|
158
|
+
const conversationBudget = Math.max(
|
|
159
|
+
0,
|
|
160
|
+
contextMaxCharsRef - changeText.length
|
|
161
|
+
);
|
|
162
|
+
const legacyConversation = advisorRequestConversation(
|
|
163
|
+
ctx,
|
|
164
|
+
conversationBudget
|
|
165
|
+
);
|
|
166
|
+
const curated = await curateAdvisorConversation(
|
|
167
|
+
ctx,
|
|
168
|
+
legacyConversation,
|
|
169
|
+
signal,
|
|
170
|
+
onScout,
|
|
171
|
+
advisorScoutEnabledRef,
|
|
172
|
+
runAdvisorScout,
|
|
173
|
+
currentInvocationId,
|
|
174
|
+
conversationBudget
|
|
175
|
+
);
|
|
176
|
+
const { conversation, scout } = curated;
|
|
177
|
+
const preferences = await readProjectPreferences(
|
|
178
|
+
ctx,
|
|
179
|
+
8 * 1024,
|
|
180
|
+
advisorRedactSecretsRef
|
|
181
|
+
);
|
|
182
|
+
const draftText = draft
|
|
183
|
+
? redactAndCapText(draft, 8 * 1024, advisorRedactSecretsRef)
|
|
184
|
+
: undefined;
|
|
185
|
+
const untracked = await readUntrackedFiles(
|
|
186
|
+
ctx.cwd,
|
|
187
|
+
includeUntracked ?? [],
|
|
188
|
+
advisorUntrackedContentRef,
|
|
189
|
+
advisorRedactSecretsRef
|
|
190
|
+
);
|
|
191
|
+
const tracked = await readTrackedFiles(
|
|
192
|
+
ctx.cwd,
|
|
193
|
+
includeTracked ?? [],
|
|
194
|
+
advisorTrackedFileContentRef,
|
|
195
|
+
advisorRedactSecretsRef,
|
|
196
|
+
Math.max(
|
|
197
|
+
0,
|
|
198
|
+
24 * 1024 - untracked.reduce((sum, item) => sum + item.bytes, 0)
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
// Keep the original question available to local UI callers, but never send
|
|
202
|
+
// its credential-shaped values to the provider when redaction is enabled.
|
|
203
|
+
const outboundQuestion =
|
|
204
|
+
advisorRedactSecretsRef && question !== undefined
|
|
205
|
+
? redactSecrets(question)
|
|
206
|
+
: question;
|
|
207
|
+
const messages: Message[] = [
|
|
208
|
+
{
|
|
209
|
+
content: [
|
|
210
|
+
{
|
|
211
|
+
text: advisorMessageText(
|
|
212
|
+
conversation,
|
|
213
|
+
outboundQuestion,
|
|
214
|
+
changeText,
|
|
215
|
+
draftText,
|
|
216
|
+
preferences?.text,
|
|
217
|
+
untracked.map(
|
|
218
|
+
(item) =>
|
|
219
|
+
`<file path=${JSON.stringify(item.path)}>\n${item.text}\n</file>`
|
|
220
|
+
),
|
|
221
|
+
tracked.map(
|
|
222
|
+
(item) =>
|
|
223
|
+
`<file path=${JSON.stringify(item.path)}>\n${item.text}\n</file>`
|
|
224
|
+
)
|
|
225
|
+
),
|
|
226
|
+
type: "text",
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
role: "user",
|
|
230
|
+
timestamp: Date.now(),
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
const streamed = await collectTextStream(resolved, {
|
|
235
|
+
messages,
|
|
236
|
+
onChunk,
|
|
237
|
+
reasoning: advisorEffortRef,
|
|
238
|
+
signal,
|
|
239
|
+
systemPrompt,
|
|
240
|
+
});
|
|
241
|
+
const markdown = streamed.text;
|
|
242
|
+
if (!markdown.trim()) {
|
|
243
|
+
throw new Error("Advisor returned no advice.");
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
draftBytes: draftText ? Buffer.byteLength(draftText, "utf8") : undefined,
|
|
247
|
+
markdown,
|
|
248
|
+
model: advisorRef,
|
|
249
|
+
preferenceBytes: preferences?.bytes,
|
|
250
|
+
thinkingText: streamed.thinking,
|
|
251
|
+
trackedBytes:
|
|
252
|
+
tracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
|
|
253
|
+
untrackedBytes:
|
|
254
|
+
untracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
|
|
255
|
+
usage: streamed.usage,
|
|
256
|
+
...(scout ? { scout } : {}),
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export const consultAdvisor = async (
|
|
261
|
+
ctx: ExtensionContext,
|
|
262
|
+
question?: string,
|
|
263
|
+
signal?: AbortSignal,
|
|
264
|
+
onChunk?: (thinking: string, text: string) => void,
|
|
265
|
+
trigger: ConsultationTrigger = "executor-requested",
|
|
266
|
+
gitContext?: GitContextLevel,
|
|
267
|
+
draft?: string,
|
|
268
|
+
includeUntracked?: string[],
|
|
269
|
+
includeTracked?: string[],
|
|
270
|
+
onScout?: (event: ScoutLifecycleEvent) => void,
|
|
271
|
+
currentInvocationId?: string
|
|
272
|
+
): Promise<AdvisorConsultationResult> => {
|
|
273
|
+
const result = await collectAdvisorResponse(
|
|
274
|
+
ctx,
|
|
275
|
+
ADVISOR_SYSTEM,
|
|
276
|
+
question,
|
|
277
|
+
signal,
|
|
278
|
+
onChunk,
|
|
279
|
+
gitContext,
|
|
280
|
+
draft,
|
|
281
|
+
includeUntracked,
|
|
282
|
+
includeTracked,
|
|
283
|
+
onScout,
|
|
284
|
+
currentInvocationId
|
|
285
|
+
);
|
|
286
|
+
return { ...result, adviceId: randomUUID(), trigger };
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export const runAdvisorGate = async (
|
|
290
|
+
ctx: ExtensionContext,
|
|
291
|
+
question: string,
|
|
292
|
+
trigger: GateTrigger = "repeated-tool-call",
|
|
293
|
+
signal?: AbortSignal,
|
|
294
|
+
onChunk?: (thinking: string, text: string) => void,
|
|
295
|
+
onScout?: (event: ScoutLifecycleEvent) => void,
|
|
296
|
+
currentInvocationId?: string
|
|
297
|
+
): Promise<AdvisorGateOutcome> => {
|
|
298
|
+
try {
|
|
299
|
+
const result = await collectAdvisorResponse(
|
|
300
|
+
ctx,
|
|
301
|
+
ADVISOR_DECISION_SYSTEM,
|
|
302
|
+
question,
|
|
303
|
+
signal,
|
|
304
|
+
onChunk,
|
|
305
|
+
undefined,
|
|
306
|
+
undefined,
|
|
307
|
+
undefined,
|
|
308
|
+
undefined,
|
|
309
|
+
onScout,
|
|
310
|
+
currentInvocationId
|
|
311
|
+
);
|
|
312
|
+
const parsed = parseAutomaticDecision(result.markdown);
|
|
313
|
+
if (!parsed.ok) {
|
|
314
|
+
return { ...parsed, usage: result.usage };
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
...parsed,
|
|
318
|
+
model: result.model,
|
|
319
|
+
thinkingText: result.thinkingText,
|
|
320
|
+
trigger,
|
|
321
|
+
usage: result.usage,
|
|
322
|
+
};
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (signal?.aborted) {
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
328
|
+
return {
|
|
329
|
+
category:
|
|
330
|
+
message === "Advisor returned no advice."
|
|
331
|
+
? "empty-response"
|
|
332
|
+
: "provider-error",
|
|
333
|
+
message,
|
|
334
|
+
ok: false,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionContext,
|
|
3
|
+
ToolCallEvent,
|
|
4
|
+
ToolCallEventResult,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
advisorBlockOnBlockedRef,
|
|
8
|
+
getAdvisorMaxCallsPerSession,
|
|
9
|
+
getAdvisorSettings,
|
|
10
|
+
isSimpleMode,
|
|
11
|
+
} from "../config/state.js";
|
|
12
|
+
import type { GateFailureMode } from "../config/types.js";
|
|
13
|
+
import { herdrAdvisorBlock, notifyHerdrAdvisorFailure } from "../herdr.js";
|
|
14
|
+
import type { AdvisorSessionState } from "../session-state.js";
|
|
15
|
+
import type { AdvisorGateResult, GateFailureCategory } from "./types.js";
|
|
16
|
+
|
|
17
|
+
export const updateAdvisorUsageStatus = (
|
|
18
|
+
ctx: ExtensionContext,
|
|
19
|
+
session: AdvisorSessionState
|
|
20
|
+
) => {
|
|
21
|
+
if (ctx.hasUI) {
|
|
22
|
+
ctx.ui.setStatus(
|
|
23
|
+
"advisor-usage",
|
|
24
|
+
getAdvisorSettings().showUsageFooter ? session.usageStatus() : undefined
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const notifyLocalFailure = (
|
|
30
|
+
ctx: ExtensionContext,
|
|
31
|
+
message: string,
|
|
32
|
+
sessionBlocked = false
|
|
33
|
+
) => {
|
|
34
|
+
if (ctx.hasUI) {
|
|
35
|
+
ctx.ui.notify(
|
|
36
|
+
`Advisor ${sessionBlocked ? "gate failure; session blocked" : "consultation failed"}: ${message}`,
|
|
37
|
+
"error"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const gateFailureEffectForMode = (mode: GateFailureMode) => {
|
|
43
|
+
if (mode === "warn-and-continue") {
|
|
44
|
+
return "continued" as const;
|
|
45
|
+
}
|
|
46
|
+
return mode === "block-tool"
|
|
47
|
+
? ("tool-blocked" as const)
|
|
48
|
+
: ("session-blocked" as const);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const gateDecisionEffect = (
|
|
52
|
+
decision: AdvisorGateResult["decision"],
|
|
53
|
+
failureMode: GateFailureMode
|
|
54
|
+
) => {
|
|
55
|
+
if (decision === "proceed") {
|
|
56
|
+
return "continued" as const;
|
|
57
|
+
}
|
|
58
|
+
return decision === "blocked"
|
|
59
|
+
? gateFailureEffectForMode(failureMode)
|
|
60
|
+
: ("tool-blocked" as const);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const failureEffect = (
|
|
64
|
+
category: GateFailureCategory,
|
|
65
|
+
message: string,
|
|
66
|
+
ctx: ExtensionContext,
|
|
67
|
+
session: AdvisorSessionState,
|
|
68
|
+
failureMode: GateFailureMode
|
|
69
|
+
) => {
|
|
70
|
+
const reason = `Advisor gate ${category}: ${message}`;
|
|
71
|
+
notifyLocalFailure(ctx, message, failureMode === "block-session");
|
|
72
|
+
notifyHerdrAdvisorFailure("Advisor gate failure", reason);
|
|
73
|
+
if (failureMode === "warn-and-continue") {
|
|
74
|
+
return { block: false, effect: "continued" as const, reason };
|
|
75
|
+
}
|
|
76
|
+
if (failureMode === "block-tool") {
|
|
77
|
+
return { block: true, effect: "tool-blocked" as const, reason };
|
|
78
|
+
}
|
|
79
|
+
session.block(reason);
|
|
80
|
+
herdrAdvisorBlock.set(reason);
|
|
81
|
+
if (advisorBlockOnBlockedRef) {
|
|
82
|
+
ctx.abort();
|
|
83
|
+
}
|
|
84
|
+
return { block: true, effect: "session-blocked" as const, reason };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const blockedDecisionEffect = (
|
|
88
|
+
reason: string,
|
|
89
|
+
ctx: ExtensionContext,
|
|
90
|
+
session: AdvisorSessionState,
|
|
91
|
+
failureMode: GateFailureMode
|
|
92
|
+
) => {
|
|
93
|
+
if (failureMode === "warn-and-continue") {
|
|
94
|
+
if (ctx.hasUI) {
|
|
95
|
+
ctx.ui.notify(
|
|
96
|
+
"Advisor gate returned blocked; continuing by configuration.",
|
|
97
|
+
"warning"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return { block: false, effect: "continued" as const, reason };
|
|
101
|
+
}
|
|
102
|
+
if (failureMode === "block-tool") {
|
|
103
|
+
return { block: true, effect: "tool-blocked" as const, reason };
|
|
104
|
+
}
|
|
105
|
+
session.block(reason);
|
|
106
|
+
herdrAdvisorBlock.set(reason);
|
|
107
|
+
if (advisorBlockOnBlockedRef) {
|
|
108
|
+
ctx.abort();
|
|
109
|
+
}
|
|
110
|
+
return { block: true, effect: "session-blocked" as const, reason };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const reserveAdvisorCall = (
|
|
114
|
+
event: ToolCallEvent,
|
|
115
|
+
ctx: ExtensionContext,
|
|
116
|
+
session: AdvisorSessionState,
|
|
117
|
+
reservedCalls: Set<string>
|
|
118
|
+
): ToolCallEventResult | undefined => {
|
|
119
|
+
if (event.toolName !== "ask_advisor" || isSimpleMode()) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!session.canConsult(getAdvisorMaxCallsPerSession())) {
|
|
123
|
+
const message = "Advisor call budget exhausted for this session.";
|
|
124
|
+
if (ctx.hasUI) {
|
|
125
|
+
ctx.ui.notify(message, "warning");
|
|
126
|
+
}
|
|
127
|
+
notifyHerdrAdvisorFailure("Advisor budget exhausted", message);
|
|
128
|
+
return { block: true, reason: message };
|
|
129
|
+
}
|
|
130
|
+
reservedCalls.add(event.toolCallId);
|
|
131
|
+
return {};
|
|
132
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { GateDecision } from "../session-state.js";
|
|
2
|
+
import type { AdvisorGateFailure, AdvisorGateResult } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const DECISION_LINE = /^Decision\s*:\s*(proceed|revise|blocked)\s*$/i;
|
|
5
|
+
const CODE_FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
6
|
+
const LINE_BREAK = /\r?\n/;
|
|
7
|
+
|
|
8
|
+
interface FenceState {
|
|
9
|
+
character: string;
|
|
10
|
+
length: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const advanceFence = (
|
|
14
|
+
openingFence: FenceState | undefined,
|
|
15
|
+
marker: string,
|
|
16
|
+
suffix: string
|
|
17
|
+
): { closed: boolean; openingFence: FenceState | undefined } => {
|
|
18
|
+
if (!openingFence) {
|
|
19
|
+
if (marker[0] === "`" && suffix.includes("`")) {
|
|
20
|
+
return { closed: false, openingFence: undefined };
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
closed: false,
|
|
24
|
+
openingFence: { character: marker[0], length: marker.length },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (
|
|
28
|
+
suffix.trim().length > 0 ||
|
|
29
|
+
marker[0] !== openingFence.character ||
|
|
30
|
+
marker.length < openingFence.length
|
|
31
|
+
) {
|
|
32
|
+
return { closed: false, openingFence };
|
|
33
|
+
}
|
|
34
|
+
return { closed: true, openingFence: undefined };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const parseAutomaticDecision = (
|
|
38
|
+
text: string
|
|
39
|
+
): AdvisorGateResult | AdvisorGateFailure => {
|
|
40
|
+
const lines = text.split(LINE_BREAK);
|
|
41
|
+
const nonEmpty = lines.findIndex((line) => line.trim().length > 0);
|
|
42
|
+
if (nonEmpty === -1) {
|
|
43
|
+
return {
|
|
44
|
+
category: "empty-response",
|
|
45
|
+
message: "Advisor returned an empty gate response.",
|
|
46
|
+
ok: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const first = lines[nonEmpty].trim();
|
|
50
|
+
const match = DECISION_LINE.exec(first);
|
|
51
|
+
if (!match) {
|
|
52
|
+
return {
|
|
53
|
+
category: first.toLowerCase().startsWith("decision:")
|
|
54
|
+
? "malformed-decision"
|
|
55
|
+
: "missing-decision",
|
|
56
|
+
markdown: text,
|
|
57
|
+
message:
|
|
58
|
+
"Advisor gate response must begin with Decision: proceed, Decision: revise, or Decision: blocked.",
|
|
59
|
+
ok: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const decision = match[1].toLowerCase() as GateDecision;
|
|
63
|
+
let openingFence: FenceState | undefined;
|
|
64
|
+
const decisions: string[] = [];
|
|
65
|
+
let pendingFencedDecisions: string[] = [];
|
|
66
|
+
for (const line of lines.slice(nonEmpty + 1)) {
|
|
67
|
+
const trimmed = line.trim();
|
|
68
|
+
// Decisions in a balanced fenced example are illustrative. Remember the
|
|
69
|
+
// opening delimiter so mismatched, undersized, or annotated fences cannot
|
|
70
|
+
// close it and hide a contradictory verdict. If the fence never closes,
|
|
71
|
+
// retain its decisions so malformed Markdown cannot make the gate fail
|
|
72
|
+
// open.
|
|
73
|
+
const fence = CODE_FENCE.exec(line);
|
|
74
|
+
if (fence) {
|
|
75
|
+
const { closed, openingFence: nextOpeningFence } = advanceFence(
|
|
76
|
+
openingFence,
|
|
77
|
+
fence[1],
|
|
78
|
+
fence[2]
|
|
79
|
+
);
|
|
80
|
+
openingFence = nextOpeningFence;
|
|
81
|
+
if (closed) {
|
|
82
|
+
pendingFencedDecisions = [];
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const subsequent = DECISION_LINE.exec(trimmed);
|
|
87
|
+
if (!subsequent) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const repeated = subsequent[1].trim().toLowerCase();
|
|
91
|
+
if (openingFence) {
|
|
92
|
+
pendingFencedDecisions.push(repeated);
|
|
93
|
+
} else {
|
|
94
|
+
decisions.push(repeated);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (openingFence) {
|
|
98
|
+
decisions.push(...pendingFencedDecisions);
|
|
99
|
+
}
|
|
100
|
+
for (const repeated of decisions) {
|
|
101
|
+
if (repeated === decision) {
|
|
102
|
+
return {
|
|
103
|
+
category: "duplicate-decision",
|
|
104
|
+
markdown: text,
|
|
105
|
+
message: "Advisor gate response contains duplicate decision lines.",
|
|
106
|
+
ok: false,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
category: "contradictory-decision",
|
|
111
|
+
markdown: text,
|
|
112
|
+
message: "Advisor gate response contains contradictory decision lines.",
|
|
113
|
+
ok: false,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
decision,
|
|
118
|
+
markdown: text,
|
|
119
|
+
model: "",
|
|
120
|
+
ok: true,
|
|
121
|
+
thinkingText: "",
|
|
122
|
+
trigger: "repeated-tool-call",
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const adviceForGateText = (result: AdvisorGateResult) =>
|
|
127
|
+
`**Decision: ${result.decision}**\n\n${result.markdown}`;
|