pi-advisor-flow 0.2.0 → 0.2.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
@@ -4,10 +4,14 @@ import {
4
4
  stream,
5
5
  } from "@earendil-works/pi-ai/compat";
6
6
  import {
7
+ type AgentToolResult,
7
8
  type ExtensionAPI,
8
9
  type ExtensionContext,
9
10
  getMarkdownTheme,
10
11
  type Theme,
12
+ type ToolCallEvent,
13
+ type ToolCallEventResult,
14
+ type ToolRenderResultOptions,
11
15
  } from "@earendil-works/pi-coding-agent";
12
16
  import { Box, Markdown, Text } from "@earendil-works/pi-tui";
13
17
  import { Type } from "typebox";
@@ -439,6 +443,267 @@ const failureEffect = (
439
443
  return { block: true, effect: "session-blocked" as const, reason };
440
444
  };
441
445
 
446
+ const reserveAdvisorCall = (
447
+ event: ToolCallEvent,
448
+ ctx: ExtensionContext,
449
+ session: AdvisorSessionState,
450
+ reservedCalls: Set<string>
451
+ ): ToolCallEventResult | undefined => {
452
+ if (event.toolName !== "ask_advisor") {
453
+ return;
454
+ }
455
+ if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
456
+ const message = "Advisor call budget exhausted for this session.";
457
+ if (ctx.hasUI) {
458
+ ctx.ui.notify(message, "warning");
459
+ }
460
+ notifyHerdrAdvisorFailure("Advisor budget exhausted", message);
461
+ return { block: true, reason: message };
462
+ }
463
+ session.consumeCall();
464
+ reservedCalls.add(event.toolCallId);
465
+ return {};
466
+ };
467
+
468
+ const sendAutomaticGateCall = (pi: ExtensionAPI, event: ToolCallEvent) => {
469
+ pi.sendMessage(
470
+ {
471
+ content: "Automatic Advisor loop review",
472
+ customType: "advisor-loop-call",
473
+ details: {
474
+ question: `Loop gate: ${event.toolName} repeated ${advisorLoopThresholdRef} times`,
475
+ },
476
+ display: true,
477
+ },
478
+ { deliverAs: "steer" }
479
+ );
480
+ };
481
+
482
+ const sendAutomaticGateFailure = (pi: ExtensionAPI, markdown: string) => {
483
+ pi.sendMessage(
484
+ {
485
+ content: markdown,
486
+ customType: "advisor-loop-result",
487
+ details: { text: markdown },
488
+ display: true,
489
+ },
490
+ { deliverAs: "steer" }
491
+ );
492
+ };
493
+
494
+ const sendAutomaticGateResult = (
495
+ pi: ExtensionAPI,
496
+ result: AdvisorGateResult
497
+ ) => {
498
+ pi.sendMessage(
499
+ {
500
+ content: adviceForText(result),
501
+ customType: "advisor-loop-result",
502
+ details: {
503
+ advisor: result.model,
504
+ decision: result.decision,
505
+ text: result.markdown,
506
+ },
507
+ display: true,
508
+ },
509
+ { deliverAs: "steer" }
510
+ );
511
+ };
512
+
513
+ const handleAutomaticGate = async (
514
+ pi: ExtensionAPI,
515
+ event: ToolCallEvent,
516
+ ctx: ExtensionContext,
517
+ session: AdvisorSessionState
518
+ ): Promise<ToolCallEventResult | undefined> => {
519
+ if (
520
+ event.toolName === "ask_advisor" ||
521
+ !advisorAutoLoopGateRef ||
522
+ !session.recordToolCall(
523
+ event.toolName,
524
+ event.input,
525
+ advisorLoopThresholdRef
526
+ )
527
+ ) {
528
+ return;
529
+ }
530
+ const reason = `Advisor loop gate: normalized signature for ${event.toolName} repeated ${advisorLoopThresholdRef} times without a materially different tool action.`;
531
+ if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
532
+ const failure = failureEffect(
533
+ "budget-exhausted",
534
+ "Advisor gate call budget is exhausted.",
535
+ ctx,
536
+ session
537
+ );
538
+ return failure.block ? { block: true, reason: failure.reason } : undefined;
539
+ }
540
+ session.consumeCall();
541
+ herdrAdvisorActivity.start();
542
+ sendAutomaticGateCall(pi, event);
543
+ try {
544
+ const result = await runAdvisorGate(
545
+ ctx,
546
+ `${reason} Review the repeated actions and recommend the smallest safe next step.`
547
+ );
548
+ if (!result.ok) {
549
+ session.recordInvocation({
550
+ executionEffect: gateFailureEffectForMode(advisorFailureModeRef),
551
+ failure: result.category,
552
+ kind: "gate",
553
+ model: advisorRef,
554
+ trigger: "repeated-tool-call",
555
+ });
556
+ const failure = failureEffect(
557
+ result.category,
558
+ result.message,
559
+ ctx,
560
+ session
561
+ );
562
+ sendAutomaticGateFailure(
563
+ pi,
564
+ `**Advisor gate failure (${result.category}):** ${result.message}`
565
+ );
566
+ return failure.block
567
+ ? { block: true, reason: `${reason}\n${failure.reason}` }
568
+ : undefined;
569
+ }
570
+ session.recordInvocation({
571
+ cost: advisorUsageCost(result.usage),
572
+ decision: result.decision,
573
+ executionEffect: gateDecisionEffect(result.decision),
574
+ kind: "gate",
575
+ model: result.model,
576
+ trigger: result.trigger,
577
+ usage: result.usage,
578
+ });
579
+ sendAutomaticGateResult(pi, result);
580
+ if (result.decision === "proceed") {
581
+ session.resetRepetition();
582
+ return;
583
+ }
584
+ const gateReason = `Advisor loop review: ${result.markdown}`;
585
+ if (result.decision === "blocked") {
586
+ session.block(gateReason);
587
+ herdrAdvisorBlock.set(gateReason);
588
+ if (advisorBlockOnBlockedRef) {
589
+ ctx.abort();
590
+ }
591
+ }
592
+ return { block: true, reason: gateReason };
593
+ } finally {
594
+ herdrAdvisorActivity.finish();
595
+ }
596
+ };
597
+
598
+ interface AdvisorToolDetails {
599
+ advisor?: string;
600
+ question?: string;
601
+ text?: string;
602
+ thinking?: string;
603
+ }
604
+ interface AdvisorRenderState {
605
+ timerId?: ReturnType<typeof setInterval>;
606
+ }
607
+ interface AdvisorToolContext {
608
+ invalidate: () => void;
609
+ lastComponent: unknown;
610
+ state: AdvisorRenderState;
611
+ }
612
+
613
+ const advisorResultDetails = (result: AgentToolResult<AdvisorToolDetails>) =>
614
+ result.details;
615
+
616
+ const renderPartialAdvisorResult = (
617
+ box: Box,
618
+ result: AgentToolResult<AdvisorToolDetails>,
619
+ expanded: boolean,
620
+ theme: Theme,
621
+ context: AdvisorToolContext
622
+ ) => {
623
+ if (!context.state.timerId) {
624
+ context.state.timerId = setInterval(() => context.invalidate(), 80);
625
+ }
626
+ const frame =
627
+ SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
628
+ const details = advisorResultDetails(result);
629
+ const lines = [
630
+ `${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`,
631
+ ];
632
+ if (details?.thinking) {
633
+ const thought =
634
+ details.thinking.length > 200
635
+ ? details.thinking.slice(-200)
636
+ : details.thinking;
637
+ lines.push(theme.fg("thinkingText", ` 💭 ${thought.replace(/\n/g, " ")}`));
638
+ }
639
+ box.addChild(new Text(lines.join("\n"), 0, 0));
640
+ if (details?.text) {
641
+ box.addChild(
642
+ new Markdown(
643
+ adviceForDisplay(details.text, expanded),
644
+ 0,
645
+ 0,
646
+ getMarkdownTheme()
647
+ )
648
+ );
649
+ }
650
+ };
651
+
652
+ const renderFinalAdvisorResult = (
653
+ box: Box,
654
+ result: AgentToolResult<AdvisorToolDetails>,
655
+ expanded: boolean,
656
+ theme: Theme,
657
+ context: AdvisorToolContext
658
+ ) => {
659
+ if (context.state.timerId) {
660
+ clearInterval(context.state.timerId);
661
+ context.state.timerId = undefined;
662
+ }
663
+ const details = advisorResultDetails(result);
664
+ const lines = [theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE"))];
665
+ if (details?.advisor) {
666
+ lines.push(theme.fg("dim", ` ${details.advisor}`));
667
+ }
668
+ if (details?.thinking) {
669
+ const thought = details.thinking.replace(/\n/g, " ").slice(0, 300);
670
+ lines.push(
671
+ theme.fg(
672
+ "thinkingText",
673
+ ` 💭 ${thought}${details.thinking.length > 300 ? "…" : ""}`
674
+ )
675
+ );
676
+ }
677
+ const advice =
678
+ details?.text ||
679
+ textFrom(result.content) ||
680
+ "(Advisor returned no advice.)";
681
+ box.addChild(new Text(lines.join("\n"), 0, 0));
682
+ box.addChild(
683
+ new Markdown(adviceForDisplay(advice, expanded), 0, 0, getMarkdownTheme())
684
+ );
685
+ };
686
+
687
+ const renderAdvisorResult = (
688
+ result: AgentToolResult<AdvisorToolDetails>,
689
+ { isPartial, expanded }: ToolRenderResultOptions,
690
+ theme: Theme,
691
+ context: AdvisorToolContext
692
+ ) => {
693
+ const box =
694
+ context.lastComponent instanceof Box
695
+ ? context.lastComponent
696
+ : new Box(1, 1, (text: string) => theme.bg("customMessageBg", text));
697
+ box.setBgFn((text) => theme.bg("customMessageBg", text));
698
+ box.clear();
699
+ if (isPartial) {
700
+ renderPartialAdvisorResult(box, result, expanded, theme, context);
701
+ } else {
702
+ renderFinalAdvisorResult(box, result, expanded, theme, context);
703
+ }
704
+ return box;
705
+ };
706
+
442
707
  export const registerAdvisorTool = (pi: ExtensionAPI) => {
443
708
  const session = advisorSessionState;
444
709
  const reservedCalls = new Set<string>();
@@ -523,122 +788,22 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
523
788
  : undefined;
524
789
  });
525
790
 
526
- pi.on("tool_call", async (event, ctx) => {
791
+ pi.on("tool_call", (event, ctx) => {
527
792
  if (!pi.getActiveTools().includes("ask_advisor")) {
528
793
  return;
529
794
  }
530
795
  loadConfig(ctx);
531
- if (event.toolName === "ask_advisor") {
532
- if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
533
- const message = "Advisor call budget exhausted for this session.";
534
- if (ctx.hasUI) {
535
- ctx.ui.notify(message, "warning");
536
- }
537
- notifyHerdrAdvisorFailure("Advisor budget exhausted", message);
538
- return { block: true, reason: message };
539
- }
540
- session.consumeCall();
541
- reservedCalls.add(event.toolCallId);
542
- return;
543
- }
544
- if (!advisorAutoLoopGateRef) {
545
- return;
546
- }
547
- if (
548
- !session.recordToolCall(
549
- event.toolName,
550
- event.input,
551
- advisorLoopThresholdRef
552
- )
553
- ) {
554
- return;
555
- }
556
- const reason = `Advisor loop gate: normalized signature for ${event.toolName} repeated ${advisorLoopThresholdRef} times without a materially different tool action.`;
557
- if (!session.canConsult(advisorMaxCallsPerSessionRef)) {
558
- const failure = failureEffect(
559
- "budget-exhausted",
560
- "Advisor gate call budget is exhausted.",
561
- ctx,
562
- session
563
- );
564
- return failure.block
565
- ? { block: true, reason: failure.reason }
566
- : undefined;
796
+ if (session.blocked) {
797
+ return {
798
+ block: true,
799
+ reason: session.blockedReason ?? "Advisor session is blocked.",
800
+ };
567
801
  }
568
- session.consumeCall();
569
- herdrAdvisorActivity.start();
570
- pi.sendMessage(
571
- {
572
- content: "Automatic Advisor loop review",
573
- customType: "advisor-loop-call",
574
- details: {
575
- question: `Loop gate: ${event.toolName} repeated ${advisorLoopThresholdRef} times`,
576
- },
577
- display: true,
578
- },
579
- { deliverAs: "steer" }
580
- );
581
- try {
582
- const result = await runAdvisorGate(
583
- ctx,
584
- `${reason} Review the repeated actions and recommend the smallest safe next step.`
585
- );
586
- if (!result.ok) {
587
- session.recordInvocation({
588
- executionEffect: gateFailureEffectForMode(advisorFailureModeRef),
589
- failure: result.category,
590
- kind: "gate",
591
- model: advisorRef,
592
- trigger: "repeated-tool-call",
593
- });
594
- const failure = failureEffect(
595
- result.category,
596
- result.message,
597
- ctx,
598
- session
599
- );
600
- return failure.block
601
- ? { block: true, reason: `${reason}\n${failure.reason}` }
602
- : undefined;
603
- }
604
- session.recordInvocation({
605
- cost: advisorUsageCost(result.usage),
606
- decision: result.decision,
607
- executionEffect: gateDecisionEffect(result.decision),
608
- kind: "gate",
609
- model: result.model,
610
- trigger: result.trigger,
611
- usage: result.usage,
612
- });
613
- pi.sendMessage(
614
- {
615
- content: adviceForText(result),
616
- customType: "advisor-loop-result",
617
- details: {
618
- advisor: result.model,
619
- decision: result.decision,
620
- text: result.markdown,
621
- },
622
- display: true,
623
- },
624
- { deliverAs: "steer" }
625
- );
626
- if (result.decision === "proceed") {
627
- session.resetRepetition();
628
- return;
629
- }
630
- const gateReason = `Advisor loop review: ${result.markdown}`;
631
- if (result.decision === "blocked") {
632
- session.block(gateReason);
633
- herdrAdvisorBlock.set(gateReason);
634
- if (advisorBlockOnBlockedRef) {
635
- ctx.abort();
636
- }
637
- }
638
- return { block: true, reason: gateReason };
639
- } finally {
640
- herdrAdvisorActivity.finish();
802
+ const reservation = reserveAdvisorCall(event, ctx, session, reservedCalls);
803
+ if (event.toolName === "ask_advisor") {
804
+ return reservation;
641
805
  }
806
+ return handleAutomaticGate(pi, event, ctx, session);
642
807
  });
643
808
 
644
809
  pi.on("agent_settled", (_event, ctx) => {
@@ -739,81 +904,13 @@ export const registerAdvisorTool = (pi: ExtensionAPI) => {
739
904
  renderCall(args, theme) {
740
905
  return renderAdvisorCallBox(args.question?.trim(), theme);
741
906
  },
742
- renderResult(result, { isPartial, expanded }, theme, context) {
743
- const box =
744
- context.lastComponent instanceof Box
745
- ? context.lastComponent
746
- : new Box(1, 1, (text) => theme.bg("customMessageBg", text));
747
- box.setBgFn((text) => theme.bg("customMessageBg", text));
748
- box.clear();
749
- if (isPartial) {
750
- if (!context.state.timerId) {
751
- context.state.timerId = setInterval(() => context.invalidate(), 80);
752
- }
753
- const frame =
754
- SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length];
755
- const d = result.details as
756
- | { thinking?: string; text?: string }
757
- | undefined;
758
- const lines: string[] = [
759
- `${theme.fg("warning", theme.bold(`◆ ADVISOR ${frame}`))} ${theme.fg("dim", "· Working…")}`,
760
- ];
761
- if (d?.thinking) {
762
- lines.push(
763
- theme.fg(
764
- "thinkingText",
765
- ` 💭 ${(d.thinking.length > 200 ? d.thinking.slice(-200) : d.thinking).replace(/\n/g, " ")}`
766
- )
767
- );
768
- }
769
- box.addChild(new Text(lines.join("\n"), 0, 0));
770
- if (d?.text) {
771
- box.addChild(
772
- new Markdown(
773
- adviceForDisplay(d.text, Boolean(expanded)),
774
- 0,
775
- 0,
776
- getMarkdownTheme()
777
- )
778
- );
779
- }
780
- } else {
781
- if (context.state.timerId) {
782
- clearInterval(context.state.timerId);
783
- context.state.timerId = undefined;
784
- }
785
- const d = result.details as
786
- | { thinking?: string; text?: string; advisor?: string }
787
- | undefined;
788
- const lines: string[] = [
789
- theme.fg("warning", theme.bold("◆ ADVISOR RESPONSE")),
790
- ];
791
- if (d?.advisor) {
792
- lines.push(theme.fg("dim", ` ${d.advisor}`));
793
- }
794
- if (d?.thinking) {
795
- lines.push(
796
- theme.fg(
797
- "thinkingText",
798
- ` 💭 ${d.thinking.replace(/\n/g, " ").slice(0, 300)}${d.thinking.length > 300 ? "…" : ""}`
799
- )
800
- );
801
- }
802
- const advice =
803
- d?.text ||
804
- textFrom(result.content) ||
805
- "(Advisor returned no advice.)";
806
- box.addChild(new Text(lines.join("\n"), 0, 0));
807
- box.addChild(
808
- new Markdown(
809
- adviceForDisplay(advice, Boolean(expanded)),
810
- 0,
811
- 0,
812
- getMarkdownTheme()
813
- )
814
- );
815
- }
816
- return box;
907
+ renderResult(result, options, theme, context) {
908
+ return renderAdvisorResult(
909
+ result as AgentToolResult<AdvisorToolDetails>,
910
+ options,
911
+ theme,
912
+ context as AdvisorToolContext
913
+ );
817
914
  },
818
915
  renderShell: "self",
819
916
  });