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.
@@ -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">