pi-advisor-flow 0.1.8 → 0.2.0

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