pi-advisor-flow 0.9.2-dev.5 → 0.10.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.
@@ -5,10 +5,13 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  import {
7
7
  advisorEffortRef,
8
+ advisorFallbackModelRef,
8
9
  advisorRedactSecretsRef,
9
10
  advisorRef,
11
+ getAdvisorSettings,
10
12
  } from "../config/state.ts";
11
13
  import { loadConfig } from "../config/storage.ts";
14
+ import type { AdvisorFollowUpPayload } from "../follow-up.ts";
12
15
  import { escapeRepositoryText } from "../git.ts";
13
16
  import type { GitContextLevel } from "../git.ts";
14
17
  import { collectTextStream, resolveConfiguredModel } from "../model-stream.ts";
@@ -20,6 +23,7 @@ import type { ConsultationContext } from "./consult-context.ts";
20
23
  import { parseAutomaticDecision } from "./gate-protocol.ts";
21
24
  import {
22
25
  advisorModelAccessReason,
26
+ fallbackSameModelAdvisorNotice,
23
27
  sameModelAdvisorDisabled,
24
28
  sameModelAdvisorNotice,
25
29
  } from "./model-access.ts";
@@ -41,6 +45,17 @@ class AdvisorNoAdviceError extends Error {
41
45
  }
42
46
  }
43
47
 
48
+ export interface AdvisorFollowUpRequest {
49
+ adviceId: string;
50
+ payload: AdvisorFollowUpPayload;
51
+ }
52
+
53
+ export type AdvisorFollowUpCapture = (
54
+ adviceId: string,
55
+ payload: AdvisorFollowUpPayload,
56
+ replacesAdviceId?: string
57
+ ) => void;
58
+
44
59
  interface CollectAdvisorResponseOptions {
45
60
  ctx: ExtensionContext;
46
61
  currentInvocationId?: string;
@@ -53,8 +68,22 @@ interface CollectAdvisorResponseOptions {
53
68
  question?: string;
54
69
  signal?: AbortSignal;
55
70
  systemPrompt: string;
71
+ followUp?: AdvisorFollowUpRequest;
56
72
  }
57
73
 
74
+ interface PreparedAdvisorRequest {
75
+ context?: ConsultationContext;
76
+ messages: Message[];
77
+ privacyKey: string;
78
+ redacted: boolean;
79
+ systemPrompt: string;
80
+ }
81
+
82
+ type CollectedAdvisorResponse = Omit<
83
+ AdvisorConsultationResult,
84
+ "adviceId" | "trigger"
85
+ > & { followUpPayload: AdvisorFollowUpPayload };
86
+
58
87
  const fileTag = (item: { path: string; text: string }) =>
59
88
  `<file path=${JSON.stringify(item.path)}>\n${item.text}\n</file>`;
60
89
 
@@ -86,26 +115,79 @@ const advisorImageNotice = (context: ConsultationContext) => {
86
115
  return `\n\nImage disclosure: ${disclosure}. Only the images explicitly attached below have pixels available. Other image markers, image paths, and text descriptions are not visual evidence.`;
87
116
  };
88
117
 
89
- const collectAdvisorResponse = async (
90
- options: CollectAdvisorResponseOptions
118
+ const advisorPrivacyKey = (ctx: ExtensionContext) => {
119
+ const settings = getAdvisorSettings();
120
+ return JSON.stringify({
121
+ agentsMdContext: settings.agentsMdContext,
122
+ contextMaxChars: settings.contextMaxChars,
123
+ cwd: ctx.cwd,
124
+ gitContext: settings.gitContext,
125
+ gitContextMaxChars: settings.gitContextMaxChars,
126
+ projectTrusted: ctx.isProjectTrusted(),
127
+ redactSecrets: settings.redactSecrets,
128
+ scoutEnabled: settings.scoutEnabled,
129
+ toolPolicies: settings.toolPolicies,
130
+ toolResultMaxBytes: settings.toolResultMaxBytes,
131
+ toolResultMaxLines: settings.toolResultMaxLines,
132
+ trackedFileContent: settings.trackedFileContent,
133
+ untrackedContent: settings.untrackedContent,
134
+ });
135
+ };
136
+
137
+ export const assertAdvisorFollowUpPrivacy = (
138
+ payload: AdvisorFollowUpPayload,
139
+ ctx: ExtensionContext
91
140
  ) => {
92
- const { ctx, question, signal, systemPrompt } = options;
93
- loadConfig(ctx);
94
- if (sameModelAdvisorDisabled(ctx)) {
95
- throw new Error(sameModelAdvisorNotice);
141
+ if (payload.privacyKey !== advisorPrivacyKey(ctx)) {
142
+ throw new Error(
143
+ "Follow-up consultation unavailable: privacy or disclosure settings changed after the original consultation. Issue a fresh consultation."
144
+ );
96
145
  }
97
- const accessReason = advisorModelAccessReason(ctx);
98
- if (accessReason) {
99
- throw new Error(accessReason);
146
+ };
147
+
148
+ const prepareFollowUp = (
149
+ followUp: AdvisorFollowUpRequest,
150
+ question: string | undefined,
151
+ ctx: ExtensionContext
152
+ ): PreparedAdvisorRequest => {
153
+ if (!question?.trim()) {
154
+ throw new Error(
155
+ "Follow-up consultations require a non-empty question. Issue a fresh consultation if no question is needed."
156
+ );
100
157
  }
101
- const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor");
158
+ assertAdvisorFollowUpPrivacy(followUp.payload, ctx);
159
+ const outboundQuestion =
160
+ advisorRedactSecretsRef || followUp.payload.redacted
161
+ ? redactSecrets(question)
162
+ : question;
163
+ const messages = structuredClone(followUp.payload.messages);
164
+ messages.push({
165
+ content: [
166
+ {
167
+ text: `Follow-up focus:\n${outboundQuestion}`,
168
+ type: "text",
169
+ },
170
+ ],
171
+ role: "user",
172
+ timestamp: Date.now(),
173
+ });
174
+ return {
175
+ messages,
176
+ privacyKey: followUp.payload.privacyKey,
177
+ redacted: advisorRedactSecretsRef || followUp.payload.redacted,
178
+ systemPrompt: followUp.payload.systemPrompt,
179
+ };
180
+ };
181
+
182
+ const prepareFreshRequest = async (
183
+ options: CollectAdvisorResponseOptions,
184
+ resolved: Awaited<ReturnType<typeof resolveConfiguredModel>>
185
+ ): Promise<PreparedAdvisorRequest> => {
186
+ const { question } = options;
102
187
  const context = await assembleConsultationContext({
103
188
  ...options,
104
189
  supportsImages: resolved.model.input.includes("image"),
105
190
  });
106
-
107
- // Keep the original question available to local UI callers, but never send
108
- // its credential-shaped values to the provider when redaction is enabled.
109
191
  const outboundQuestion =
110
192
  advisorRedactSecretsRef && question !== undefined
111
193
  ? redactSecrets(question)
@@ -139,45 +221,227 @@ const collectAdvisorResponse = async (
139
221
  timestamp: Date.now(),
140
222
  },
141
223
  ];
224
+ return {
225
+ context,
226
+ messages,
227
+ privacyKey: advisorPrivacyKey(options.ctx),
228
+ redacted: advisorRedactSecretsRef,
229
+ systemPrompt: options.systemPrompt,
230
+ };
231
+ };
232
+
233
+ const combinedFallbackError = (
234
+ primaryModel: string,
235
+ primaryError: Error,
236
+ fallbackModel: string,
237
+ fallbackError: Error
238
+ ) =>
239
+ new Error(
240
+ `Advisor model ${primaryModel} failed: ${primaryError.message}. Fallback Advisor model ${fallbackModel} failed: ${fallbackError.message}.`,
241
+ { cause: fallbackError }
242
+ );
243
+
244
+ const advisorContextDetails = (context: ConsultationContext | undefined) => ({
245
+ agentRulesBytes: context?.projectRules?.bytes || undefined,
246
+ draftBytes: context?.draftText
247
+ ? Buffer.byteLength(context.draftText, "utf-8")
248
+ : undefined,
249
+ imageBytes:
250
+ context?.images.reduce(
251
+ (sum, item) => sum + Buffer.from(item.image.data, "base64").length,
252
+ 0
253
+ ) || undefined,
254
+ imageCount: context?.images.length,
255
+ imageOmissions: context?.imageOmissions || undefined,
256
+ imagePartsSeen: context?.imagePartsSeen || undefined,
257
+ preferenceBytes: context?.preferences?.bytes,
258
+ scout: context?.scout,
259
+ trackedBytes:
260
+ context?.tracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
261
+ untrackedBytes:
262
+ context?.untracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
263
+ });
142
264
 
265
+ const runAdvisorAttempt = async (
266
+ options: CollectAdvisorResponseOptions,
267
+ prepared: PreparedAdvisorRequest,
268
+ resolved: Awaited<ReturnType<typeof resolveConfiguredModel>>,
269
+ modelRef: string
270
+ ): Promise<CollectedAdvisorResponse> => {
143
271
  const streamed = await collectTextStream(resolved, {
144
- messages,
272
+ messages: structuredClone(prepared.messages),
145
273
  onChunk: options.onChunk,
146
274
  reasoning: advisorEffortRef,
147
- signal,
148
- systemPrompt,
275
+ signal: options.signal,
276
+ systemPrompt: prepared.systemPrompt,
149
277
  });
150
- const markdown = streamed.text;
278
+ const { text: markdown } = streamed;
151
279
  if (!markdown.trim()) {
152
280
  throw new AdvisorNoAdviceError();
153
281
  }
154
- const response: Omit<AdvisorConsultationResult, "adviceId" | "trigger"> = {
155
- agentRulesBytes: context.projectRules?.bytes || undefined,
156
- draftBytes: context.draftText
157
- ? Buffer.byteLength(context.draftText, "utf-8")
158
- : undefined,
159
- imageBytes:
160
- context.images.reduce(
161
- (sum, item) => sum + Buffer.from(item.image.data, "base64").length,
162
- 0
163
- ) || undefined,
164
- imageCount: context.images.length,
165
- imageOmissions: context.imageOmissions || undefined,
166
- imagePartsSeen: context.imagePartsSeen || undefined,
282
+ return {
283
+ ...advisorContextDetails(prepared.context),
284
+ followUp: Boolean(options.followUp),
285
+ followUpPayload: {
286
+ depth: options.followUp ? options.followUp.payload.depth + 1 : 0,
287
+ messages: structuredClone(prepared.messages),
288
+ model: modelRef,
289
+ privacyKey: prepared.privacyKey,
290
+ redacted: prepared.redacted,
291
+ systemPrompt: prepared.systemPrompt,
292
+ },
167
293
  markdown,
168
- model: advisorRef,
169
- preferenceBytes: context.preferences?.bytes,
294
+ model: modelRef,
170
295
  thinkingText: streamed.thinking,
171
- trackedBytes:
172
- context.tracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
173
- untrackedBytes:
174
- context.untracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
175
296
  usage: streamed.usage,
176
297
  };
177
- if (context.scout) {
178
- response.scout = context.scout;
298
+ };
299
+
300
+ const runFallbackAdvisorAttempt = async (
301
+ options: CollectAdvisorResponseOptions,
302
+ primaryModel: string,
303
+ primaryError: Error,
304
+ prepared: PreparedAdvisorRequest | undefined
305
+ ): Promise<CollectedAdvisorResponse> => {
306
+ const { ctx } = options;
307
+ let request = prepared;
308
+ if (!advisorFallbackModelRef) {
309
+ throw primaryError;
310
+ }
311
+ const fallbackModel = advisorFallbackModelRef;
312
+ if (fallbackModel === primaryModel) {
313
+ throw combinedFallbackError(
314
+ primaryModel,
315
+ primaryError,
316
+ fallbackModel,
317
+ new Error("fallback is the same model as the primary Advisor")
318
+ );
319
+ }
320
+ if (sameModelAdvisorDisabled(ctx, ctx.model, fallbackModel)) {
321
+ throw combinedFallbackError(
322
+ primaryModel,
323
+ primaryError,
324
+ fallbackModel,
325
+ new Error(fallbackSameModelAdvisorNotice)
326
+ );
327
+ }
328
+ const fallbackAccessReason = advisorModelAccessReason(ctx);
329
+ if (fallbackAccessReason) {
330
+ throw combinedFallbackError(
331
+ primaryModel,
332
+ primaryError,
333
+ fallbackModel,
334
+ new Error(fallbackAccessReason)
335
+ );
336
+ }
337
+ let resolvedFallback: Awaited<ReturnType<typeof resolveConfiguredModel>>;
338
+ try {
339
+ resolvedFallback = await resolveConfiguredModel(
340
+ ctx,
341
+ fallbackModel,
342
+ "Advisor fallback"
343
+ );
344
+ } catch (error) {
345
+ throw combinedFallbackError(
346
+ primaryModel,
347
+ primaryError,
348
+ fallbackModel,
349
+ error instanceof Error ? error : new Error(String(error))
350
+ );
351
+ }
352
+ if (!request) {
353
+ try {
354
+ request = options.followUp
355
+ ? prepareFollowUp(options.followUp, options.question, options.ctx)
356
+ : await prepareFreshRequest(options, resolvedFallback);
357
+ } catch (error) {
358
+ throw combinedFallbackError(
359
+ primaryModel,
360
+ primaryError,
361
+ fallbackModel,
362
+ error instanceof Error ? error : new Error(String(error))
363
+ );
364
+ }
365
+ }
366
+ try {
367
+ return await runAdvisorAttempt(
368
+ options,
369
+ request,
370
+ resolvedFallback,
371
+ fallbackModel
372
+ );
373
+ } catch (error) {
374
+ if (options.signal?.aborted) {
375
+ throw error;
376
+ }
377
+ throw combinedFallbackError(
378
+ primaryModel,
379
+ primaryError,
380
+ fallbackModel,
381
+ error instanceof Error ? error : new Error(String(error))
382
+ );
179
383
  }
180
- return response;
384
+ };
385
+
386
+ const collectAdvisorResponse = async (
387
+ options: CollectAdvisorResponseOptions
388
+ ): Promise<CollectedAdvisorResponse> => {
389
+ const { ctx, signal } = options;
390
+ loadConfig(ctx);
391
+ const primaryModel = options.followUp?.payload.model ?? advisorRef;
392
+ if (sameModelAdvisorDisabled(ctx, ctx.model, primaryModel)) {
393
+ throw new Error(
394
+ primaryModel === advisorRef
395
+ ? sameModelAdvisorNotice
396
+ : fallbackSameModelAdvisorNotice
397
+ );
398
+ }
399
+ const accessReason = advisorModelAccessReason(ctx);
400
+ if (accessReason) {
401
+ throw new Error(accessReason);
402
+ }
403
+
404
+ let primaryError: Error | undefined;
405
+ let prepared: PreparedAdvisorRequest | undefined;
406
+ let resolvedPrimary:
407
+ | Awaited<ReturnType<typeof resolveConfiguredModel>>
408
+ | undefined;
409
+ try {
410
+ resolvedPrimary = await resolveConfiguredModel(
411
+ ctx,
412
+ primaryModel,
413
+ "Advisor"
414
+ );
415
+ } catch (error) {
416
+ primaryError = error instanceof Error ? error : new Error(String(error));
417
+ }
418
+ if (resolvedPrimary) {
419
+ prepared = options.followUp
420
+ ? prepareFollowUp(options.followUp, options.question, options.ctx)
421
+ : await prepareFreshRequest(options, resolvedPrimary);
422
+ try {
423
+ return await runAdvisorAttempt(
424
+ options,
425
+ prepared,
426
+ resolvedPrimary,
427
+ primaryModel
428
+ );
429
+ } catch (error) {
430
+ primaryError = error instanceof Error ? error : new Error(String(error));
431
+ }
432
+ }
433
+ if (!primaryError) {
434
+ throw new Error("Advisor request failed before an error was recorded.");
435
+ }
436
+ if (signal?.aborted) {
437
+ throw primaryError;
438
+ }
439
+ return runFallbackAdvisorAttempt(
440
+ options,
441
+ primaryModel,
442
+ primaryError,
443
+ prepared
444
+ );
181
445
  };
182
446
 
183
447
  export const consultAdvisor = async (
@@ -191,12 +455,15 @@ export const consultAdvisor = async (
191
455
  includeUntracked?: string[],
192
456
  includeTracked?: string[],
193
457
  onScout?: (event: ScoutLifecycleEvent) => void,
194
- currentInvocationId?: string
458
+ currentInvocationId?: string,
459
+ followUp?: AdvisorFollowUpRequest,
460
+ onFollowUpPayload?: AdvisorFollowUpCapture
195
461
  ): Promise<AdvisorConsultationResult> => {
196
462
  const result = await collectAdvisorResponse({
197
463
  ctx,
198
464
  currentInvocationId,
199
465
  draft,
466
+ followUp,
200
467
  gitContext,
201
468
  includeTracked,
202
469
  includeUntracked,
@@ -206,7 +473,10 @@ export const consultAdvisor = async (
206
473
  signal,
207
474
  systemPrompt: ADVISOR_SYSTEM,
208
475
  });
209
- return { ...result, adviceId: randomUUID(), trigger };
476
+ const adviceId = randomUUID();
477
+ const { followUpPayload, ...publicResult } = result;
478
+ onFollowUpPayload?.(adviceId, followUpPayload, followUp?.adviceId);
479
+ return { ...publicResult, adviceId, trigger };
210
480
  };
211
481
 
212
482
  export const runAdvisorGate = async (
@@ -41,17 +41,21 @@ export const advisorModelAccess = (
41
41
 
42
42
  export const sameModelAdvisorDisabled = (
43
43
  ctx: Pick<ExtensionContext, "model">,
44
- model: { id: string; provider: string } | undefined = ctx.model
44
+ model: { id: string; provider: string } | undefined = ctx.model,
45
+ advisorModelRef: string | undefined = advisorRef
45
46
  ): boolean => {
46
- if (!(advisorDisableSameModelRef && advisorRef && model)) {
47
+ const selectedAdvisorRef = advisorModelRef ?? advisorRef;
48
+ if (!(advisorDisableSameModelRef && selectedAdvisorRef && model)) {
47
49
  return false;
48
50
  }
49
- const [provider, id] = splitRef(advisorRef);
51
+ const [provider, id] = splitRef(selectedAdvisorRef);
50
52
  return provider === model.provider && id === model.id;
51
53
  };
52
54
 
53
55
  export const sameModelAdvisorNotice =
54
56
  "Advisor disabled: executor and advisor are the same model.";
57
+ export const fallbackSameModelAdvisorNotice =
58
+ "Advisor fallback skipped: executor and fallback Advisor are the same model.";
55
59
 
56
60
  export const advisorModelIsAllowed = (
57
61
  ctx: Pick<ExtensionContext, "model">
@@ -11,6 +11,7 @@ import {
11
11
  isSimpleMode,
12
12
  } from "../config/state.ts";
13
13
  import { loadConfig } from "../config/storage.ts";
14
+ import type { AdvisorFollowUpPayload } from "../follow-up.ts";
14
15
  import { notifyHerdrAdvisorFailure } from "../herdr.ts";
15
16
  import {
16
17
  ADVISOR_STREAM_UPDATE_INTERVAL_MS,
@@ -21,10 +22,12 @@ import {
21
22
  advisorUsageForPi,
22
23
  snapshotAdvisorUsage,
23
24
  } from "../usage.ts";
25
+ import { assertAdvisorFollowUpPrivacy } from "./consultation.ts";
24
26
  import { notifyLocalFailure, updateAdvisorUsageStatus } from "./gate-policy.ts";
25
27
  import { normalizeScreeningQuestion, screeningSkipText } from "./jev-filter.ts";
26
28
  import {
27
29
  advisorModelAccessReason,
30
+ fallbackSameModelAdvisorNotice,
28
31
  sameModelAdvisorDisabled,
29
32
  sameModelAdvisorNotice,
30
33
  } from "./model-access.ts";
@@ -47,6 +50,65 @@ const assertAdvisorModelAccess = (ctx: ExtensionContext) => {
47
50
  }
48
51
  };
49
52
 
53
+ interface AskAdvisorParams {
54
+ draft?: string;
55
+ followUpTo?: string;
56
+ force?: boolean;
57
+ gitContext?: "none" | "summary" | "full";
58
+ includeTrackedFiles?: string[];
59
+ includeUntracked?: string[];
60
+ question?: string;
61
+ }
62
+
63
+ const resolveFollowUp = (
64
+ params: AskAdvisorParams,
65
+ session: ToolRegistrationContext["session"]
66
+ ): { adviceId: string; payload: AdvisorFollowUpPayload } | undefined => {
67
+ if (!params.followUpTo) {
68
+ return undefined;
69
+ }
70
+ if (
71
+ !params.question?.trim() ||
72
+ params.draft !== undefined ||
73
+ params.gitContext !== undefined ||
74
+ params.includeTrackedFiles?.length ||
75
+ params.includeUntracked?.length
76
+ ) {
77
+ throw new Error(
78
+ "Follow-up consultations accept only followUpTo and a non-empty question. Issue a fresh consultation for new context or attachments."
79
+ );
80
+ }
81
+ const cached = session.followUpFor(params.followUpTo);
82
+ if (!cached.payload) {
83
+ throw new Error(
84
+ `Follow-up unavailable for adviceId ${params.followUpTo}: ${cached.reason ?? "the cached payload is unavailable"}. Issue a fresh consultation.`
85
+ );
86
+ }
87
+ return { adviceId: params.followUpTo, payload: cached.payload };
88
+ };
89
+
90
+ const sameModelNoticeFor = (modelRef: string | undefined) =>
91
+ (modelRef ?? advisorRef) === advisorRef
92
+ ? sameModelAdvisorNotice
93
+ : fallbackSameModelAdvisorNotice;
94
+
95
+ const skippedSameModelResult = (
96
+ id: string,
97
+ notice: string,
98
+ reservedCalls: Set<string>,
99
+ session: ToolRegistrationContext["session"]
100
+ ) => {
101
+ session.releaseCall(id);
102
+ reservedCalls.delete(id);
103
+ return {
104
+ content: [{ text: notice, type: "text" as const }],
105
+ details: {
106
+ skipReason: notice,
107
+ text: notice,
108
+ },
109
+ };
110
+ };
111
+
50
112
  const claimTrackedHandoff = (
51
113
  session: ToolRegistrationContext["session"],
52
114
  includeTrackedFiles: string[] | undefined
@@ -76,23 +138,25 @@ export const registerAskAdvisorTool = ({
76
138
  }: ToolRegistrationContext): void => {
77
139
  pi.registerTool({
78
140
  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.",
141
+ "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.",
80
142
  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
- };
143
+ let followUp:
144
+ | { adviceId: string; payload: AdvisorFollowUpPayload }
145
+ | undefined;
92
146
  try {
93
147
  loadConfig(ctx);
94
- if (sameModelAdvisorDisabled(ctx)) {
95
- return skipSameModel();
148
+ followUp = resolveFollowUp(params, session);
149
+ if (followUp) {
150
+ assertAdvisorFollowUpPrivacy(followUp.payload, ctx);
151
+ }
152
+ const modelRef = followUp?.payload.model;
153
+ if (sameModelAdvisorDisabled(ctx, ctx.model, modelRef)) {
154
+ return skippedSameModelResult(
155
+ _id,
156
+ sameModelNoticeFor(modelRef),
157
+ reservedCalls,
158
+ session
159
+ );
96
160
  }
97
161
  assertAdvisorModelAccess(ctx);
98
162
  } catch (error) {
@@ -148,8 +212,13 @@ export const registerAskAdvisorTool = ({
148
212
  },
149
213
  };
150
214
  }
151
- if (sameModelAdvisorDisabled(ctx)) {
152
- return skipSameModel();
215
+ if (sameModelAdvisorDisabled(ctx, ctx.model, followUp?.payload.model)) {
216
+ return skippedSameModelResult(
217
+ _id,
218
+ sameModelNoticeFor(followUp?.payload.model),
219
+ reservedCalls,
220
+ session
221
+ );
153
222
  }
154
223
  if (
155
224
  !simpleMode &&
@@ -191,7 +260,8 @@ export const registerAskAdvisorTool = ({
191
260
  coalescedUpdate.update({
192
261
  content: [{ text: tx, type: "text" }],
193
262
  details: {
194
- advisor: advisorRef,
263
+ advisor: followUp?.payload.model ?? advisorRef,
264
+ followUp: Boolean(followUp),
195
265
  question: resolveAdvisorRequest(params.question),
196
266
  scout: scoutDetails,
197
267
  text: tx,
@@ -209,13 +279,17 @@ export const registerAskAdvisorTool = ({
209
279
  coalescedUpdate.update({
210
280
  content: [{ text: scoutDetails.text ?? "", type: "text" }],
211
281
  details: {
212
- advisor: advisorRef,
282
+ advisor: followUp?.payload.model ?? advisorRef,
283
+ followUp: Boolean(followUp),
213
284
  question: resolveAdvisorRequest(params.question),
214
285
  scout: scoutDetails,
215
286
  },
216
287
  });
217
288
  },
218
- _id
289
+ _id,
290
+ followUp,
291
+ (adviceId, payload, replacesAdviceId) =>
292
+ session.captureFollowUp(adviceId, payload, replacesAdviceId)
219
293
  );
220
294
  flushUpdate();
221
295
  session.issueAdvice(
@@ -228,6 +302,7 @@ export const registerAskAdvisorTool = ({
228
302
  session.recordInvocation({
229
303
  cost: advisorUsageCost(result.usage),
230
304
  executionEffect: "continued",
305
+ followUp: result.followUp,
231
306
  kind: "markdown",
232
307
  model: result.model,
233
308
  trigger: "executor-requested",
@@ -241,6 +316,7 @@ export const registerAskAdvisorTool = ({
241
316
  advisor: result.model,
242
317
  agentRulesBytes: result.agentRulesBytes,
243
318
  draftBytes: result.draftBytes,
319
+ followUp: result.followUp,
244
320
  imageBytes: result.imageBytes,
245
321
  imageCount: result.imageCount,
246
322
  imageOmissions: result.imageOmissions,
@@ -259,7 +335,7 @@ export const registerAskAdvisorTool = ({
259
335
  const response: AgentToolResult<AdvisorToolDetails> = {
260
336
  content: [
261
337
  {
262
- text: `Advisor (${result.model})\n\n${result.markdown}`,
338
+ text: `${result.followUp ? "Advisor follow-up" : "Advisor"} (${result.model})\n\n${result.markdown}`,
263
339
  type: "text",
264
340
  },
265
341
  ],
@@ -280,7 +356,7 @@ export const registerAskAdvisorTool = ({
280
356
  executionEffect: "continued",
281
357
  failure: "provider-error",
282
358
  kind: "markdown",
283
- model: advisorRef,
359
+ model: followUp?.payload.model ?? advisorRef,
284
360
  trigger: "executor-requested",
285
361
  });
286
362
  updateAdvisorUsageStatus(ctx, session);
@@ -303,6 +379,12 @@ export const registerAskAdvisorTool = ({
303
379
  "Concise untrusted draft for plan or completion review; claims are not verification evidence.",
304
380
  })
305
381
  ),
382
+ followUpTo: Type.Optional(
383
+ Type.String({
384
+ description:
385
+ "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.",
386
+ })
387
+ ),
306
388
  force: Type.Optional(
307
389
  Type.Boolean({
308
390
  description:
@@ -342,12 +424,16 @@ export const registerAskAdvisorTool = ({
342
424
  ),
343
425
  }),
344
426
  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.",
427
+ "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
428
  ],
347
429
  promptSnippet:
348
430
  "Consult the Advisor using its existing context; attach a draft for plan or completion review",
349
431
  renderCall(args, theme) {
350
- return renderAdvisorCallBox(args.question?.trim(), theme);
432
+ return renderAdvisorCallBox(
433
+ args.question?.trim(),
434
+ theme,
435
+ Boolean(args.followUpTo)
436
+ );
351
437
  },
352
438
  renderResult(result, options, theme, context) {
353
439
  // SAFETY: this tool's execute() only returns AdvisorToolDetails-shaped details.