pi-advisor-flow 0.9.2-dev.5 → 0.10.1-dev.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.
@@ -3,6 +3,7 @@ import type {
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
+ import type { Static } from "typebox";
6
7
 
7
8
  import {
8
9
  advisorRef,
@@ -11,6 +12,7 @@ import {
11
12
  isSimpleMode,
12
13
  } from "../config/state.ts";
13
14
  import { loadConfig } from "../config/storage.ts";
15
+ import type { AdvisorFollowUpPayload } from "../follow-up.ts";
14
16
  import { notifyHerdrAdvisorFailure } from "../herdr.ts";
15
17
  import {
16
18
  ADVISOR_STREAM_UPDATE_INTERVAL_MS,
@@ -21,10 +23,13 @@ import {
21
23
  advisorUsageForPi,
22
24
  snapshotAdvisorUsage,
23
25
  } from "../usage.ts";
26
+ import { advisorOutputSchema } from "./advisor-output.ts";
27
+ import { assertAdvisorFollowUpPrivacy } from "./consultation.ts";
24
28
  import { notifyLocalFailure, updateAdvisorUsageStatus } from "./gate-policy.ts";
25
29
  import { normalizeScreeningQuestion, screeningSkipText } from "./jev-filter.ts";
26
30
  import {
27
31
  advisorModelAccessReason,
32
+ fallbackSameModelAdvisorNotice,
28
33
  sameModelAdvisorDisabled,
29
34
  sameModelAdvisorNotice,
30
35
  } from "./model-access.ts";
@@ -47,6 +52,66 @@ const assertAdvisorModelAccess = (ctx: ExtensionContext) => {
47
52
  }
48
53
  };
49
54
 
55
+ interface AskAdvisorParams {
56
+ draft?: string;
57
+ followUpTo?: string;
58
+ force?: boolean;
59
+ gitContext?: "none" | "summary" | "full";
60
+ includeTrackedFiles?: string[];
61
+ includeUntracked?: string[];
62
+ question?: string;
63
+ }
64
+
65
+ const resolveFollowUp = (
66
+ params: AskAdvisorParams,
67
+ session: ToolRegistrationContext["session"]
68
+ ): { adviceId: string; payload: AdvisorFollowUpPayload } | undefined => {
69
+ if (!params.followUpTo) {
70
+ return undefined;
71
+ }
72
+ if (
73
+ !params.question?.trim() ||
74
+ params.draft !== undefined ||
75
+ params.gitContext !== undefined ||
76
+ params.includeTrackedFiles?.length ||
77
+ params.includeUntracked?.length
78
+ ) {
79
+ throw new Error(
80
+ "Follow-up consultations accept only followUpTo and a non-empty question. Issue a fresh consultation for new context or attachments."
81
+ );
82
+ }
83
+ const cached = session.followUpFor(params.followUpTo);
84
+ if (!cached.payload) {
85
+ throw new Error(
86
+ `Follow-up unavailable for adviceId ${params.followUpTo}: ${cached.reason ?? "the cached payload is unavailable"}. Issue a fresh consultation.`
87
+ );
88
+ }
89
+ return { adviceId: params.followUpTo, payload: cached.payload };
90
+ };
91
+
92
+ const sameModelNoticeFor = (modelRef: string | undefined) =>
93
+ (modelRef ?? advisorRef) === advisorRef
94
+ ? sameModelAdvisorNotice
95
+ : fallbackSameModelAdvisorNotice;
96
+
97
+ const skippedSameModelResult = (
98
+ id: string,
99
+ notice: string,
100
+ reservedCalls: Set<string>,
101
+ session: ToolRegistrationContext["session"]
102
+ ) => {
103
+ session.releaseCall(id);
104
+ reservedCalls.delete(id);
105
+ return {
106
+ content: [{ text: notice, type: "text" as const }],
107
+ details: {
108
+ skipReason: notice,
109
+ text: notice,
110
+ },
111
+ structuredContent: { skipReason: notice, text: notice },
112
+ };
113
+ };
114
+
50
115
  const claimTrackedHandoff = (
51
116
  session: ToolRegistrationContext["session"],
52
117
  includeTrackedFiles: string[] | undefined
@@ -76,23 +141,25 @@ export const registerAskAdvisorTool = ({
76
141
  }: ToolRegistrationContext): void => {
77
142
  pi.registerTool({
78
143
  description:
79
- "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a contextual review; attach an optional draft for concrete plan or completion review. If the Advisor explicitly names a missing file, you may make a sequential follow-up call with includeTrackedFiles when enabled and relevant.",
144
+ "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a contextual review; attach an optional draft for concrete plan or completion review. Use followUpTo with a new question to continue one cached consultation without rebuilding its redacted context. If the Advisor explicitly names a missing file, you may make a sequential fresh consultation with includeTrackedFiles when enabled and relevant. In Pi Codemode, gather and filter deterministic tool results first, then call tools.ask_advisor. Nested results are not automatically reconstructed: pass only permitted, concise summaries in draft (untrusted, capped at 8 KiB, not independently verified). Draft does not inherit source-tool disclosure policies; use gitContext for patches within the user's allowance.",
80
145
  async execute(_id, params, signal, onUpdate, ctx) {
81
- const skipSameModel = () => {
82
- session.releaseCall(_id);
83
- reservedCalls.delete(_id);
84
- return {
85
- content: [{ text: sameModelAdvisorNotice, type: "text" as const }],
86
- details: {
87
- skipReason: sameModelAdvisorNotice,
88
- text: sameModelAdvisorNotice,
89
- },
90
- };
91
- };
146
+ let followUp:
147
+ | { adviceId: string; payload: AdvisorFollowUpPayload }
148
+ | undefined;
92
149
  try {
93
150
  loadConfig(ctx);
94
- if (sameModelAdvisorDisabled(ctx)) {
95
- return skipSameModel();
151
+ followUp = resolveFollowUp(params, session);
152
+ if (followUp) {
153
+ assertAdvisorFollowUpPrivacy(followUp.payload, ctx);
154
+ }
155
+ const modelRef = followUp?.payload.model;
156
+ if (sameModelAdvisorDisabled(ctx, ctx.model, modelRef)) {
157
+ return skippedSameModelResult(
158
+ _id,
159
+ sameModelNoticeFor(modelRef),
160
+ reservedCalls,
161
+ session
162
+ );
96
163
  }
97
164
  assertAdvisorModelAccess(ctx);
98
165
  } catch (error) {
@@ -136,20 +203,27 @@ export const registerAskAdvisorTool = ({
136
203
  const skipText = screeningSkipText(screening);
137
204
  session.releaseCall(_id);
138
205
  reservedCalls.delete(_id);
206
+ const skipped = {
207
+ jev: {
208
+ kind: screening.kind,
209
+ reason: screening.reason,
210
+ skipped: true,
211
+ },
212
+ text: skipText,
213
+ };
139
214
  return {
140
215
  content: [{ text: skipText, type: "text" }],
141
- details: {
142
- jev: {
143
- kind: screening.kind,
144
- reason: screening.reason,
145
- skipped: true,
146
- },
147
- text: skipText,
148
- },
216
+ details: skipped,
217
+ structuredContent: skipped,
149
218
  };
150
219
  }
151
- if (sameModelAdvisorDisabled(ctx)) {
152
- return skipSameModel();
220
+ if (sameModelAdvisorDisabled(ctx, ctx.model, followUp?.payload.model)) {
221
+ return skippedSameModelResult(
222
+ _id,
223
+ sameModelNoticeFor(followUp?.payload.model),
224
+ reservedCalls,
225
+ session
226
+ );
153
227
  }
154
228
  if (
155
229
  !simpleMode &&
@@ -191,7 +265,8 @@ export const registerAskAdvisorTool = ({
191
265
  coalescedUpdate.update({
192
266
  content: [{ text: tx, type: "text" }],
193
267
  details: {
194
- advisor: advisorRef,
268
+ advisor: followUp?.payload.model ?? advisorRef,
269
+ followUp: Boolean(followUp),
195
270
  question: resolveAdvisorRequest(params.question),
196
271
  scout: scoutDetails,
197
272
  text: tx,
@@ -209,13 +284,17 @@ export const registerAskAdvisorTool = ({
209
284
  coalescedUpdate.update({
210
285
  content: [{ text: scoutDetails.text ?? "", type: "text" }],
211
286
  details: {
212
- advisor: advisorRef,
287
+ advisor: followUp?.payload.model ?? advisorRef,
288
+ followUp: Boolean(followUp),
213
289
  question: resolveAdvisorRequest(params.question),
214
290
  scout: scoutDetails,
215
291
  },
216
292
  });
217
293
  },
218
- _id
294
+ _id,
295
+ followUp,
296
+ (adviceId, payload, replacesAdviceId) =>
297
+ session.captureFollowUp(adviceId, payload, replacesAdviceId)
219
298
  );
220
299
  flushUpdate();
221
300
  session.issueAdvice(
@@ -228,6 +307,7 @@ export const registerAskAdvisorTool = ({
228
307
  session.recordInvocation({
229
308
  cost: advisorUsageCost(result.usage),
230
309
  executionEffect: "continued",
310
+ followUp: result.followUp,
231
311
  kind: "markdown",
232
312
  model: result.model,
233
313
  trigger: "executor-requested",
@@ -241,6 +321,7 @@ export const registerAskAdvisorTool = ({
241
321
  advisor: result.model,
242
322
  agentRulesBytes: result.agentRulesBytes,
243
323
  draftBytes: result.draftBytes,
324
+ followUp: result.followUp,
244
325
  imageBytes: result.imageBytes,
245
326
  imageCount: result.imageCount,
246
327
  imageOmissions: result.imageOmissions,
@@ -256,14 +337,24 @@ export const registerAskAdvisorTool = ({
256
337
  if (usage) {
257
338
  details.usage = usage;
258
339
  }
340
+ const structuredContent: Static<typeof advisorOutputSchema> = {
341
+ adviceId: result.adviceId,
342
+ advisor: result.model,
343
+ followUp: Boolean(result.followUp),
344
+ text: result.markdown,
345
+ };
346
+ if (usage) {
347
+ structuredContent.usage = usage;
348
+ }
259
349
  const response: AgentToolResult<AdvisorToolDetails> = {
260
350
  content: [
261
351
  {
262
- text: `Advisor (${result.model})\n\n${result.markdown}`,
352
+ text: `${result.followUp ? "Advisor follow-up" : "Advisor"} (${result.model})\n\n${result.markdown}`,
263
353
  type: "text",
264
354
  },
265
355
  ],
266
356
  details,
357
+ structuredContent,
267
358
  };
268
359
  if (piUsage) {
269
360
  response.usage = piUsage;
@@ -280,7 +371,7 @@ export const registerAskAdvisorTool = ({
280
371
  executionEffect: "continued",
281
372
  failure: "provider-error",
282
373
  kind: "markdown",
283
- model: advisorRef,
374
+ model: followUp?.payload.model ?? advisorRef,
284
375
  trigger: "executor-requested",
285
376
  });
286
377
  updateAdvisorUsageStatus(ctx, session);
@@ -296,6 +387,7 @@ export const registerAskAdvisorTool = ({
296
387
  },
297
388
  label: "Ask Advisor",
298
389
  name: "ask_advisor",
390
+ outputSchema: advisorOutputSchema,
299
391
  parameters: Type.Object({
300
392
  draft: Type.Optional(
301
393
  Type.String({
@@ -303,6 +395,12 @@ export const registerAskAdvisorTool = ({
303
395
  "Concise untrusted draft for plan or completion review; claims are not verification evidence.",
304
396
  })
305
397
  ),
398
+ followUpTo: Type.Optional(
399
+ Type.String({
400
+ description:
401
+ "Opaque adviceId from a prior successful consultation. Requires a new non-empty question and reuses that consultation's redacted payload; do not combine with draft, Git context, or file attachments.",
402
+ })
403
+ ),
306
404
  force: Type.Optional(
307
405
  Type.Boolean({
308
406
  description:
@@ -342,12 +440,16 @@ export const registerAskAdvisorTool = ({
342
440
  ),
343
441
  }),
344
442
  promptGuidelines: [
345
- "Call ask_advisor with an empty object for general consultation. For a plan or completion review, include a concise draft naming work, validation, and remaining risks; its claims are not evidence. If the Advisor explicitly says it cannot review a specifically named file, you may make a sequential follow-up call with includeTrackedFiles when the file is relevant, permitted, and worth the shared call budget; do not infer paths or retry automatically.",
443
+ "Call ask_advisor with an empty object for general consultation. For a plan or completion review, include a concise draft naming work, validation, and remaining risks; its claims are not evidence. To drill into an existing answer, pass its adviceId as followUpTo with a new question and no draft, Git context, or attachments. If the Advisor explicitly says it cannot review a specifically named file, you may make a sequential follow-up call with includeTrackedFiles when the file is relevant, permitted, and worth the shared call budget; do not infer paths or retry automatically.",
346
444
  ],
347
445
  promptSnippet:
348
446
  "Consult the Advisor using its existing context; attach a draft for plan or completion review",
349
447
  renderCall(args, theme) {
350
- return renderAdvisorCallBox(args.question?.trim(), theme);
448
+ return renderAdvisorCallBox(
449
+ args.question?.trim(),
450
+ theme,
451
+ Boolean(args.followUpTo)
452
+ );
351
453
  },
352
454
  renderResult(result, options, theme, context) {
353
455
  // SAFETY: this tool's execute() only returns AdvisorToolDetails-shaped details.
@@ -95,6 +95,16 @@ export const registerToolLifecycle = ({
95
95
  : undefined;
96
96
  });
97
97
 
98
+ pi.on("input", () => {
99
+ session.clearFollowUps();
100
+ });
101
+
102
+ pi.on("tool_result", (event) => {
103
+ if (event.toolName !== "ask_advisor") {
104
+ session.advanceFollowUpToolCall();
105
+ }
106
+ });
107
+
98
108
  pi.on("tool_call", (event, ctx) => {
99
109
  if (session.blocked) {
100
110
  return {
@@ -149,6 +159,7 @@ export const registerToolLifecycle = ({
149
159
 
150
160
  pi.on("session_shutdown", (_event, ctx) => {
151
161
  reservedCalls.clear();
162
+ session.clearFollowUps();
152
163
  session.clearCallReservations();
153
164
  scoutStatus.clear(ctx);
154
165
  herdrBlock.clear();
@@ -162,6 +162,9 @@ const finalResultLines = (
162
162
  theme: Theme
163
163
  ): string[] => {
164
164
  const lines = [renderAdvisorResponseHeader(hasSoundVerdict(advice), theme)];
165
+ if (details?.followUp) {
166
+ lines.push(theme.fg("dim", " Follow-up consultation"));
167
+ }
165
168
  if (details?.advisor) {
166
169
  lines.push(theme.fg("dim", ` ${details.advisor}`));
167
170
  }
@@ -85,11 +85,15 @@ export const resolveAdvisorRequest = (question?: string) =>
85
85
 
86
86
  export const renderAdvisorCallBox = (
87
87
  question: string | undefined,
88
- theme: Theme
88
+ theme: Theme,
89
+ followUp = false
89
90
  ) => {
90
91
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
91
92
  const label = theme.fg("customMessageLabel", theme.bold("[advisor]"));
92
- const title = theme.fg("customMessageText", "Executor → Advisor");
93
+ const title = theme.fg(
94
+ "customMessageText",
95
+ followUp ? "Executor → Advisor · Follow-up" : "Executor → Advisor"
96
+ );
93
97
  box.addChild(
94
98
  new Text(
95
99
  question
@@ -48,6 +48,7 @@ export interface AdvisorConsultationResult {
48
48
  adviceId: string;
49
49
  agentRulesBytes?: number;
50
50
  draftBytes?: number;
51
+ followUp?: boolean;
51
52
  imageBytes?: number;
52
53
  imageCount?: number;
53
54
  imageOmissions?: number;
@@ -89,6 +90,7 @@ export interface AdvisorToolDetails {
89
90
  adviceId?: string;
90
91
  advisor?: string;
91
92
  agentRulesBytes?: number;
93
+ followUp?: boolean;
92
94
  draftBytes?: number;
93
95
  imageBytes?: number;
94
96
  imageCount?: number;
@@ -17,6 +17,7 @@ import {
17
17
  import { isValidAdvisorToolPolicies } from "../config/validation.ts";
18
18
  import { JevSetupSubmenu } from "./jev-setup-submenu.ts";
19
19
  import { SearchableModelMultiSelector } from "./model-multi-selector.ts";
20
+ import { SearchableModelSelector } from "./model-selector.ts";
20
21
  import {
21
22
  contextDescription,
22
23
  currentContextLabel,
@@ -28,6 +29,7 @@ import {
28
29
  withCurrentValue,
29
30
  } from "./settings-formatting.ts";
30
31
  import { TextSettingSubmenu } from "./text-setting-submenu.ts";
32
+ import { FALLBACK_ADVISOR_MODEL_DISABLED } from "./types.ts";
31
33
  import type {
32
34
  AdvisorSettings,
33
35
  ContextPreset,
@@ -36,6 +38,7 @@ import type {
36
38
 
37
39
  export interface SettingsItemsOptions {
38
40
  effortLevels: string[];
41
+ fallbackModel: SettingItem;
39
42
  modelWhitelist: SettingItem;
40
43
  presets: ContextPreset[];
41
44
  settings: AdvisorSettings;
@@ -220,6 +223,37 @@ const jevItems = (
220
223
  },
221
224
  ];
222
225
 
226
+ export const advisorFallbackModelItem = (
227
+ settings: AdvisorSettings,
228
+ modelRefs: string[] | undefined,
229
+ keybindings: KeybindingsManager | undefined,
230
+ theme: Theme,
231
+ tui: RenderRequester
232
+ ): SettingItem => ({
233
+ currentValue: settings.fallbackModel ?? FALLBACK_ADVISOR_MODEL_DISABLED,
234
+ description:
235
+ "Retry a failed Advisor request once with this optional model; the primary model remains unchanged.",
236
+ id: "fallbackModel",
237
+ label: "Fallback Advisor model",
238
+ submenu: (_currentValue, done) =>
239
+ new SearchableModelSelector({
240
+ allOptions: [
241
+ FALLBACK_ADVISOR_MODEL_DISABLED,
242
+ ...new Set(
243
+ modelRefs ?? (settings.fallbackModel ? [settings.fallbackModel] : [])
244
+ ),
245
+ ],
246
+ currentOption: settings.fallbackModel ?? FALLBACK_ADVISOR_MODEL_DISABLED,
247
+ keybindings: keybindings ?? getKeybindings(),
248
+ onCancel: done,
249
+ onSelect: (value) =>
250
+ done(value === FALLBACK_ADVISOR_MODEL_DISABLED ? "" : value),
251
+ theme,
252
+ title: "Fallback Advisor model",
253
+ tui,
254
+ }),
255
+ });
256
+
223
257
  export const advisorModelWhitelistItem = (
224
258
  settings: AdvisorSettings,
225
259
  modelRefs: string[] | undefined,
@@ -252,6 +286,7 @@ export const advisorModelWhitelistItem = (
252
286
 
253
287
  export const createSettingsItems = ({
254
288
  effortLevels,
289
+ fallbackModel,
255
290
  modelWhitelist,
256
291
  presets,
257
292
  settings,
@@ -282,6 +317,7 @@ export const createSettingsItems = ({
282
317
  label: "Always on",
283
318
  values: TOGGLE_VALUES,
284
319
  },
320
+ fallbackModel,
285
321
  ];
286
322
  if (settings.simpleMode) {
287
323
  items.push(
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_JEV_MODEL } from "../config/types.ts";
2
+ import { FALLBACK_ADVISOR_MODEL_DISABLED } from "./types.ts";
2
3
  import type { AdvisorSettings, ContextPreset } from "./types.ts";
3
4
 
4
5
  const BOOLEAN_SETTING_FIELDS = [
@@ -55,6 +56,13 @@ const applyCoreMutation = (
55
56
  settings.alwaysOn = value === "On";
56
57
  return true;
57
58
  }
59
+ case "fallbackModel": {
60
+ settings.fallbackModel =
61
+ value === FALLBACK_ADVISOR_MODEL_DISABLED
62
+ ? undefined
63
+ : value.trim() || undefined;
64
+ return true;
65
+ }
58
66
  case "effort": {
59
67
  settings.effort = value;
60
68
  return true;
@@ -7,6 +7,7 @@ import {
7
7
  SIMPLE_MODE_GRADIENT_INTERVAL_MS,
8
8
  } from "./settings-formatting.ts";
9
9
  import {
10
+ advisorFallbackModelItem,
10
11
  advisorModelWhitelistItem,
11
12
  createSettingsItems,
12
13
  } from "./settings-items.ts";
@@ -96,6 +97,13 @@ export class AdvisorSettingsSelector implements Component, Focusable {
96
97
  }
97
98
  return defaultLabel(text, selected);
98
99
  };
100
+ const fallbackModel = advisorFallbackModelItem(
101
+ this.settings,
102
+ this.options.modelRefs,
103
+ this.options.keybindings,
104
+ this.options.theme,
105
+ this.options.tui
106
+ );
99
107
  const modelWhitelist = advisorModelWhitelistItem(
100
108
  this.settings,
101
109
  this.options.modelRefs,
@@ -105,6 +113,7 @@ export class AdvisorSettingsSelector implements Component, Focusable {
105
113
  );
106
114
  const items = createSettingsItems({
107
115
  effortLevels: this.options.effortLevels,
116
+ fallbackModel,
108
117
  modelWhitelist,
109
118
  presets: this.presets,
110
119
  settings: this.settings,
package/src/ui/types.ts CHANGED
@@ -66,8 +66,11 @@ export interface ContextPreset {
66
66
  value: number;
67
67
  }
68
68
 
69
+ export const FALLBACK_ADVISOR_MODEL_DISABLED = "Disabled (no fallback)";
70
+
69
71
  export interface AdvisorSettings {
70
72
  agentsMdContext?: boolean;
73
+ fallbackModel?: string;
71
74
  alwaysOn?: boolean;
72
75
  autoLoopGate?: boolean;
73
76
  blockOnBlocked?: boolean;