pi-advisor-flow 0.4.0 → 0.5.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.
package/src/tools.ts CHANGED
@@ -10,7 +10,14 @@ import {
10
10
  type ToolCallEventResult,
11
11
  type ToolRenderResultOptions,
12
12
  } from "@earendil-works/pi-coding-agent";
13
- import { Box, Markdown, Text } from "@earendil-works/pi-tui";
13
+ import {
14
+ Box,
15
+ type Component,
16
+ Markdown,
17
+ Text,
18
+ truncateToWidth,
19
+ visibleWidth,
20
+ } from "@earendil-works/pi-tui";
14
21
  import { Type } from "typebox";
15
22
  import {
16
23
  advisorAutoLoopGateRef,
@@ -35,6 +42,7 @@ import {
35
42
  contextMaxCharsRef,
36
43
  executorRef,
37
44
  getAdvisorMaxCallsPerSession,
45
+ getAdvisorSettings,
38
46
  isSimpleMode,
39
47
  loadConfig,
40
48
  } from "./config.js";
@@ -57,7 +65,12 @@ import {
57
65
  herdrAdvisorBlock,
58
66
  notifyHerdrAdvisorFailure,
59
67
  } from "./herdr.js";
60
- import { collectTextStream, resolveConfiguredModel } from "./model-stream.js";
68
+ import {
69
+ ADVISOR_STREAM_UPDATE_INTERVAL_MS,
70
+ collectTextStream,
71
+ createCoalescedUpdate,
72
+ resolveConfiguredModel,
73
+ } from "./model-stream.js";
61
74
  import { ADOPTIONS, appendOutcome, VALIDATIONS } from "./outcomes.js";
62
75
  import { readProjectPreferences } from "./preferences.js";
63
76
  import {
@@ -105,6 +118,56 @@ export const SPINNER_FRAMES = [
105
118
  "⠇",
106
119
  "⠏",
107
120
  ];
121
+
122
+ const THINKING_PREFIX = " 💭 ";
123
+ const THINKING_PREFIX_WIDTH = visibleWidth(THINKING_PREFIX);
124
+ type ThinkingTheme = Pick<Theme, "fg">;
125
+
126
+ /**
127
+ * Renders visible nested-model thinking with the same Markdown semantics as
128
+ * Pi's assistant thinking blocks while keeping the compact speech-bubble cue.
129
+ * The prefix is added after Markdown parsing so it cannot change block syntax.
130
+ *
131
+ * Note: During streaming, incomplete Markdown (e.g., `**text` without closing `**`)
132
+ * displays raw markers transiently until delimiters arrive. This mirrors expected
133
+ * behavior when typing Markdown incrementally and is acceptable in the context
134
+ * of brief thinking previews. When thinking is complete, all markers render.
135
+ */
136
+ class ThinkingMarkdown implements Component {
137
+ private readonly markdown: Markdown;
138
+ private readonly prefix: string;
139
+
140
+ constructor(thinking: string, theme: ThinkingTheme) {
141
+ this.markdown = new Markdown(thinking.trim(), 0, 0, getMarkdownTheme(), {
142
+ color: (text) => theme.fg("thinkingText", text),
143
+ italic: true,
144
+ });
145
+ this.prefix = theme.fg("thinkingText", THINKING_PREFIX);
146
+ }
147
+
148
+ render(width: number): string[] {
149
+ const renderWidth = Math.max(1, Math.floor(width));
150
+ const contentWidth = Math.max(1, renderWidth - THINKING_PREFIX_WIDTH);
151
+ const lines = this.markdown.render(contentWidth);
152
+ return lines.map((line, index) =>
153
+ truncateToWidth(
154
+ index === 0 ? `${this.prefix}${line}` : line,
155
+ renderWidth,
156
+ ""
157
+ )
158
+ );
159
+ }
160
+
161
+ invalidate(): void {
162
+ this.markdown.invalidate();
163
+ }
164
+ }
165
+
166
+ export const renderThinkingMarkdown = (
167
+ thinking: string,
168
+ theme: ThinkingTheme
169
+ ): Component => new ThinkingMarkdown(thinking, theme);
170
+
108
171
  export const resolveAdvisorRequest = (question?: string) =>
109
172
  question?.trim() || undefined;
110
173
  export const advisorMessageText = (
@@ -712,7 +775,10 @@ const updateAdvisorUsageStatus = (
712
775
  session: AdvisorSessionState
713
776
  ) => {
714
777
  if (ctx.hasUI) {
715
- ctx.ui.setStatus("advisor-usage", session.usageStatus());
778
+ ctx.ui.setStatus(
779
+ "advisor-usage",
780
+ getAdvisorSettings().showUsageFooter ? session.usageStatus() : undefined
781
+ );
716
782
  }
717
783
  };
718
784
 
@@ -740,12 +806,15 @@ export const gateFailureEffectForMode = (
740
806
  : ("session-blocked" as const);
741
807
  };
742
808
 
743
- const gateDecisionEffect = (decision: GateDecision) => {
809
+ const gateDecisionEffect = (
810
+ decision: GateDecision,
811
+ failureMode: "block-session" | "block-tool" | "warn-and-continue"
812
+ ) => {
744
813
  if (decision === "proceed") {
745
814
  return "continued" as const;
746
815
  }
747
816
  return decision === "blocked"
748
- ? ("session-blocked" as const)
817
+ ? gateFailureEffectForMode(failureMode)
749
818
  : ("tool-blocked" as const);
750
819
  };
751
820
 
@@ -753,15 +822,42 @@ const failureEffect = (
753
822
  category: GateFailureCategory,
754
823
  message: string,
755
824
  ctx: ExtensionContext,
756
- session: AdvisorSessionState
825
+ session: AdvisorSessionState,
826
+ failureMode: "block-session" | "block-tool" | "warn-and-continue"
757
827
  ) => {
758
828
  const reason = `Advisor gate ${category}: ${message}`;
759
- notifyLocalFailure(ctx, message, advisorFailureModeRef === "block-session");
829
+ notifyLocalFailure(ctx, message, failureMode === "block-session");
760
830
  notifyHerdrAdvisorFailure("Advisor gate failure", reason);
761
- if (advisorFailureModeRef === "warn-and-continue") {
831
+ if (failureMode === "warn-and-continue") {
832
+ return { block: false, effect: "continued" as const, reason };
833
+ }
834
+ if (failureMode === "block-tool") {
835
+ return { block: true, effect: "tool-blocked" as const, reason };
836
+ }
837
+ session.block(reason);
838
+ herdrAdvisorBlock.set(reason);
839
+ if (advisorBlockOnBlockedRef) {
840
+ ctx.abort();
841
+ }
842
+ return { block: true, effect: "session-blocked" as const, reason };
843
+ };
844
+
845
+ const blockedDecisionEffect = (
846
+ reason: string,
847
+ ctx: ExtensionContext,
848
+ session: AdvisorSessionState,
849
+ failureMode: "block-session" | "block-tool" | "warn-and-continue"
850
+ ) => {
851
+ if (failureMode === "warn-and-continue") {
852
+ if (ctx.hasUI) {
853
+ ctx.ui.notify(
854
+ "Advisor gate returned blocked; continuing by configuration.",
855
+ "warning"
856
+ );
857
+ }
762
858
  return { block: false, effect: "continued" as const, reason };
763
859
  }
764
- if (advisorFailureModeRef === "block-tool") {
860
+ if (failureMode === "block-tool") {
765
861
  return { block: true, effect: "tool-blocked" as const, reason };
766
862
  }
767
863
  session.block(reason);
@@ -870,12 +966,14 @@ const handleAutomaticGate = async (
870
966
  return;
871
967
  }
872
968
  const reason = `Advisor loop gate: normalized signature for ${event.toolName} repeated ${advisorLoopThresholdRef} times without a materially different tool action.`;
969
+ const failureMode = advisorFailureModeRef;
873
970
  if (!session.canConsult(getAdvisorMaxCallsPerSession())) {
874
971
  const failure = failureEffect(
875
972
  "budget-exhausted",
876
973
  "Advisor gate call budget is exhausted.",
877
974
  ctx,
878
- session
975
+ session,
976
+ failureMode
879
977
  );
880
978
  return failure.block ? { block: true, reason: failure.reason } : undefined;
881
979
  }
@@ -913,7 +1011,7 @@ const handleAutomaticGate = async (
913
1011
  ensureGateCall();
914
1012
  if (!result.ok) {
915
1013
  session.recordInvocation({
916
- executionEffect: gateFailureEffectForMode(advisorFailureModeRef),
1014
+ executionEffect: gateFailureEffectForMode(failureMode),
917
1015
  failure: result.category,
918
1016
  kind: "gate",
919
1017
  model: advisorRef,
@@ -925,7 +1023,8 @@ const handleAutomaticGate = async (
925
1023
  result.category,
926
1024
  result.message,
927
1025
  ctx,
928
- session
1026
+ session,
1027
+ failureMode
929
1028
  );
930
1029
  sendAutomaticGateFailure(
931
1030
  pi,
@@ -939,7 +1038,7 @@ const handleAutomaticGate = async (
939
1038
  session.recordInvocation({
940
1039
  cost: advisorUsageCost(result.usage),
941
1040
  decision: result.decision,
942
- executionEffect: gateDecisionEffect(result.decision),
1041
+ executionEffect: gateDecisionEffect(result.decision, failureMode),
943
1042
  kind: "gate",
944
1043
  model: result.model,
945
1044
  trigger: result.trigger,
@@ -953,11 +1052,13 @@ const handleAutomaticGate = async (
953
1052
  }
954
1053
  const gateReason = `Advisor loop review: ${result.markdown}`;
955
1054
  if (result.decision === "blocked") {
956
- session.block(gateReason);
957
- herdrAdvisorBlock.set(gateReason);
958
- if (advisorBlockOnBlockedRef) {
959
- ctx.abort();
960
- }
1055
+ const effect = blockedDecisionEffect(
1056
+ gateReason,
1057
+ ctx,
1058
+ session,
1059
+ failureMode
1060
+ );
1061
+ return effect.block ? { block: true, reason: effect.reason } : undefined;
961
1062
  }
962
1063
  return { block: true, reason: gateReason };
963
1064
  } finally {
@@ -1074,6 +1175,11 @@ export class ScoutStatusManager {
1074
1175
  readonly #active = new Set<symbol>();
1075
1176
  readonly #known = new Set<symbol>();
1076
1177
  readonly #retired = new Set<symbol>();
1178
+ private readonly showStatus: boolean;
1179
+
1180
+ constructor(showStatus = true) {
1181
+ this.showStatus = showStatus;
1182
+ }
1077
1183
 
1078
1184
  register(token: symbol) {
1079
1185
  if (!this.#retired.has(token)) {
@@ -1088,7 +1194,9 @@ export class ScoutStatusManager {
1088
1194
  this.#known.add(token);
1089
1195
  if (event.type === "call" || event.type === "chunk") {
1090
1196
  this.#active.add(token);
1091
- ctx.ui.setStatus("advisor-scout", "Scout curating…");
1197
+ if (this.showStatus) {
1198
+ ctx.ui.setStatus("advisor-scout", "Scout curating…");
1199
+ }
1092
1200
  return;
1093
1201
  }
1094
1202
  this.release(ctx, token);
@@ -1098,7 +1206,7 @@ export class ScoutStatusManager {
1098
1206
  this.#active.delete(token);
1099
1207
  this.#known.delete(token);
1100
1208
  this.#retired.add(token);
1101
- if (!ctx.hasUI) {
1209
+ if (!(ctx.hasUI && this.showStatus)) {
1102
1210
  return;
1103
1211
  }
1104
1212
  ctx.ui.setStatus(
@@ -1113,7 +1221,7 @@ export class ScoutStatusManager {
1113
1221
  }
1114
1222
  this.#known.clear();
1115
1223
  this.#active.clear();
1116
- if (ctx.hasUI) {
1224
+ if (ctx.hasUI && this.showStatus) {
1117
1225
  ctx.ui.setStatus("advisor-scout", undefined);
1118
1226
  }
1119
1227
  }
@@ -1154,28 +1262,28 @@ export const renderScoutDetails = (
1154
1262
  ` ${scout.model}${scout.selectedCount === undefined ? "" : ` · ${scout.selectedCount} kept / ${Math.max(0, (scout.availableCount ?? 0) - scout.selectedCount)} omitted`}${scout.latencyMs === undefined ? "" : ` · ${(scout.latencyMs / 1000).toFixed(1)}s`}`
1155
1263
  ),
1156
1264
  ];
1157
- const usage = formatAdvisorUsage(scout.usage);
1158
- if (usage) {
1159
- lines.push(theme.fg("dim", ` Usage: ${usage}`));
1265
+ if (getAdvisorSettings().showUsageDetails) {
1266
+ const usage = formatAdvisorUsage(scout.usage);
1267
+ if (usage) {
1268
+ lines.push(theme.fg("dim", ` Usage: ${usage}`));
1269
+ }
1160
1270
  }
1161
1271
  if (scout.fallbackReason) {
1162
1272
  lines.push(theme.fg("warning", ` ${scout.fallbackReason}`));
1163
1273
  }
1164
- if (scout.thinking && active) {
1165
- lines.push(
1166
- theme.fg(
1167
- "thinkingText",
1168
- ` 💭 ${scout.thinking.replace(/\n/g, " ").slice(-200)}`
1169
- )
1170
- );
1274
+ const thinking = scout.thinking && active ? scout.thinking.slice(-200) : "";
1275
+ box.addChild(new Text(lines.join("\n"), 0, 0));
1276
+ if (thinking.trim()) {
1277
+ box.addChild(renderThinkingMarkdown(thinking, theme));
1171
1278
  }
1279
+ const expandedLines: string[] = [];
1172
1280
  if (expanded && scout.selectedLabels?.length) {
1173
- lines.push(
1281
+ expandedLines.push(
1174
1282
  theme.fg("dim", ` Selected: ${scout.selectedLabels.join("; ")}`)
1175
1283
  );
1176
1284
  }
1177
1285
  if (expanded && scout.synthesis) {
1178
- lines.push(
1286
+ expandedLines.push(
1179
1287
  theme.fg(
1180
1288
  "dim",
1181
1289
  ` Scout synthesis (untrusted inference): ${scout.synthesis}`
@@ -1183,14 +1291,16 @@ export const renderScoutDetails = (
1183
1291
  );
1184
1292
  }
1185
1293
  if (expanded && scout.omittedBeforeScout) {
1186
- lines.push(
1294
+ expandedLines.push(
1187
1295
  theme.fg(
1188
1296
  "dim",
1189
1297
  ` ${scout.omittedBeforeScout} group(s) omitted before Scout`
1190
1298
  )
1191
1299
  );
1192
1300
  }
1193
- box.addChild(new Text(lines.join("\n"), 0, 0));
1301
+ if (expandedLines.length > 0) {
1302
+ box.addChild(new Text(expandedLines.join("\n"), 0, 0));
1303
+ }
1194
1304
  };
1195
1305
 
1196
1306
  const syncRenderPhase = (context: AdvisorToolContext, phase: string) => {
@@ -1230,14 +1340,14 @@ const renderPartialAdvisorResult = (
1230
1340
  const lines = [
1231
1341
  `${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`,
1232
1342
  ];
1233
- if (details?.thinking) {
1343
+ box.addChild(new Text(lines.join("\n"), 0, 0));
1344
+ if (details?.thinking?.trim()) {
1234
1345
  const thought =
1235
1346
  details.thinking.length > 200
1236
1347
  ? details.thinking.slice(-200)
1237
1348
  : details.thinking;
1238
- lines.push(theme.fg("thinkingText", ` 💭 ${thought.replace(/\n/g, " ")}`));
1349
+ box.addChild(renderThinkingMarkdown(thought, theme));
1239
1350
  }
1240
- box.addChild(new Text(lines.join("\n"), 0, 0));
1241
1351
  if (details?.text) {
1242
1352
  box.addChild(
1243
1353
  new Markdown(
@@ -1278,9 +1388,11 @@ const renderFinalAdvisorResult = (
1278
1388
  if (details?.advisor) {
1279
1389
  lines.push(theme.fg("dim", ` ${details.advisor}`));
1280
1390
  }
1281
- const usage = formatAdvisorUsage(details?.usage);
1282
- if (usage) {
1283
- lines.push(theme.fg("dim", ` Usage: ${usage}`));
1391
+ if (getAdvisorSettings().showUsageDetails) {
1392
+ const usage = formatAdvisorUsage(details?.usage);
1393
+ if (usage) {
1394
+ lines.push(theme.fg("dim", ` Usage: ${usage}`));
1395
+ }
1284
1396
  }
1285
1397
  const attachments = [
1286
1398
  details?.draftBytes
@@ -1299,17 +1411,14 @@ const renderFinalAdvisorResult = (
1299
1411
  if (attachments.length) {
1300
1412
  lines.push(theme.fg("dim", ` ${attachments.join(" · ")}`));
1301
1413
  }
1302
- if (details?.thinking) {
1303
- const thought = details.thinking.replace(/\n/g, " ").slice(0, 300);
1304
- lines.push(
1305
- theme.fg(
1306
- "thinkingText",
1307
- ` 💭 ${thought}${details.thinking.length > 300 ? "…" : ""}`
1308
- )
1309
- );
1310
- }
1414
+ const thinking = details?.thinking?.trim()
1415
+ ? `${details.thinking.slice(0, 300)}${details.thinking.length > 300 ? "…" : ""}`
1416
+ : "";
1311
1417
  const displayAdvice = advice || "(Advisor returned no advice.)";
1312
1418
  box.addChild(new Text(lines.join("\n"), 0, 0));
1419
+ if (thinking) {
1420
+ box.addChild(renderThinkingMarkdown(thinking, theme));
1421
+ }
1313
1422
  box.addChild(
1314
1423
  new Markdown(
1315
1424
  adviceForDisplay(displayAdvice, expanded),
@@ -1344,10 +1453,14 @@ export const registerAdvisorTool = (
1344
1453
  pi: ExtensionAPI,
1345
1454
  session: AdvisorSessionState = advisorSessionState,
1346
1455
  dependencies: {
1456
+ appendOutcome?: typeof appendOutcome;
1457
+ consult?: typeof consultAdvisor;
1347
1458
  runGate?: typeof runAdvisorGate;
1348
1459
  statusManager?: ScoutStatusManager;
1349
1460
  } = {}
1350
1461
  ) => {
1462
+ const appendAdvisorOutcome = dependencies.appendOutcome ?? appendOutcome;
1463
+ const requestAdvisor = dependencies.consult ?? consultAdvisor;
1351
1464
  const reservedCalls = new Set<string>();
1352
1465
  const scoutStatus = dependencies.statusManager ?? new ScoutStatusManager();
1353
1466
 
@@ -1394,9 +1507,11 @@ export const registerAdvisorTool = (
1394
1507
  if (details?.advisor) {
1395
1508
  box.addChild(new Text(theme.fg("dim", ` ${details.advisor}`), 0, 0));
1396
1509
  }
1397
- const usage = formatAdvisorUsage(details?.usage);
1398
- if (usage) {
1399
- box.addChild(new Text(theme.fg("dim", ` Usage: ${usage}`), 0, 0));
1510
+ if (getAdvisorSettings().showUsageDetails) {
1511
+ const usage = formatAdvisorUsage(details?.usage);
1512
+ if (usage) {
1513
+ box.addChild(new Text(theme.fg("dim", ` Usage: ${usage}`), 0, 0));
1514
+ }
1400
1515
  }
1401
1516
  if (details?.text) {
1402
1517
  box.addChild(
@@ -1505,7 +1620,6 @@ export const registerAdvisorTool = (
1505
1620
  pi.registerTool({
1506
1621
  description:
1507
1622
  "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.",
1508
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: usage and consent paths remain explicit at the tool boundary.
1509
1623
  async execute(_id, params, signal, onUpdate, ctx) {
1510
1624
  reservedCalls.delete(_id);
1511
1625
  if (
@@ -1524,13 +1638,24 @@ export const registerAdvisorTool = (
1524
1638
  }
1525
1639
  herdrAdvisorActivity.start();
1526
1640
  let scoutDetails: ScoutToolDetails | undefined;
1641
+ const coalescedUpdate = createCoalescedUpdate(
1642
+ (update: Parameters<NonNullable<typeof onUpdate>>[0]) =>
1643
+ onUpdate?.(update),
1644
+ ADVISOR_STREAM_UPDATE_INTERVAL_MS
1645
+ );
1646
+ const flushUpdate = () => {
1647
+ const result = coalescedUpdate.flush();
1648
+ if (result.failed) {
1649
+ throw result.error;
1650
+ }
1651
+ };
1527
1652
  try {
1528
- const result = await consultAdvisor(
1653
+ const result = await requestAdvisor(
1529
1654
  ctx,
1530
1655
  resolveAdvisorRequest(params.question),
1531
1656
  signal,
1532
1657
  (t, tx) =>
1533
- onUpdate?.({
1658
+ coalescedUpdate.update({
1534
1659
  content: [{ text: tx, type: "text" }],
1535
1660
  details: {
1536
1661
  advisor: advisorRef,
@@ -1548,7 +1673,7 @@ export const registerAdvisorTool = (
1548
1673
  params.includeTrackedFiles,
1549
1674
  (event) => {
1550
1675
  scoutDetails = scoutDetailsFromEvent(event, scoutDetails);
1551
- onUpdate?.({
1676
+ coalescedUpdate.update({
1552
1677
  content: [{ text: scoutDetails.text ?? "", type: "text" }],
1553
1678
  details: {
1554
1679
  advisor: advisorRef,
@@ -1559,6 +1684,7 @@ export const registerAdvisorTool = (
1559
1684
  },
1560
1685
  _id
1561
1686
  );
1687
+ flushUpdate();
1562
1688
  session.issueAdvice(
1563
1689
  result.adviceId,
1564
1690
  result.markdown,
@@ -1575,9 +1701,7 @@ export const registerAdvisorTool = (
1575
1701
  });
1576
1702
  const usage = snapshotAdvisorUsage(result.usage);
1577
1703
  const piUsage = advisorUsageForPi(result.usage);
1578
- if (ctx.hasUI) {
1579
- ctx.ui.setStatus("advisor-usage", session.usageStatus());
1580
- }
1704
+ updateAdvisorUsageStatus(ctx, session);
1581
1705
  return {
1582
1706
  content: [
1583
1707
  {
@@ -1601,6 +1725,10 @@ export const registerAdvisorTool = (
1601
1725
  ...(piUsage ? { usage: piUsage } : {}),
1602
1726
  };
1603
1727
  } catch (error) {
1728
+ // Publish the latest partial state before surfacing a provider or
1729
+ // execution error. A failure from the UI sink must not replace the
1730
+ // original error because this path is also used for provider failures.
1731
+ coalescedUpdate.flush();
1604
1732
  const message = error instanceof Error ? error.message : String(error);
1605
1733
  session.recordInvocation({
1606
1734
  executionEffect: "continued",
@@ -1609,13 +1737,12 @@ export const registerAdvisorTool = (
1609
1737
  model: advisorRef,
1610
1738
  trigger: "executor-requested",
1611
1739
  });
1612
- if (ctx.hasUI) {
1613
- ctx.ui.setStatus("advisor-usage", session.usageStatus());
1614
- }
1740
+ updateAdvisorUsageStatus(ctx, session);
1615
1741
  notifyLocalFailure(ctx, message);
1616
1742
  notifyHerdrAdvisorFailure("Advisor consultation failed", message);
1617
1743
  throw error;
1618
1744
  } finally {
1745
+ coalescedUpdate.cancel();
1619
1746
  herdrAdvisorActivity.finish();
1620
1747
  }
1621
1748
  },
@@ -1692,18 +1819,19 @@ export const registerAdvisorTool = (
1692
1819
  details: { recorded: false },
1693
1820
  };
1694
1821
  }
1695
- const advice = session.claimAdvice(params.adviceId);
1822
+ const advice = session.reserveAdvice(params.adviceId);
1696
1823
  if (!advice) {
1697
- throw new Error("Unknown or already recorded adviceId.");
1824
+ throw new Error("Unknown, already recorded, or pending adviceId.");
1698
1825
  }
1699
1826
  try {
1700
- await appendOutcome({
1827
+ await appendAdvisorOutcome({
1701
1828
  adoption: params.adoption as (typeof ADOPTIONS)[number],
1702
1829
  advice: advice.advice,
1703
1830
  trigger: advice.trigger,
1704
1831
  validationStatus:
1705
1832
  params.validationStatus as (typeof VALIDATIONS)[number],
1706
1833
  });
1834
+ session.commitAdvice(params.adviceId);
1707
1835
  return {
1708
1836
  content: [
1709
1837
  { text: "Advisor outcome recorded locally.", type: "text" },
@@ -1711,6 +1839,7 @@ export const registerAdvisorTool = (
1711
1839
  details: { recorded: true },
1712
1840
  };
1713
1841
  } catch {
1842
+ session.releaseAdvice(params.adviceId);
1714
1843
  if (ctx.hasUI) {
1715
1844
  ctx.ui.notify(
1716
1845
  "Advisor outcome could not be recorded locally.",