pi-advisor-flow 0.1.9 → 0.2.1

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/src/tools.ts CHANGED
@@ -1,46 +1,134 @@
1
- import { stream, type Message, type AssistantMessage } from "@earendil-works/pi-ai/compat";
2
- import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ type AssistantMessage,
3
+ type Message,
4
+ stream,
5
+ } from "@earendil-works/pi-ai/compat";
6
+ import {
7
+ type AgentToolResult,
8
+ type ExtensionAPI,
9
+ type ExtensionContext,
10
+ getMarkdownTheme,
11
+ type Theme,
12
+ type ToolCallEvent,
13
+ type ToolCallEventResult,
14
+ type ToolRenderResultOptions,
15
+ } from "@earendil-works/pi-coding-agent";
3
16
  import { Box, Markdown, Text } from "@earendil-works/pi-tui";
4
17
  import { Type } from "typebox";
5
18
  import {
6
- advisorAutoLoopGateRef, advisorMaxCallsPerSessionRef, advisorBlockOnBlockedRef, advisorCollapseResponsesRef, advisorCompletionGateRef, advisorCustomInvocationRef, advisorFailureGateRef, advisorLoopThresholdRef, advisorPlanGateRef, advisorSessionSummaryRef,
7
- advisorRef, advisorEffortRef, contextMaxCharsRef, loadConfig, splitRef,
19
+ advisorAutoLoopGateRef,
20
+ advisorBlockOnBlockedRef,
21
+ advisorCollapseResponsesRef,
22
+ advisorCompletionGateRef,
23
+ advisorCustomInvocationRef,
24
+ advisorEffortRef,
25
+ advisorFailureGateRef,
26
+ advisorFailureModeRef,
27
+ advisorLoopThresholdRef,
28
+ advisorMaxCallsPerSessionRef,
29
+ advisorPlanGateRef,
30
+ advisorRef,
31
+ advisorSessionSummaryRef,
32
+ contextMaxCharsRef,
33
+ loadConfig,
34
+ splitRef,
8
35
  } from "./config.js";
9
36
  import { recentConversation, textFrom } from "./conversation.js";
10
- import { herdrAdvisorActivity, herdrAdvisorBlock } from "./herdr.js";
11
- import { AdvisorSessionState, type AdvisorVerdict } from "./session-state.js";
37
+ import {
38
+ herdrAdvisorActivity,
39
+ herdrAdvisorBlock,
40
+ notifyHerdrAdvisorFailure,
41
+ } from "./herdr.js";
42
+ import {
43
+ AdvisorSessionState,
44
+ type ConsultationTrigger,
45
+ type GateDecision,
46
+ type GateTrigger,
47
+ } from "./session-state.js";
48
+
49
+ export type {
50
+ AdvisorInvocationRecord,
51
+ ConsultationTrigger,
52
+ GateDecision,
53
+ GateTrigger,
54
+ } from "./session-state.js";
12
55
 
13
56
  export const advisorSessionState = new AdvisorSessionState();
14
57
 
15
- export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
- export const resolveAdvisorRequest = (question?: string) => question?.trim() || undefined;
58
+ export const SPINNER_FRAMES = [
59
+ "⠋",
60
+ "⠙",
61
+ "⠹",
62
+ "⠸",
63
+ "⠼",
64
+ "⠴",
65
+ "⠦",
66
+ "⠧",
67
+ "⠇",
68
+ "⠏",
69
+ ];
70
+ export const resolveAdvisorRequest = (question?: string) =>
71
+ question?.trim() || undefined;
17
72
  export const advisorMessageText = (conversation: string, question?: string) =>
18
73
  `${conversation ? `<conversation>\n${conversation}\n</conversation>` : ""}${question ? `\n\nTargeted focus:\n${question}` : ""}`;
19
74
 
20
- export const renderAdvisorCallBox = (question: string | undefined, theme: any) => {
75
+ export const renderAdvisorCallBox = (
76
+ question: string | undefined,
77
+ theme: Theme
78
+ ) => {
21
79
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
22
80
  const label = theme.fg("customMessageLabel", theme.bold("[advisor]"));
23
81
  const title = theme.fg("customMessageText", "Executor → Advisor");
24
- box.addChild(new Text(question ? `${label} ${title}\n${theme.fg("dim", ` ${question}`)}` : `${label} ${title}`, 0, 0));
82
+ box.addChild(
83
+ new Text(
84
+ question
85
+ ? `${label} ${title}\n${theme.fg("dim", ` ${question}`)}`
86
+ : `${label} ${title}`,
87
+ 0,
88
+ 0
89
+ )
90
+ );
25
91
  return box;
26
92
  };
27
93
 
28
94
  const COLLAPSED_ADVICE_LINES = 12;
29
95
 
30
96
  export const adviceForDisplay = (advice: string, expanded: boolean) => {
31
- if (!advisorCollapseResponsesRef || expanded) return advice;
97
+ if (!advisorCollapseResponsesRef || expanded) {
98
+ return advice;
99
+ }
32
100
  const lines = advice.split("\n");
33
- if (lines.length <= COLLAPSED_ADVICE_LINES) return advice;
101
+ if (lines.length <= COLLAPSED_ADVICE_LINES) {
102
+ return advice;
103
+ }
34
104
  return `${lines.slice(0, COLLAPSED_ADVICE_LINES).join("\n")}\n\n… (${lines.length - COLLAPSED_ADVICE_LINES} more lines, Ctrl+O to expand)`;
35
105
  };
36
106
 
37
107
  export const advisorInvocationGuidelines = () => {
38
108
  const guidelines: string[] = [];
39
- if (advisorPlanGateRef) guidelines.push("Before committing to a materially consequential plan, use ask_advisor after investigating and forming your own candidate direction. Use it to stress-test consequential architectural, security, data-loss, compatibility, or difficult-to-reverse decisions. Do not delegate the entire plan or task.");
40
- if (advisorFailureGateRef) guidelines.push("Use ask_advisor after two consecutive materially equivalent failed attempts, when a fix recreates an earlier failure, or after two actions produce no measurable progress. Do not make another materially equivalent attempt before consulting.");
41
- if (advisorCompletionGateRef) guidelines.push("Before declaring success, use ask_advisor to review the goal, changed files, key decisions, tests, results, and remaining risks. Skip this only for demonstrably trivial, low-risk work.");
42
- if (advisorCustomInvocationRef) guidelines.push(`Also use ask_advisor when: ${advisorCustomInvocationRef}`);
43
- if (guidelines.length > 0) guidelines.push("Call ask_advisor with an empty object by default. Do not invent a question merely to request a review: the Advisor already receives context. Include question only for a genuinely specific assumption or trade-off.");
109
+ if (advisorPlanGateRef) {
110
+ guidelines.push(
111
+ "Before committing to a materially consequential plan, use ask_advisor after investigating and forming your own candidate direction. Use it to stress-test consequential architectural, security, data-loss, compatibility, or difficult-to-reverse decisions. Do not delegate the entire plan or task."
112
+ );
113
+ }
114
+ if (advisorFailureGateRef) {
115
+ guidelines.push(
116
+ "Use ask_advisor after two consecutive materially equivalent failed attempts, when a fix recreates an earlier failure, or after two actions produce no measurable progress. Do not make another materially equivalent attempt before consulting."
117
+ );
118
+ }
119
+ if (advisorCompletionGateRef) {
120
+ guidelines.push(
121
+ "Before declaring success, use ask_advisor to review the goal, changed files, key decisions, tests, results, and remaining risks. Skip this only for demonstrably trivial, low-risk work."
122
+ );
123
+ }
124
+ if (advisorCustomInvocationRef) {
125
+ guidelines.push(`Also use ask_advisor when: ${advisorCustomInvocationRef}`);
126
+ }
127
+ if (guidelines.length > 0) {
128
+ guidelines.push(
129
+ "Call ask_advisor with an empty object by default. Do not invent a question merely to request a review: the Advisor already receives context. Include question only for a genuinely specific assumption or trade-off."
130
+ );
131
+ }
44
132
  return guidelines;
45
133
  };
46
134
 
@@ -55,70 +143,159 @@ export const ADVISOR_SYSTEM = [
55
143
  export const ADVISOR_DECISION_SYSTEM = [
56
144
  "You are the Advisor's automatic safety gate for a repeated-tool loop.",
57
145
  "Review the supplied context and decide whether the Executor may proceed.",
58
- "Answer in concise Markdown. Your first line must be exactly `Decision: proceed`, `Decision: revise`, `Decision: insufficient-evidence`, or `Decision: blocked`.",
146
+ "Answer in concise Markdown. Your first non-empty line must be exactly `Decision: proceed`, `Decision: revise`, or `Decision: blocked`.",
59
147
  "Use blocked only for a critical issue requiring the user. Never claim verification that the supplied evidence does not show.",
60
148
  ].join(" ");
61
149
 
62
- type Advice = { verdict: AdvisorVerdict; criticalFindings: Array<{ severity: string; claim: string; evidence: string }>; missingEvidence: string[]; smallestNextStep: string; verificationRequired: string[]; escalationReason: string | null };
150
+ export type GateFailureCategory =
151
+ | "provider-error"
152
+ | "empty-response"
153
+ | "missing-decision"
154
+ | "malformed-decision"
155
+ | "duplicate-decision"
156
+ | "contradictory-decision"
157
+ | "budget-exhausted";
158
+ export interface AdvisorGateFailure {
159
+ category: GateFailureCategory;
160
+ markdown?: string;
161
+ message: string;
162
+ ok: false;
163
+ }
164
+ export interface AdvisorConsultationResult {
165
+ markdown: string;
166
+ model: string;
167
+ thinkingText: string;
168
+ trigger: ConsultationTrigger;
169
+ usage?: unknown;
170
+ }
171
+ export interface AdvisorGateResult {
172
+ decision: GateDecision;
173
+ markdown: string;
174
+ model: string;
175
+ ok: true;
176
+ thinkingText: string;
177
+ trigger: GateTrigger;
178
+ usage?: unknown;
179
+ }
180
+ export type AdvisorGateOutcome = AdvisorGateResult | AdvisorGateFailure;
63
181
 
64
- export const parseAutomaticDecision = (text: string): Advice => {
65
- const firstLine = text.split(/\r?\n/, 1)[0];
66
- const match = /^Decision: (proceed|revise|blocked|insufficient-evidence)$/i.exec(firstLine);
67
- if (!match) return parseAdvice("");
68
- return { verdict: match[1] as AdvisorVerdict, criticalFindings: [], missingEvidence: [], smallestNextStep: text.trim(), verificationRequired: [], escalationReason: null };
182
+ export const advisorUsageCost = (usage: unknown): number | undefined => {
183
+ const value = usage as
184
+ | { cost?: { total?: unknown }; totalCost?: unknown }
185
+ | undefined;
186
+ const cost = value?.cost?.total ?? value?.totalCost;
187
+ return typeof cost === "number" ? cost : undefined;
69
188
  };
70
189
 
71
- export const parseAdvice = (text: string): Advice => {
72
- try {
73
- const value = JSON.parse(text) as Partial<Advice>;
74
- if (!["proceed", "revise", "blocked", "insufficient-evidence"].includes(value.verdict ?? "")) throw new Error("invalid verdict");
75
- if (!Array.isArray(value.criticalFindings) || !Array.isArray(value.missingEvidence) || !Array.isArray(value.verificationRequired) || typeof value.smallestNextStep !== "string" || !value.missingEvidence.every((item) => typeof item === "string") || !value.verificationRequired.every((item) => typeof item === "string") || !value.criticalFindings.every((item) => item && typeof item === "object" && typeof (item as Record<string, unknown>).severity === "string" && typeof (item as Record<string, unknown>).claim === "string" && typeof (item as Record<string, unknown>).evidence === "string")) throw new Error("invalid shape");
76
- return { verdict: value.verdict as AdvisorVerdict, criticalFindings: value.criticalFindings as Advice["criticalFindings"], missingEvidence: value.missingEvidence as string[], smallestNextStep: value.smallestNextStep, verificationRequired: value.verificationRequired as string[], escalationReason: typeof value.escalationReason === "string" ? value.escalationReason : null };
77
- } catch {
78
- return { verdict: "insufficient-evidence", criticalFindings: [{ severity: "medium", claim: "Advisor response was not structured for an automatic safety decision", evidence: "The response was not valid structured decision data." }], missingEvidence: [], smallestNextStep: "Review the Advisor's guidance before retrying.", verificationRequired: [], escalationReason: null };
190
+ const DECISION_LINE = /^Decision\s*:\s*(proceed|revise|blocked)\s*$/i;
191
+ const ANY_DECISION_LINE = /^Decision\s*:\s*(.*?)\s*$/i;
192
+ const LINE_BREAK = /\r?\n/;
193
+
194
+ export const parseAutomaticDecision = (
195
+ text: string
196
+ ): AdvisorGateResult | AdvisorGateFailure => {
197
+ const lines = text.split(LINE_BREAK);
198
+ const nonEmpty = lines.findIndex((line) => line.trim().length > 0);
199
+ if (nonEmpty === -1) {
200
+ return {
201
+ category: "empty-response",
202
+ message: "Advisor returned an empty gate response.",
203
+ ok: false,
204
+ };
205
+ }
206
+ const first = lines[nonEmpty].trim();
207
+ const match = DECISION_LINE.exec(first);
208
+ if (!match) {
209
+ return {
210
+ category: first.toLowerCase().startsWith("decision:")
211
+ ? "malformed-decision"
212
+ : "missing-decision",
213
+ markdown: text,
214
+ message:
215
+ "Advisor gate response must begin with Decision: proceed, Decision: revise, or Decision: blocked.",
216
+ ok: false,
217
+ };
79
218
  }
219
+ const decision = match[1].toLowerCase() as GateDecision;
220
+ for (const line of lines.slice(nonEmpty + 1)) {
221
+ const subsequent = ANY_DECISION_LINE.exec(line.trim());
222
+ if (!subsequent) {
223
+ continue;
224
+ }
225
+ const repeated = subsequent[1].trim().toLowerCase();
226
+ if (repeated === decision) {
227
+ return {
228
+ category: "duplicate-decision",
229
+ markdown: text,
230
+ message: "Advisor gate response contains duplicate decision lines.",
231
+ ok: false,
232
+ };
233
+ }
234
+ return {
235
+ category: "contradictory-decision",
236
+ markdown: text,
237
+ message: "Advisor gate response contains contradictory decision lines.",
238
+ ok: false,
239
+ };
240
+ }
241
+ return {
242
+ decision,
243
+ markdown: text,
244
+ model: "",
245
+ ok: true,
246
+ thinkingText: "",
247
+ trigger: "repeated-tool-call",
248
+ };
80
249
  };
81
250
 
82
- const adviceForText = (advice: Advice) => [
83
- `**Verdict: ${advice.verdict}**`,
84
- ...advice.criticalFindings.map((finding) => `- **${finding.severity}:** ${finding.claim}${finding.evidence ? ` — ${finding.evidence}` : ""}`),
85
- advice.missingEvidence.length ? `\n**Missing evidence**\n${advice.missingEvidence.map((item) => `- ${item}`).join("\n")}` : "",
86
- `\n**Smallest next step**\n${advice.smallestNextStep}`,
87
- advice.verificationRequired.length ? `\n**Required verification**\n${advice.verificationRequired.map((item) => `- ${item}`).join("\n")}` : "",
88
- ].filter(Boolean).join("\n");
251
+ const adviceForText = (result: AdvisorGateResult) =>
252
+ `**Decision: ${result.decision}**\n\n${result.markdown}`;
89
253
 
90
- export const consult = async (
254
+ const collectAdvisorResponse = async (
91
255
  ctx: ExtensionContext,
92
- question?: string,
93
- signal?: AbortSignal,
94
- onChunk?: (thinking: string, text: string) => void,
95
- structuredMode = false,
256
+ systemPrompt: string,
257
+ question: string | undefined,
258
+ signal: AbortSignal | undefined,
259
+ onChunk: ((thinking: string, text: string) => void) | undefined
96
260
  ) => {
97
261
  loadConfig(ctx);
98
262
  const [provider, modelId] = splitRef(advisorRef);
99
263
  const model = ctx.modelRegistry.find(provider, modelId);
100
- if (!model) throw new Error(`Advisor model not found: ${advisorRef}`);
264
+ if (!model) {
265
+ throw new Error(`Advisor model not found: ${advisorRef}`);
266
+ }
101
267
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
102
- if (!auth.ok) throw new Error((auth as { error: string }).error);
103
- if (!auth.apiKey) throw new Error(`No API key for ${advisorRef}`);
268
+ if (!auth.ok) {
269
+ throw new Error((auth as { error: string }).error);
270
+ }
271
+ if (!auth.apiKey) {
272
+ throw new Error(`No API key for ${advisorRef}`);
273
+ }
104
274
 
105
275
  const conversation = recentConversation(ctx, contextMaxCharsRef);
106
- const messages: Message[] = [{
107
- role: "user",
108
- content: [{ type: "text", text: advisorMessageText(conversation, question) }],
109
- timestamp: Date.now(),
110
- }];
276
+ const messages: Message[] = [
277
+ {
278
+ content: [
279
+ { text: advisorMessageText(conversation, question), type: "text" },
280
+ ],
281
+ role: "user",
282
+ timestamp: Date.now(),
283
+ },
284
+ ];
111
285
 
112
286
  let thinkingText = "";
113
287
  let responseText = "";
114
-
115
- const eventStream = stream(model, { systemPrompt: structuredMode ? ADVISOR_DECISION_SYSTEM : ADVISOR_SYSTEM, messages }, {
116
- apiKey: auth.apiKey,
117
- headers: auth.headers,
118
- env: auth.env,
119
- signal,
120
- reasoning: advisorEffortRef as any,
121
- });
288
+ const eventStream = stream(
289
+ model,
290
+ { messages, systemPrompt },
291
+ {
292
+ apiKey: auth.apiKey,
293
+ env: auth.env,
294
+ headers: auth.headers,
295
+ reasoning: advisorEffortRef as never,
296
+ signal,
297
+ }
298
+ );
122
299
 
123
300
  for await (const event of eventStream) {
124
301
  if (event.type === "thinking_delta") {
@@ -131,182 +308,610 @@ export const consult = async (
131
308
  }
132
309
 
133
310
  const response = await eventStream.result();
134
- const lastAssistant = [response].find((m): m is AssistantMessage => m.role === "assistant");
135
- const advice = lastAssistant?.content
136
- .filter((part): part is { type: "text"; text: string } => part.type === "text")
137
- .map((part) => part.text)
138
- .join("\n")
139
- .trim() || responseText;
140
-
141
- if (!advice) throw new Error("Advisor returned no advice.");
142
- const structured = structuredMode ? parseAutomaticDecision(advice) : parseAdvice(advice);
143
- return { advice, thinkingText, structured };
311
+ const lastAssistant = [response].find(
312
+ (m): m is AssistantMessage => m.role === "assistant"
313
+ );
314
+ const markdown =
315
+ lastAssistant?.content
316
+ .filter(
317
+ (part): part is { type: "text"; text: string } => part.type === "text"
318
+ )
319
+ .map((part) => part.text)
320
+ .join("\n") || responseText;
321
+ if (!markdown.trim()) {
322
+ throw new Error("Advisor returned no advice.");
323
+ }
324
+ return {
325
+ markdown,
326
+ model: advisorRef,
327
+ thinkingText,
328
+ usage: (
329
+ lastAssistant as (AssistantMessage & { usage?: unknown }) | undefined
330
+ )?.usage,
331
+ };
332
+ };
333
+
334
+ export const consultAdvisor = async (
335
+ ctx: ExtensionContext,
336
+ question?: string,
337
+ signal?: AbortSignal,
338
+ onChunk?: (thinking: string, text: string) => void,
339
+ trigger: ConsultationTrigger = "executor-requested"
340
+ ): Promise<AdvisorConsultationResult> => {
341
+ const result = await collectAdvisorResponse(
342
+ ctx,
343
+ ADVISOR_SYSTEM,
344
+ question,
345
+ signal,
346
+ onChunk
347
+ );
348
+ return { ...result, trigger };
349
+ };
350
+
351
+ export const runAdvisorGate = async (
352
+ ctx: ExtensionContext,
353
+ question: string,
354
+ trigger: GateTrigger = "repeated-tool-call",
355
+ signal?: AbortSignal,
356
+ onChunk?: (thinking: string, text: string) => void
357
+ ): Promise<AdvisorGateOutcome> => {
358
+ try {
359
+ const result = await collectAdvisorResponse(
360
+ ctx,
361
+ ADVISOR_DECISION_SYSTEM,
362
+ question,
363
+ signal,
364
+ onChunk
365
+ );
366
+ const parsed = parseAutomaticDecision(result.markdown);
367
+ if (!parsed.ok) {
368
+ return parsed;
369
+ }
370
+ return {
371
+ ...parsed,
372
+ model: result.model,
373
+ thinkingText: result.thinkingText,
374
+ trigger,
375
+ usage: result.usage,
376
+ };
377
+ } catch (error) {
378
+ const message = error instanceof Error ? error.message : String(error);
379
+ return {
380
+ category:
381
+ message === "Advisor returned no advice."
382
+ ? "empty-response"
383
+ : "provider-error",
384
+ message,
385
+ ok: false,
386
+ };
387
+ }
388
+ };
389
+
390
+ const notifyLocalFailure = (
391
+ ctx: ExtensionContext,
392
+ message: string,
393
+ sessionBlocked = false
394
+ ) => {
395
+ if (ctx.hasUI) {
396
+ ctx.ui.notify(
397
+ `Advisor ${sessionBlocked ? "gate failure; session blocked" : "consultation failed"}: ${message}`,
398
+ "error"
399
+ );
400
+ }
401
+ };
402
+
403
+ export const gateFailureEffectForMode = (
404
+ mode: "block-session" | "block-tool" | "warn-and-continue"
405
+ ) => {
406
+ if (mode === "warn-and-continue") {
407
+ return "continued" as const;
408
+ }
409
+ return mode === "block-tool"
410
+ ? ("tool-blocked" as const)
411
+ : ("session-blocked" as const);
412
+ };
413
+
414
+ const gateDecisionEffect = (decision: GateDecision) => {
415
+ if (decision === "proceed") {
416
+ return "continued" as const;
417
+ }
418
+ return decision === "blocked"
419
+ ? ("session-blocked" as const)
420
+ : ("tool-blocked" as const);
421
+ };
422
+
423
+ const failureEffect = (
424
+ category: GateFailureCategory,
425
+ message: string,
426
+ ctx: ExtensionContext,
427
+ session: AdvisorSessionState
428
+ ) => {
429
+ const reason = `Advisor gate ${category}: ${message}`;
430
+ notifyLocalFailure(ctx, message, advisorFailureModeRef === "block-session");
431
+ notifyHerdrAdvisorFailure("Advisor gate failure", reason);
432
+ if (advisorFailureModeRef === "warn-and-continue") {
433
+ return { block: false, effect: "continued" as const, reason };
434
+ }
435
+ if (advisorFailureModeRef === "block-tool") {
436
+ return { block: true, effect: "tool-blocked" as const, reason };
437
+ }
438
+ session.block(reason);
439
+ herdrAdvisorBlock.set(reason);
440
+ if (advisorBlockOnBlockedRef) {
441
+ ctx.abort();
442
+ }
443
+ return { block: true, effect: "session-blocked" as const, reason };
444
+ };
445
+
446
+ const reserveAdvisorCall = (
447
+ event: ToolCallEvent,
448
+ ctx: ExtensionContext,
449
+ session: AdvisorSessionState,
450
+ reservedCalls: Set<string>
451
+ ): ToolCallEventResult | undefined => {
452
+ if (event.toolName !== "ask_advisor") {
453
+ return;
454
+ }
455
+ if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
456
+ const message = "Advisor call budget exhausted for this session.";
457
+ if (ctx.hasUI) {
458
+ ctx.ui.notify(message, "warning");
459
+ }
460
+ notifyHerdrAdvisorFailure("Advisor budget exhausted", message);
461
+ return { block: true, reason: message };
462
+ }
463
+ session.consumeCall();
464
+ reservedCalls.add(event.toolCallId);
465
+ return {};
466
+ };
467
+
468
+ const sendAutomaticGateCall = (pi: ExtensionAPI, event: ToolCallEvent) => {
469
+ pi.sendMessage(
470
+ {
471
+ content: "Automatic Advisor loop review",
472
+ customType: "advisor-loop-call",
473
+ details: {
474
+ question: `Loop gate: ${event.toolName} repeated ${advisorLoopThresholdRef} times`,
475
+ },
476
+ display: true,
477
+ },
478
+ { deliverAs: "steer" }
479
+ );
480
+ };
481
+
482
+ const sendAutomaticGateFailure = (pi: ExtensionAPI, markdown: string) => {
483
+ pi.sendMessage(
484
+ {
485
+ content: markdown,
486
+ customType: "advisor-loop-result",
487
+ details: { text: markdown },
488
+ display: true,
489
+ },
490
+ { deliverAs: "steer" }
491
+ );
492
+ };
493
+
494
+ const sendAutomaticGateResult = (
495
+ pi: ExtensionAPI,
496
+ result: AdvisorGateResult
497
+ ) => {
498
+ pi.sendMessage(
499
+ {
500
+ content: adviceForText(result),
501
+ customType: "advisor-loop-result",
502
+ details: {
503
+ advisor: result.model,
504
+ decision: result.decision,
505
+ text: result.markdown,
506
+ },
507
+ display: true,
508
+ },
509
+ { deliverAs: "steer" }
510
+ );
511
+ };
512
+
513
+ const handleAutomaticGate = async (
514
+ pi: ExtensionAPI,
515
+ event: ToolCallEvent,
516
+ ctx: ExtensionContext,
517
+ session: AdvisorSessionState
518
+ ): Promise<ToolCallEventResult | undefined> => {
519
+ if (
520
+ event.toolName === "ask_advisor" ||
521
+ !advisorAutoLoopGateRef ||
522
+ !session.recordToolCall(
523
+ event.toolName,
524
+ event.input,
525
+ advisorLoopThresholdRef
526
+ )
527
+ ) {
528
+ return;
529
+ }
530
+ const reason = `Advisor loop gate: normalized signature for ${event.toolName} repeated ${advisorLoopThresholdRef} times without a materially different tool action.`;
531
+ if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
532
+ const failure = failureEffect(
533
+ "budget-exhausted",
534
+ "Advisor gate call budget is exhausted.",
535
+ ctx,
536
+ session
537
+ );
538
+ return failure.block ? { block: true, reason: failure.reason } : undefined;
539
+ }
540
+ session.consumeCall();
541
+ herdrAdvisorActivity.start();
542
+ sendAutomaticGateCall(pi, event);
543
+ try {
544
+ const result = await runAdvisorGate(
545
+ ctx,
546
+ `${reason} Review the repeated actions and recommend the smallest safe next step.`
547
+ );
548
+ if (!result.ok) {
549
+ session.recordInvocation({
550
+ executionEffect: gateFailureEffectForMode(advisorFailureModeRef),
551
+ failure: result.category,
552
+ kind: "gate",
553
+ model: advisorRef,
554
+ trigger: "repeated-tool-call",
555
+ });
556
+ const failure = failureEffect(
557
+ result.category,
558
+ result.message,
559
+ ctx,
560
+ session
561
+ );
562
+ sendAutomaticGateFailure(
563
+ pi,
564
+ `**Advisor gate failure (${result.category}):** ${result.message}`
565
+ );
566
+ return failure.block
567
+ ? { block: true, reason: `${reason}\n${failure.reason}` }
568
+ : undefined;
569
+ }
570
+ session.recordInvocation({
571
+ cost: advisorUsageCost(result.usage),
572
+ decision: result.decision,
573
+ executionEffect: gateDecisionEffect(result.decision),
574
+ kind: "gate",
575
+ model: result.model,
576
+ trigger: result.trigger,
577
+ usage: result.usage,
578
+ });
579
+ sendAutomaticGateResult(pi, result);
580
+ if (result.decision === "proceed") {
581
+ session.resetRepetition();
582
+ return;
583
+ }
584
+ const gateReason = `Advisor loop review: ${result.markdown}`;
585
+ if (result.decision === "blocked") {
586
+ session.block(gateReason);
587
+ herdrAdvisorBlock.set(gateReason);
588
+ if (advisorBlockOnBlockedRef) {
589
+ ctx.abort();
590
+ }
591
+ }
592
+ return { block: true, reason: gateReason };
593
+ } finally {
594
+ herdrAdvisorActivity.finish();
595
+ }
596
+ };
597
+
598
+ interface AdvisorToolDetails {
599
+ advisor?: string;
600
+ question?: string;
601
+ text?: string;
602
+ thinking?: string;
603
+ }
604
+ interface AdvisorRenderState {
605
+ timerId?: ReturnType<typeof setInterval>;
606
+ }
607
+ interface AdvisorToolContext {
608
+ invalidate: () => void;
609
+ lastComponent: unknown;
610
+ state: AdvisorRenderState;
611
+ }
612
+
613
+ const advisorResultDetails = (result: AgentToolResult<AdvisorToolDetails>) =>
614
+ result.details;
615
+
616
+ const renderPartialAdvisorResult = (
617
+ box: Box,
618
+ result: AgentToolResult<AdvisorToolDetails>,
619
+ expanded: boolean,
620
+ theme: Theme,
621
+ context: AdvisorToolContext
622
+ ) => {
623
+ if (!context.state.timerId) {
624
+ context.state.timerId = setInterval(() => context.invalidate(), 80);
625
+ }
626
+ const frame =
627
+ SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
628
+ const details = advisorResultDetails(result);
629
+ const lines = [
630
+ `${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`,
631
+ ];
632
+ if (details?.thinking) {
633
+ const thought =
634
+ details.thinking.length > 200
635
+ ? details.thinking.slice(-200)
636
+ : details.thinking;
637
+ lines.push(theme.fg("thinkingText", ` 💭 ${thought.replace(/\n/g, " ")}`));
638
+ }
639
+ box.addChild(new Text(lines.join("\n"), 0, 0));
640
+ if (details?.text) {
641
+ box.addChild(
642
+ new Markdown(
643
+ adviceForDisplay(details.text, expanded),
644
+ 0,
645
+ 0,
646
+ getMarkdownTheme()
647
+ )
648
+ );
649
+ }
650
+ };
651
+
652
+ const renderFinalAdvisorResult = (
653
+ box: Box,
654
+ result: AgentToolResult<AdvisorToolDetails>,
655
+ expanded: boolean,
656
+ theme: Theme,
657
+ context: AdvisorToolContext
658
+ ) => {
659
+ if (context.state.timerId) {
660
+ clearInterval(context.state.timerId);
661
+ context.state.timerId = undefined;
662
+ }
663
+ const details = advisorResultDetails(result);
664
+ const lines = [theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE"))];
665
+ if (details?.advisor) {
666
+ lines.push(theme.fg("dim", ` ${details.advisor}`));
667
+ }
668
+ if (details?.thinking) {
669
+ const thought = details.thinking.replace(/\n/g, " ").slice(0, 300);
670
+ lines.push(
671
+ theme.fg(
672
+ "thinkingText",
673
+ ` 💭 ${thought}${details.thinking.length > 300 ? "…" : ""}`
674
+ )
675
+ );
676
+ }
677
+ const advice =
678
+ details?.text ||
679
+ textFrom(result.content) ||
680
+ "(Advisor returned no advice.)";
681
+ box.addChild(new Text(lines.join("\n"), 0, 0));
682
+ box.addChild(
683
+ new Markdown(adviceForDisplay(advice, expanded), 0, 0, getMarkdownTheme())
684
+ );
685
+ };
686
+
687
+ const renderAdvisorResult = (
688
+ result: AgentToolResult<AdvisorToolDetails>,
689
+ { isPartial, expanded }: ToolRenderResultOptions,
690
+ theme: Theme,
691
+ context: AdvisorToolContext
692
+ ) => {
693
+ const box =
694
+ context.lastComponent instanceof Box
695
+ ? context.lastComponent
696
+ : new Box(1, 1, (text: string) => theme.bg("customMessageBg", text));
697
+ box.setBgFn((text) => theme.bg("customMessageBg", text));
698
+ box.clear();
699
+ if (isPartial) {
700
+ renderPartialAdvisorResult(box, result, expanded, theme, context);
701
+ } else {
702
+ renderFinalAdvisorResult(box, result, expanded, theme, context);
703
+ }
704
+ return box;
144
705
  };
145
706
 
146
707
  export const registerAdvisorTool = (pi: ExtensionAPI) => {
147
708
  const session = advisorSessionState;
709
+ const reservedCalls = new Set<string>();
148
710
 
149
- pi.registerMessageRenderer?.("advisor-loop-call", (message, _options, theme) => {
150
- const details = message.details as { question?: string } | undefined;
151
- return renderAdvisorCallBox(details?.question, theme);
152
- });
711
+ pi.registerMessageRenderer?.(
712
+ "advisor-loop-call",
713
+ (message, _options, theme) => {
714
+ const details = message.details as { question?: string } | undefined;
715
+ return renderAdvisorCallBox(details?.question, theme);
716
+ }
717
+ );
718
+
719
+ pi.registerMessageRenderer?.(
720
+ "advisor-loop-result",
721
+ (message, { expanded }, theme) => {
722
+ const details = message.details as
723
+ | { decision?: GateDecision; text?: string; advisor?: string }
724
+ | undefined;
725
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
726
+ box.addChild(
727
+ new Text(
728
+ theme.fg(
729
+ "warning",
730
+ theme.bold(`◆ ADVISOR GATE: ${details?.decision ?? "failure"}`)
731
+ ),
732
+ 0,
733
+ 0
734
+ )
735
+ );
736
+ if (details?.advisor) {
737
+ box.addChild(new Text(theme.fg("dim", ` ${details.advisor}`), 0, 0));
738
+ }
739
+ if (details?.text) {
740
+ box.addChild(
741
+ new Markdown(
742
+ adviceForDisplay(details.text, Boolean(expanded)),
743
+ 0,
744
+ 0,
745
+ getMarkdownTheme()
746
+ )
747
+ );
748
+ } else {
749
+ box.addChild(
750
+ new Text(
751
+ theme.fg(
752
+ "error",
753
+ typeof message.content === "string"
754
+ ? message.content
755
+ : "Advisor gate failed."
756
+ ),
757
+ 0,
758
+ 0
759
+ )
760
+ );
761
+ }
762
+ return box;
763
+ }
764
+ );
153
765
 
154
766
  pi.on("session_start", () => {
155
767
  session.resetTask();
768
+ reservedCalls.clear();
156
769
  herdrAdvisorBlock.clear();
157
770
  });
158
771
 
159
772
  pi.on("before_agent_start", (_event, ctx) => {
160
- if (!pi.getActiveTools().includes("ask_advisor")) return;
773
+ if (!pi.getActiveTools().includes("ask_advisor")) {
774
+ return;
775
+ }
161
776
  loadConfig(ctx);
162
777
  const guidelines = advisorInvocationGuidelines();
163
- const budget = session.remainingAutomaticCalls(advisorMaxCallsPerSessionRef);
164
- if (budget !== undefined) guidelines.push(`Advisor calls remaining this session: ${budget}.\nReserve calls for material decisions, repeated failures, or final review.`);
165
- return guidelines.length > 0 ? { systemPrompt: `${ctx.getSystemPrompt()}\n\nAdvisor invocation settings:\n${guidelines.map((rule) => `- ${rule}`).join("\n")}` } : undefined;
778
+ const budget = session.remainingCalls(advisorMaxCallsPerSessionRef);
779
+ if (budget !== undefined) {
780
+ guidelines.push(
781
+ `Advisor calls remaining this session: ${budget}.\nReserve calls for material decisions, repeated failures, or final review.`
782
+ );
783
+ }
784
+ return guidelines.length > 0
785
+ ? {
786
+ systemPrompt: `${ctx.getSystemPrompt()}\n\nAdvisor invocation settings:\n${guidelines.map((rule) => `- ${rule}`).join("\n")}`,
787
+ }
788
+ : undefined;
166
789
  });
167
790
 
168
- pi.on("tool_call", async (event, ctx) => {
169
- if (!pi.getActiveTools().includes("ask_advisor")) return;
791
+ pi.on("tool_call", (event, ctx) => {
792
+ if (!pi.getActiveTools().includes("ask_advisor")) {
793
+ return;
794
+ }
170
795
  loadConfig(ctx);
171
- if (event.toolName === "ask_advisor" && !session.canUseAutomaticCall(advisorMaxCallsPerSessionRef)) {
172
- return { block: true, reason: "Advisor call budget exhausted for this session." };
796
+ if (session.blocked) {
797
+ return {
798
+ block: true,
799
+ reason: session.blockedReason ?? "Advisor session is blocked.",
800
+ };
173
801
  }
174
- if (!advisorAutoLoopGateRef) return;
175
- if (!session.recordToolCall(event.toolName, event.input, advisorLoopThresholdRef)) return;
176
- let reason = `Advisor loop gate: ${event.toolName} repeated ${advisorLoopThresholdRef} times without a different tool action.`;
177
- let blockSession = false;
178
- if (session.canUseAutomaticCall(advisorMaxCallsPerSessionRef)) {
179
- session.consumeAutomaticCall();
180
- herdrAdvisorActivity.start();
181
- pi.sendMessage({
182
- customType: "advisor-loop-call",
183
- content: "Automatic Advisor loop review",
184
- display: true,
185
- details: { question: `Loop gate: ${event.toolName} repeated ${advisorLoopThresholdRef} times` },
186
- }, { deliverAs: "steer" });
187
- try {
188
- const { advice, structured } = await consult(ctx, `${reason} Review the repeated actions and recommend the smallest safe next step.`, undefined, undefined, true);
189
- session.recordConsultation("automatic", structured.verdict);
190
- pi.sendMessage({
191
- customType: "advisor-manual-result",
192
- content: `Automatic loop-gate Advisor review:\n\n${advice}`,
193
- display: true,
194
- details: { advisor: advisorRef, text: advice },
195
- }, { deliverAs: "steer" });
196
- if (structured.verdict === "proceed") {
197
- session.resetRepetition();
198
- return;
199
- }
200
- blockSession = structured.verdict === "blocked";
201
- if (blockSession) {
202
- const escalation = structured.escalationReason || structured.smallestNextStep;
203
- session.block(escalation);
204
- herdrAdvisorBlock.set(escalation);
205
- } else {
206
- reason = "Advisor loop review delivered. Follow its guidance before retrying this command.";
207
- }
208
- } catch (error) {
209
- session.recordConsultation("automatic");
210
- const message = error instanceof Error ? error.message : String(error);
211
- reason = `${reason}\nAdvisor loop review failed: ${message}`;
212
- session.block(reason);
213
- herdrAdvisorBlock.set(reason);
214
- blockSession = true;
215
- if (ctx.hasUI) ctx.ui.notify(`Advisor loop review failed. Session is blocked: ${message}`, "error");
216
- } finally {
217
- herdrAdvisorActivity.finish();
218
- }
219
- } else {
220
- reason = `${reason}\nAdvisor loop review was not run because the session call budget is exhausted.`;
221
- session.block(reason);
222
- herdrAdvisorBlock.set(reason);
223
- blockSession = true;
224
- if (ctx.hasUI) ctx.ui.notify("Advisor loop review was not run because the session call budget is exhausted. Session is blocked.", "error");
802
+ const reservation = reserveAdvisorCall(event, ctx, session, reservedCalls);
803
+ if (event.toolName === "ask_advisor") {
804
+ return reservation;
225
805
  }
226
- if (blockSession && advisorBlockOnBlockedRef) ctx.abort();
227
- return { block: true, reason };
806
+ return handleAutomaticGate(pi, event, ctx, session);
228
807
  });
229
808
 
230
809
  pi.on("agent_settled", (_event, ctx) => {
231
- if (session.blocked || !advisorSessionSummaryRef) return;
810
+ if (session.blocked || !advisorSessionSummaryRef) {
811
+ return;
812
+ }
232
813
  const summary = session.summary(advisorMaxCallsPerSessionRef);
233
- if (summary && ctx.hasUI) ctx.ui.notify(summary, "info");
814
+ if (summary && ctx.hasUI) {
815
+ ctx.ui.notify(summary, "info");
816
+ }
234
817
  });
235
818
 
236
- pi.on("session_shutdown", () => herdrAdvisorBlock.clear());
819
+ pi.on("session_shutdown", () => {
820
+ reservedCalls.clear();
821
+ herdrAdvisorBlock.clear();
822
+ });
237
823
 
238
824
  pi.registerTool({
239
- name: "ask_advisor",
240
- label: "Ask Advisor",
241
- description: "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a context-aware review; add question only for a genuinely targeted focus.",
242
- promptSnippet: "Consult the Advisor using its existing context; omit question unless a specific focus is necessary",
243
- promptGuidelines: [
244
- "Call ask_advisor with an empty object by default. Do not invent a question merely to request a review: the Advisor already receives the context. Include question only for a genuinely specific assumption or trade-off.",
245
- ],
246
- parameters: Type.Object({ question: Type.Optional(Type.String({ description: "The specific question or decision to get advice on. Omit this for normal reviews: the Advisor already has the conversation context." })) }),
247
- renderShell: "self",
248
- renderCall(args, theme, _context) {
249
- return renderAdvisorCallBox(args.question?.trim(), theme);
250
- },
251
- renderResult(result, { isPartial, expanded }, theme, context) {
252
- const box = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1, (text) => theme.bg("customMessageBg", text));
253
- box.setBgFn((text) => theme.bg("customMessageBg", text));
254
- box.clear();
255
- if (isPartial) {
256
- if (!context.state.timerId) {
257
- context.state.timerId = setInterval(() => context.invalidate(), 80);
258
- }
259
- const frame = SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
260
- const d = result.details as { thinking?: string; text?: string } | undefined;
261
- const lines: string[] = [`${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`];
262
- if (d?.thinking) {
263
- const snippet = d.thinking.length > 200 ? d.thinking.slice(-200) : d.thinking;
264
- lines.push(theme.fg("thinkingText", ` 💭 ${snippet.replace(/\n/g, " ")}`));
265
- }
266
- box.addChild(new Text(lines.join("\n"), 0, 0));
267
- if (d?.text) box.addChild(new Markdown(adviceForDisplay(d.text, Boolean(expanded)), 0, 0, getMarkdownTheme()));
268
- } else {
269
- if (context.state.timerId) {
270
- clearInterval(context.state.timerId);
271
- delete context.state.timerId;
272
- }
273
- const d = result.details as { thinking?: string; text?: string; advisor?: string } | undefined;
274
- const lines: string[] = [theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE"))];
275
- if (d?.advisor) lines.push(theme.fg("dim", ` ${d.advisor}`));
276
- if (d?.thinking) {
277
- lines.push(theme.fg("thinkingText", ` 💭 ${d.thinking.replace(/\n/g, " ").slice(0, 300)}${d.thinking.length > 300 ? "…" : ""}`));
825
+ description:
826
+ "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a context-aware review; add question only for a genuinely targeted focus.",
827
+ async execute(_id, params, signal, onUpdate, ctx) {
828
+ if (!reservedCalls.delete(_id)) {
829
+ if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
830
+ throw new Error("Advisor call budget exhausted for this session.");
278
831
  }
279
- const advice = d?.text || textFrom(result.content) || "(Advisor returned no advice.)";
280
- box.addChild(new Text(lines.join("\n"), 0, 0));
281
- box.addChild(new Markdown(adviceForDisplay(advice, Boolean(expanded)), 0, 0, getMarkdownTheme()));
832
+ session.consumeCall();
282
833
  }
283
- return box;
284
- },
285
- async execute(_id, params, signal, onUpdate, ctx) {
286
- const question = resolveAdvisorRequest(params.question);
287
834
  herdrAdvisorActivity.start();
288
835
  try {
289
- session.consumeAutomaticCall();
290
- const { advice, thinkingText, structured } = await consult(ctx, question, signal, (t, tx) => {
291
- onUpdate?.({
292
- content: [{ type: "text", text: tx }],
293
- details: { thinking: t, text: tx, advisor: advisorRef, question },
294
- });
836
+ const result = await consultAdvisor(
837
+ ctx,
838
+ resolveAdvisorRequest(params.question),
839
+ signal,
840
+ (t, tx) =>
841
+ onUpdate?.({
842
+ content: [{ text: tx, type: "text" }],
843
+ details: {
844
+ advisor: advisorRef,
845
+ question: resolveAdvisorRequest(params.question),
846
+ text: tx,
847
+ thinking: t,
848
+ },
849
+ })
850
+ );
851
+ session.recordInvocation({
852
+ cost: advisorUsageCost(result.usage),
853
+ executionEffect: "continued",
854
+ kind: "markdown",
855
+ model: result.model,
856
+ trigger: "executor-requested",
857
+ usage: result.usage,
295
858
  });
296
- session.recordConsultation("executor", structured.verdict);
297
- if (structured.verdict === "blocked") {
298
- const reason = structured.escalationReason || structured.smallestNextStep;
299
- session.block(reason);
300
- herdrAdvisorBlock.set(reason);
301
- if (advisorBlockOnBlockedRef) ctx.abort();
302
- }
303
859
  return {
304
- content: [{ type: "text", text: `Advisor (${advisorRef})\n\n${advice}` }],
305
- details: { thinking: thinkingText, text: advice, advisor: advisorRef, question, verdict: structured.verdict },
860
+ content: [
861
+ {
862
+ text: `Advisor (${result.model})\n\n${result.markdown}`,
863
+ type: "text",
864
+ },
865
+ ],
866
+ details: {
867
+ advisor: result.model,
868
+ question: resolveAdvisorRequest(params.question),
869
+ text: result.markdown,
870
+ thinking: result.thinkingText,
871
+ },
306
872
  };
873
+ } catch (error) {
874
+ const message = error instanceof Error ? error.message : String(error);
875
+ session.recordInvocation({
876
+ executionEffect: "continued",
877
+ failure: "provider-error",
878
+ kind: "markdown",
879
+ model: advisorRef,
880
+ trigger: "executor-requested",
881
+ });
882
+ notifyLocalFailure(ctx, message);
883
+ notifyHerdrAdvisorFailure("Advisor consultation failed", message);
884
+ throw error;
307
885
  } finally {
308
886
  herdrAdvisorActivity.finish();
309
887
  }
310
888
  },
889
+ label: "Ask Advisor",
890
+ name: "ask_advisor",
891
+ parameters: Type.Object({
892
+ question: Type.Optional(
893
+ Type.String({
894
+ description:
895
+ "The specific question or decision to get advice on. Omit this for normal reviews: the Advisor already has the conversation context.",
896
+ })
897
+ ),
898
+ }),
899
+ promptGuidelines: [
900
+ "Call ask_advisor with an empty object by default. Do not invent a question merely to request a review: the Advisor already receives context. Include question only for a genuinely specific assumption or trade-off.",
901
+ ],
902
+ promptSnippet:
903
+ "Consult the Advisor using its existing context; omit question unless a specific focus is necessary",
904
+ renderCall(args, theme) {
905
+ return renderAdvisorCallBox(args.question?.trim(), theme);
906
+ },
907
+ renderResult(result, options, theme, context) {
908
+ return renderAdvisorResult(
909
+ result as AgentToolResult<AdvisorToolDetails>,
910
+ options,
911
+ theme,
912
+ context as AdvisorToolContext
913
+ );
914
+ },
915
+ renderShell: "self",
311
916
  });
312
917
  };