pi-advisor-flow 0.8.2 → 0.8.3-dev.2

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.
@@ -15,7 +15,8 @@ import {
15
15
  isSimpleMode,
16
16
  } from "../config/state.ts";
17
17
  import type { GateFailureMode } from "../config/types.ts";
18
- import { herdrAdvisorActivity } from "../herdr.ts";
18
+ import { herdrAdvisorActivity, herdrAdvisorBlock } from "../herdr.ts";
19
+ import type { HerdrAdvisorActivityScope, HerdrAdvisorBlock } from "../herdr.ts";
19
20
  import type { AdvisorSessionState, GateDecision } from "../session-state.ts";
20
21
  import { advisorUsageCost, snapshotAdvisorUsage } from "../usage.ts";
21
22
  import type { AdvisorUsageSnapshot } from "../usage.ts";
@@ -106,7 +107,8 @@ const applyGateDecision = (
106
107
  session: AdvisorSessionState,
107
108
  result: Awaited<ReturnType<typeof runAdvisorGate>>,
108
109
  reason: string,
109
- failureMode: GateFailureMode
110
+ failureMode: GateFailureMode,
111
+ herdrBlock: HerdrAdvisorBlock
110
112
  ): ToolCallEventResult | undefined => {
111
113
  if (!result.ok) {
112
114
  session.recordInvocation({
@@ -123,7 +125,8 @@ const applyGateDecision = (
123
125
  result.message,
124
126
  ctx,
125
127
  session,
126
- failureMode
128
+ failureMode,
129
+ herdrBlock
127
130
  );
128
131
  sendAutomaticGateFailure(
129
132
  pi,
@@ -151,7 +154,13 @@ const applyGateDecision = (
151
154
  }
152
155
  const gateReason = `Advisor loop review: ${result.markdown}`;
153
156
  if (result.decision === "blocked") {
154
- const effect = blockedDecisionEffect(gateReason, ctx, session, failureMode);
157
+ const effect = blockedDecisionEffect(
158
+ gateReason,
159
+ ctx,
160
+ session,
161
+ failureMode,
162
+ herdrBlock
163
+ );
155
164
  return effect.block ? { block: true, reason: effect.reason } : undefined;
156
165
  }
157
166
  return { block: true, reason: gateReason };
@@ -163,7 +172,9 @@ export const handleAutomaticGate = async (
163
172
  ctx: ExtensionContext,
164
173
  session: AdvisorSessionState,
165
174
  runGate: typeof runAdvisorGate,
166
- scoutStatus: ScoutStatusManager
175
+ scoutStatus: ScoutStatusManager,
176
+ herdrActivity: HerdrAdvisorActivityScope = herdrAdvisorActivity,
177
+ herdrBlock: HerdrAdvisorBlock = herdrAdvisorBlock
167
178
  ): Promise<ToolCallEventResult | undefined> => {
168
179
  if (
169
180
  isSimpleMode() ||
@@ -186,13 +197,14 @@ export const handleAutomaticGate = async (
186
197
  "Advisor gate call budget is exhausted.",
187
198
  ctx,
188
199
  session,
189
- failureMode
200
+ failureMode,
201
+ herdrBlock
190
202
  );
191
203
  return failure.block ? { block: true, reason: failure.reason } : undefined;
192
204
  }
193
205
  session.consumeCall();
194
206
  session.resetTurnsSinceConsultation();
195
- herdrAdvisorActivity.start();
207
+ const finishHerdrActivity = herdrActivity.start();
196
208
  let scoutDetails: ScoutToolDetails | undefined;
197
209
  const scoutStatusToken = Symbol("automatic-gate-scout");
198
210
  scoutStatus.register(scoutStatusToken);
@@ -203,10 +215,10 @@ export const handleAutomaticGate = async (
203
215
  gateCallSent = true;
204
216
  }
205
217
  };
206
- if (!advisorScoutEnabledRef) {
207
- ensureGateCall();
208
- }
209
218
  try {
219
+ if (!advisorScoutEnabledRef) {
220
+ ensureGateCall();
221
+ }
210
222
  const result = await runGate(
211
223
  ctx,
212
224
  `${reason} Review the repeated actions and recommend the smallest safe next step.`,
@@ -223,9 +235,17 @@ export const handleAutomaticGate = async (
223
235
  event.toolCallId
224
236
  );
225
237
  ensureGateCall();
226
- return applyGateDecision(pi, ctx, session, result, reason, failureMode);
238
+ return applyGateDecision(
239
+ pi,
240
+ ctx,
241
+ session,
242
+ result,
243
+ reason,
244
+ failureMode,
245
+ herdrBlock
246
+ );
227
247
  } finally {
248
+ finishHerdrActivity();
228
249
  scoutStatus.release(ctx, scoutStatusToken);
229
- herdrAdvisorActivity.finish();
230
250
  }
231
251
  };
@@ -10,7 +10,7 @@ import {
10
10
  getAdvisorSettings,
11
11
  isSimpleMode,
12
12
  } from "../config/state.ts";
13
- import { herdrAdvisorActivity, notifyHerdrAdvisorFailure } from "../herdr.ts";
13
+ import { notifyHerdrAdvisorFailure } from "../herdr.ts";
14
14
  import {
15
15
  ADVISOR_STREAM_UPDATE_INTERVAL_MS,
16
16
  createCoalescedUpdate,
@@ -63,6 +63,7 @@ const claimTrackedHandoff = (
63
63
 
64
64
  export const registerAskAdvisorTool = ({
65
65
  consult: requestAdvisor,
66
+ herdrActivity,
66
67
  pi,
67
68
  reservedCalls,
68
69
  screen,
@@ -72,162 +73,198 @@ export const registerAskAdvisorTool = ({
72
73
  description:
73
74
  "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.",
74
75
  async execute(_id, params, signal, onUpdate, ctx) {
75
- reservedCalls.delete(_id);
76
- assertAdvisorModelAccess(ctx);
77
- // The budget check precedes the handoff claim so a rejected call never
78
- // consumes the one-shot tracked-file handoff.
76
+ try {
77
+ assertAdvisorModelAccess(ctx);
78
+ } catch (error) {
79
+ session.releaseCall(_id);
80
+ reservedCalls.delete(_id);
81
+ throw error;
82
+ }
83
+ const simpleMode = isSimpleMode();
79
84
  if (
80
- !(isSimpleMode() || session.canConsult(getAdvisorMaxCallsPerSession()))
85
+ !simpleMode &&
86
+ !session.reserveCall(_id, getAdvisorMaxCallsPerSession())
81
87
  ) {
82
88
  throw new Error("Advisor call budget exhausted for this session.");
83
89
  }
84
- // The Jev screening seam sits after the budget re-check and before the
85
- // handoff claim: a skipped call consumes neither the one-shot handoff
86
- // nor the budget.
87
- const normalizedQuestion = normalizeScreeningQuestion(
88
- resolveAdvisorRequest(params.question)
89
- );
90
- const screening = await screen(ctx, session, {
91
- draft: params.draft,
92
- force: params.force,
93
- question: resolveAdvisorRequest(params.question),
94
- signal,
95
- });
96
- if (screening.decision === "skip") {
97
- const skipText = screeningSkipText(screening);
98
- return {
99
- content: [{ text: skipText, type: "text" }],
100
- details: {
101
- jev: {
102
- kind: screening.kind,
103
- reason: screening.reason,
104
- skipped: true,
105
- },
106
- text: skipText,
107
- },
108
- };
90
+ if (simpleMode) {
91
+ session.releaseCall(_id);
92
+ reservedCalls.delete(_id);
93
+ } else {
94
+ reservedCalls.add(_id);
109
95
  }
110
- claimTrackedHandoff(session, params.includeTrackedFiles);
111
- if (!isSimpleMode()) {
112
- session.consumeCall();
113
- session.resetTurnsSinceConsultation();
114
- }
115
- herdrAdvisorActivity.start();
116
- let scoutDetails: AdvisorToolDetails["scout"];
117
- const coalescedUpdate = createCoalescedUpdate(
118
- (update: Parameters<NonNullable<typeof onUpdate>>[0]) =>
119
- onUpdate?.(update),
120
- ADVISOR_STREAM_UPDATE_INTERVAL_MS
121
- );
122
- const flushUpdate = () => {
123
- const result = coalescedUpdate.flush();
124
- if (result.failed) {
125
- throw result.error;
126
- }
127
- };
96
+ let normalizedQuestion: string | undefined;
128
97
  try {
129
- const result = await requestAdvisor(
130
- ctx,
131
- resolveAdvisorRequest(params.question),
132
- signal,
133
- (t, tx) =>
134
- coalescedUpdate.update({
135
- content: [{ text: tx, type: "text" }],
136
- details: {
137
- advisor: advisorRef,
138
- question: resolveAdvisorRequest(params.question),
139
- scout: scoutDetails,
140
- text: tx,
141
- thinking: t,
142
- },
143
- }),
144
- "executor-requested",
145
- // "none" is the model declining repository context for this call.
146
- params.gitContext === "none" ? "off" : params.gitContext,
147
- params.draft,
148
- params.includeUntracked,
149
- params.includeTrackedFiles,
150
- (event) => {
151
- scoutDetails = scoutDetailsFromEvent(event, scoutDetails);
152
- coalescedUpdate.update({
153
- content: [{ text: scoutDetails.text ?? "", type: "text" }],
154
- details: {
155
- advisor: advisorRef,
156
- question: resolveAdvisorRequest(params.question),
157
- scout: scoutDetails,
158
- },
159
- });
160
- },
161
- _id
162
- );
163
- flushUpdate();
164
- session.issueAdvice(
165
- result.adviceId,
166
- result.markdown,
167
- result.trigger,
168
- Boolean(result.draftBytes),
169
- normalizedQuestion
98
+ if (
99
+ !simpleMode &&
100
+ !session.canConsult(getAdvisorMaxCallsPerSession(), _id)
101
+ ) {
102
+ throw new Error("Advisor call budget exhausted for this session.");
103
+ }
104
+ // The Jev screening seam sits before the handoff claim: a skipped call
105
+ // consumes neither the one-shot handoff nor the budget.
106
+ normalizedQuestion = normalizeScreeningQuestion(
107
+ resolveAdvisorRequest(params.question)
170
108
  );
171
- session.recordInvocation({
172
- cost: advisorUsageCost(result.usage),
173
- executionEffect: "continued",
174
- kind: "markdown",
175
- model: result.model,
176
- trigger: "executor-requested",
177
- usage: result.usage,
178
- });
179
- const usage = snapshotAdvisorUsage(result.usage);
180
- const piUsage = advisorUsageForPi(result.usage);
181
- updateAdvisorUsageStatus(ctx, session);
182
- const details: AdvisorToolDetails = {
183
- adviceId: result.adviceId,
184
- advisor: result.model,
185
- draftBytes: result.draftBytes,
186
- preferenceBytes: result.preferenceBytes,
109
+ const screening = await screen(ctx, session, {
110
+ draft: params.draft,
111
+ force: params.force,
187
112
  question: resolveAdvisorRequest(params.question),
188
- scout: scoutDetails,
189
- text: result.markdown,
190
- thinking: result.thinkingText,
191
- trackedBytes: result.trackedBytes,
192
- untrackedBytes: result.untrackedBytes,
193
- };
194
- if (usage) {
195
- details.usage = usage;
196
- }
197
- const response: AgentToolResult<AdvisorToolDetails> = {
198
- content: [
199
- {
200
- text: `Advisor (${result.model})\n\n${result.markdown}`,
201
- type: "text",
113
+ signal,
114
+ });
115
+ if (screening.decision === "skip") {
116
+ const skipText = screeningSkipText(screening);
117
+ session.releaseCall(_id);
118
+ reservedCalls.delete(_id);
119
+ return {
120
+ content: [{ text: skipText, type: "text" }],
121
+ details: {
122
+ jev: {
123
+ kind: screening.kind,
124
+ reason: screening.reason,
125
+ skipped: true,
126
+ },
127
+ text: skipText,
202
128
  },
203
- ],
204
- details,
205
- };
206
- if (piUsage) {
207
- response.usage = piUsage;
129
+ };
130
+ }
131
+ if (
132
+ !simpleMode &&
133
+ !session.canConsult(getAdvisorMaxCallsPerSession(), _id)
134
+ ) {
135
+ throw new Error("Advisor call budget exhausted for this session.");
136
+ }
137
+ claimTrackedHandoff(session, params.includeTrackedFiles);
138
+ if (!simpleMode) {
139
+ session.consumeCall(_id);
140
+ reservedCalls.delete(_id);
141
+ session.resetTurnsSinceConsultation();
208
142
  }
209
- return response;
210
143
  } catch (error) {
211
- // Publish the latest partial state before surfacing a provider or
212
- // execution error. A failure from the UI sink must not replace the
213
- // original error because this path is also used for provider failures.
214
- coalescedUpdate.flush();
215
- const message = error instanceof Error ? error.message : String(error);
216
- session.recordInvocation({
217
- executionEffect: "continued",
218
- failure: "provider-error",
219
- kind: "markdown",
220
- model: advisorRef,
221
- trigger: "executor-requested",
222
- });
223
- updateAdvisorUsageStatus(ctx, session);
224
- notifyLocalFailure(ctx, message);
225
- notifyHerdrAdvisorFailure("Advisor consultation failed", message);
144
+ session.releaseCall(_id);
145
+ reservedCalls.delete(_id);
226
146
  throw error;
227
- } finally {
228
- coalescedUpdate.cancel();
229
- herdrAdvisorActivity.finish();
230
147
  }
148
+ const runConsultation = async () => {
149
+ let scoutDetails: AdvisorToolDetails["scout"];
150
+ const coalescedUpdate = createCoalescedUpdate(
151
+ (update: Parameters<NonNullable<typeof onUpdate>>[0]) =>
152
+ onUpdate?.(update),
153
+ ADVISOR_STREAM_UPDATE_INTERVAL_MS
154
+ );
155
+ const flushUpdate = () => {
156
+ const result = coalescedUpdate.flush();
157
+ if (result.failed) {
158
+ throw result.error;
159
+ }
160
+ };
161
+ const finishHerdrActivity = herdrActivity.start();
162
+ try {
163
+ const result = await requestAdvisor(
164
+ ctx,
165
+ resolveAdvisorRequest(params.question),
166
+ signal,
167
+ (t, tx) =>
168
+ coalescedUpdate.update({
169
+ content: [{ text: tx, type: "text" }],
170
+ details: {
171
+ advisor: advisorRef,
172
+ question: resolveAdvisorRequest(params.question),
173
+ scout: scoutDetails,
174
+ text: tx,
175
+ thinking: t,
176
+ },
177
+ }),
178
+ "executor-requested",
179
+ // "none" is the model declining repository context for this call.
180
+ params.gitContext === "none" ? "off" : params.gitContext,
181
+ params.draft,
182
+ params.includeUntracked,
183
+ params.includeTrackedFiles,
184
+ (event) => {
185
+ scoutDetails = scoutDetailsFromEvent(event, scoutDetails);
186
+ coalescedUpdate.update({
187
+ content: [{ text: scoutDetails.text ?? "", type: "text" }],
188
+ details: {
189
+ advisor: advisorRef,
190
+ question: resolveAdvisorRequest(params.question),
191
+ scout: scoutDetails,
192
+ },
193
+ });
194
+ },
195
+ _id
196
+ );
197
+ flushUpdate();
198
+ session.issueAdvice(
199
+ result.adviceId,
200
+ result.markdown,
201
+ result.trigger,
202
+ Boolean(result.draftBytes),
203
+ normalizedQuestion
204
+ );
205
+ session.recordInvocation({
206
+ cost: advisorUsageCost(result.usage),
207
+ executionEffect: "continued",
208
+ kind: "markdown",
209
+ model: result.model,
210
+ trigger: "executor-requested",
211
+ usage: result.usage,
212
+ });
213
+ const usage = snapshotAdvisorUsage(result.usage);
214
+ const piUsage = advisorUsageForPi(result.usage);
215
+ updateAdvisorUsageStatus(ctx, session);
216
+ const details: AdvisorToolDetails = {
217
+ adviceId: result.adviceId,
218
+ advisor: result.model,
219
+ draftBytes: result.draftBytes,
220
+ preferenceBytes: result.preferenceBytes,
221
+ question: resolveAdvisorRequest(params.question),
222
+ scout: scoutDetails,
223
+ text: result.markdown,
224
+ thinking: result.thinkingText,
225
+ trackedBytes: result.trackedBytes,
226
+ untrackedBytes: result.untrackedBytes,
227
+ };
228
+ if (usage) {
229
+ details.usage = usage;
230
+ }
231
+ const response: AgentToolResult<AdvisorToolDetails> = {
232
+ content: [
233
+ {
234
+ text: `Advisor (${result.model})\n\n${result.markdown}`,
235
+ type: "text",
236
+ },
237
+ ],
238
+ details,
239
+ };
240
+ if (piUsage) {
241
+ response.usage = piUsage;
242
+ }
243
+ return response;
244
+ } catch (error) {
245
+ // Publish the latest partial state before surfacing a provider or
246
+ // execution error. A failure from the UI sink must not replace the
247
+ // original error because this path is also used for provider failures.
248
+ coalescedUpdate.flush();
249
+ const message =
250
+ error instanceof Error ? error.message : String(error);
251
+ session.recordInvocation({
252
+ executionEffect: "continued",
253
+ failure: "provider-error",
254
+ kind: "markdown",
255
+ model: advisorRef,
256
+ trigger: "executor-requested",
257
+ });
258
+ updateAdvisorUsageStatus(ctx, session);
259
+ notifyLocalFailure(ctx, message);
260
+ notifyHerdrAdvisorFailure("Advisor consultation failed", message);
261
+ throw error;
262
+ } finally {
263
+ finishHerdrActivity();
264
+ coalescedUpdate.cancel();
265
+ }
266
+ };
267
+ return runConsultation();
231
268
  },
232
269
  label: "Ask Advisor",
233
270
  name: "ask_advisor",
@@ -4,7 +4,6 @@ import {
4
4
  isSimpleMode,
5
5
  } from "../config/state.ts";
6
6
  import { loadConfig } from "../config/storage.ts";
7
- import { herdrAdvisorBlock } from "../herdr.ts";
8
7
  import { reserveAdvisorCall } from "./gate-policy.ts";
9
8
  import { handleAutomaticGate } from "./loop-gate.ts";
10
9
  import { advisorModelAccess } from "./model-access.ts";
@@ -26,6 +25,8 @@ const modelAccessBlock = (
26
25
  };
27
26
 
28
27
  export const registerToolLifecycle = ({
28
+ herdrActivity,
29
+ herdrBlock,
29
30
  pi,
30
31
  reservedCalls,
31
32
  runGate,
@@ -61,7 +62,7 @@ export const registerToolLifecycle = ({
61
62
  pi.on("session_start", (_event, ctx) => {
62
63
  session.resetTask();
63
64
  reservedCalls.clear();
64
- herdrAdvisorBlock.clear();
65
+ herdrBlock.clear();
65
66
  if (ctx?.hasUI) {
66
67
  ctx.ui.setStatus("advisor-usage", undefined);
67
68
  }
@@ -114,13 +115,23 @@ export const registerToolLifecycle = ({
114
115
  if (event.toolName === "ask_advisor") {
115
116
  return reservation;
116
117
  }
117
- return handleAutomaticGate(pi, event, ctx, session, runGate, scoutStatus);
118
+ return handleAutomaticGate(
119
+ pi,
120
+ event,
121
+ ctx,
122
+ session,
123
+ runGate,
124
+ scoutStatus,
125
+ herdrActivity,
126
+ herdrBlock
127
+ );
118
128
  });
119
129
 
120
130
  pi.on("agent_settled", (_event, ctx) => {
121
131
  // Any reservation still present never reached execute (for example because
122
132
  // another handler blocked it or the turn was aborted).
123
133
  reservedCalls.clear();
134
+ session.clearCallReservations();
124
135
  if (isSimpleMode() || session.blocked || !advisorSessionSummaryRef) {
125
136
  return;
126
137
  }
@@ -132,8 +143,9 @@ export const registerToolLifecycle = ({
132
143
 
133
144
  pi.on("session_shutdown", (_event, ctx) => {
134
145
  reservedCalls.clear();
146
+ session.clearCallReservations();
135
147
  scoutStatus.clear(ctx);
136
- herdrAdvisorBlock.clear();
148
+ herdrBlock.clear();
137
149
  if (ctx?.hasUI) {
138
150
  ctx.ui.setStatus("advisor-usage", undefined);
139
151
  }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ import { herdrAdvisorActivity, herdrAdvisorBlock } from "../herdr.ts";
3
4
  import { appendOutcome } from "../outcomes.ts";
4
5
  import type { AdvisorSessionState } from "../session-state.ts";
5
6
  import { consultAdvisor, runAdvisorGate } from "./consultation.ts";
@@ -25,6 +26,8 @@ export const registerAdvisorTool = (
25
26
  const registration: ToolRegistrationContext = {
26
27
  appendOutcome: dependencies.appendOutcome ?? appendOutcome,
27
28
  consult: dependencies.consult ?? consultAdvisor,
29
+ herdrActivity: dependencies.herdrActivity ?? herdrAdvisorActivity,
30
+ herdrBlock: dependencies.herdrBlock ?? herdrAdvisorBlock,
28
31
  pi,
29
32
  reservedCalls: new Set<string>(),
30
33
  runGate: dependencies.runGate ?? runAdvisorGate,
@@ -40,6 +43,7 @@ export const registerAdvisorTool = (
40
43
  const turnGate: JevTurnGateRegistration = {
41
44
  activeTools: () => pi.getActiveTools(),
42
45
  consult: registration.consult,
46
+ herdrActivity: registration.herdrActivity,
43
47
  send: (message) => pi.sendMessage(message, { deliverAs: "steer" }),
44
48
  session,
45
49
  };
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ import type { HerdrAdvisorActivityScope, HerdrAdvisorBlock } from "../herdr.ts";
3
4
  import type { appendOutcome } from "../outcomes.ts";
4
5
  import type { ScoutOutcome } from "../scout.ts";
5
6
  import type {
@@ -112,6 +113,8 @@ export interface AdvisorToolContext {
112
113
  export interface ToolRegistrationContext {
113
114
  appendOutcome: typeof appendOutcome;
114
115
  consult: typeof consultAdvisor;
116
+ herdrActivity: HerdrAdvisorActivityScope;
117
+ herdrBlock: HerdrAdvisorBlock;
115
118
  pi: ExtensionAPI;
116
119
  reservedCalls: Set<string>;
117
120
  runGate: typeof runAdvisorGate;
@@ -123,6 +126,8 @@ export interface ToolRegistrationContext {
123
126
  export interface ToolRegistrationDependencies {
124
127
  appendOutcome?: typeof appendOutcome;
125
128
  consult?: typeof consultAdvisor;
129
+ herdrActivity?: HerdrAdvisorActivityScope;
130
+ herdrBlock?: HerdrAdvisorBlock;
126
131
  runGate?: typeof runAdvisorGate;
127
132
  screen?: typeof screenConsultation;
128
133
  statusManager?: ScoutStatusManager;
@@ -214,7 +214,9 @@ export class JevSetupSubmenu implements Component, Focusable {
214
214
 
215
215
  private actions(): SetupAction[] {
216
216
  if (!this.credentials) {
217
- return ["enter-key", "done"];
217
+ return this.options.currentValue === "On"
218
+ ? ["enter-key", "disable", "done"]
219
+ : ["enter-key", "done"];
218
220
  }
219
221
  const enabled = this.options.currentValue === "On";
220
222
  const actions: SetupAction[] = [enabled ? "verify-again" : "verify-enable"];
@@ -277,8 +279,12 @@ export class JevSetupSubmenu implements Component, Focusable {
277
279
  return;
278
280
  }
279
281
  const result = await clear();
280
- this.credentials = undefined;
281
282
  this.notice = result.message;
283
+ if (!result.ok) {
284
+ this.options.tui.requestRender();
285
+ return;
286
+ }
287
+ this.credentials = undefined;
282
288
  this.options.done("Off");
283
289
  }
284
290