pi-advisor-flow 0.6.0 → 0.8.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.
- package/CHANGELOG.md +34 -17
- package/README.md +10 -8
- package/dist/index.js +2852 -868
- package/extensions/index.ts +2 -1
- package/package.json +55 -40
- package/src/attachments.ts +11 -10
- package/src/commands/activation-preparation.ts +1 -0
- package/src/commands/activation.ts +2 -0
- package/src/commands/lifecycle.ts +1 -0
- package/src/commands/manual-command.ts +7 -1
- package/src/commands/manual-consultation.ts +103 -79
- package/src/commands/manual-progress.ts +7 -5
- package/src/commands/model-commands.ts +1 -1
- package/src/commands/model-options.ts +14 -3
- package/src/commands/model-picker.ts +1 -0
- package/src/commands/registration.ts +1 -0
- package/src/commands/renderers.ts +67 -55
- package/src/commands/runtime.ts +16 -14
- package/src/commands/settings-commands.ts +10 -4
- package/src/commands/settings-persistence.ts +64 -2
- package/src/commands/types.ts +2 -0
- package/src/commands.ts +0 -1
- package/src/config/args.ts +1 -1
- package/src/config/defaults.ts +83 -1
- package/src/config/schema.ts +152 -15
- package/src/config/state.ts +80 -2
- package/src/config/storage.ts +38 -22
- package/src/config/types.ts +45 -3
- package/src/config/validation.ts +33 -21
- package/src/config.ts +3 -0
- package/src/content-utils.ts +24 -4
- package/src/conversation.ts +16 -15
- package/src/git.ts +10 -3
- package/src/herdr-block.ts +83 -0
- package/src/herdr-shared.ts +108 -0
- package/src/herdr.ts +30 -192
- package/src/jev/client.ts +338 -0
- package/src/jev/failure.ts +19 -0
- package/src/jev/key-store.ts +267 -0
- package/src/jev/ledger.ts +228 -0
- package/src/jev/questions.ts +122 -0
- package/src/jev/state.ts +50 -0
- package/src/jev/transport.ts +63 -0
- package/src/model-stream.ts +35 -37
- package/src/outcomes.ts +20 -12
- package/src/pi-settings.ts +4 -9
- package/src/preferences.ts +4 -2
- package/src/redaction.ts +10 -12
- package/src/scout-context.ts +59 -60
- package/src/scout-curation.ts +4 -13
- package/src/scout-groups.ts +178 -175
- package/src/scout-protocol.ts +74 -70
- package/src/scout-reconstruct.ts +1 -1
- package/src/scout.ts +115 -73
- package/src/session-state.ts +148 -39
- package/src/tool-result-cap.ts +18 -17
- package/src/tools/consult-context.ts +7 -15
- package/src/tools/consultation.ts +14 -4
- package/src/tools/gate-policy.ts +1 -0
- package/src/tools/gate-protocol.ts +36 -23
- package/src/tools/jev-filter.ts +204 -0
- package/src/tools/jev-turn-gate.ts +203 -0
- package/src/tools/loop-gate.ts +32 -20
- package/src/tools/model-access.ts +46 -0
- package/src/tools/outage-notifier.ts +30 -0
- package/src/tools/prompts.ts +20 -25
- package/src/tools/register-ask-advisor.ts +73 -17
- package/src/tools/register-lifecycle.ts +22 -0
- package/src/tools/register-outcome.ts +2 -0
- package/src/tools/register-renderers.ts +149 -73
- package/src/tools/registration.ts +15 -0
- package/src/tools/render-advisor-result.ts +77 -35
- package/src/tools/render-common.ts +16 -19
- package/src/tools/scout-status.ts +42 -27
- package/src/tools/types.ts +11 -0
- package/src/tools.ts +0 -1
- package/src/ui/jev-setup-submenu.ts +350 -0
- package/src/ui/manual-dialog-render.ts +1 -0
- package/src/ui/manual-dialog.ts +39 -43
- package/src/ui/masked-input.ts +62 -0
- package/src/ui/model-multi-selector.ts +9 -0
- package/src/ui/model-selector-adapter.ts +31 -0
- package/src/ui/model-selector.ts +14 -161
- package/src/ui/searchable-model-list.ts +230 -0
- package/src/ui/settings-formatting.ts +24 -14
- package/src/ui/settings-items.ts +200 -2
- package/src/ui/settings-list-adapter.ts +15 -12
- package/src/ui/settings-mutations.ts +169 -47
- package/src/ui/settings-selector.ts +18 -9
- package/src/ui/text-setting-submenu.ts +3 -6
- package/src/ui/types.ts +25 -0
- package/src/ui.ts +0 -1
- package/src/usage.ts +29 -20
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
2
3
|
import type { Message } from "@earendil-works/pi-ai/compat";
|
|
3
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
4
6
|
import {
|
|
5
7
|
advisorEffortRef,
|
|
6
8
|
advisorRedactSecretsRef,
|
|
@@ -14,6 +16,7 @@ import type { ScoutLifecycleEvent } from "../scout.ts";
|
|
|
14
16
|
import type { ConsultationTrigger, GateTrigger } from "../session-state.ts";
|
|
15
17
|
import { assembleConsultationContext } from "./consult-context.ts";
|
|
16
18
|
import { parseAutomaticDecision } from "./gate-protocol.ts";
|
|
19
|
+
import { advisorModelAccessReason } from "./model-access.ts";
|
|
17
20
|
import {
|
|
18
21
|
ADVISOR_DECISION_SYSTEM,
|
|
19
22
|
ADVISOR_SYSTEM,
|
|
@@ -21,7 +24,7 @@ import {
|
|
|
21
24
|
} from "./prompts.ts";
|
|
22
25
|
import type { AdvisorConsultationResult, AdvisorGateOutcome } from "./types.ts";
|
|
23
26
|
|
|
24
|
-
//
|
|
27
|
+
// Re-export preserves the tools facade's historical curation entry point.
|
|
25
28
|
export { curateAdvisorConversation } from "../scout-curation.ts";
|
|
26
29
|
|
|
27
30
|
/** Thrown when the Advisor produced an empty response body. */
|
|
@@ -54,6 +57,10 @@ const collectAdvisorResponse = async (
|
|
|
54
57
|
) => {
|
|
55
58
|
const { ctx, question, signal, systemPrompt } = options;
|
|
56
59
|
loadConfig(ctx);
|
|
60
|
+
const accessReason = advisorModelAccessReason(ctx);
|
|
61
|
+
if (accessReason) {
|
|
62
|
+
throw new Error(accessReason);
|
|
63
|
+
}
|
|
57
64
|
const resolved = await resolveConfiguredModel(ctx, advisorRef, "Advisor");
|
|
58
65
|
const context = await assembleConsultationContext(options);
|
|
59
66
|
|
|
@@ -95,9 +102,9 @@ const collectAdvisorResponse = async (
|
|
|
95
102
|
if (!markdown.trim()) {
|
|
96
103
|
throw new AdvisorNoAdviceError();
|
|
97
104
|
}
|
|
98
|
-
|
|
105
|
+
const response: Omit<AdvisorConsultationResult, "adviceId" | "trigger"> = {
|
|
99
106
|
draftBytes: context.draftText
|
|
100
|
-
? Buffer.byteLength(context.draftText, "
|
|
107
|
+
? Buffer.byteLength(context.draftText, "utf-8")
|
|
101
108
|
: undefined,
|
|
102
109
|
markdown,
|
|
103
110
|
model: advisorRef,
|
|
@@ -108,8 +115,11 @@ const collectAdvisorResponse = async (
|
|
|
108
115
|
untrackedBytes:
|
|
109
116
|
context.untracked.reduce((sum, item) => sum + item.bytes, 0) || undefined,
|
|
110
117
|
usage: streamed.usage,
|
|
111
|
-
...(context.scout ? { scout: context.scout } : {}),
|
|
112
118
|
};
|
|
119
|
+
if (context.scout) {
|
|
120
|
+
response.scout = context.scout;
|
|
121
|
+
}
|
|
122
|
+
return response;
|
|
113
123
|
};
|
|
114
124
|
|
|
115
125
|
export const consultAdvisor = async (
|
package/src/tools/gate-policy.ts
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
import type { GateDecision } from "../session-state.ts";
|
|
2
2
|
import type { AdvisorGateFailure, AdvisorGateResult } from "./types.ts";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
4
|
+
/** Named-group accessor; exec matches of these regexes always populate groups. */
|
|
5
|
+
const namedGroups = (match: RegExpExecArray): Record<string, string> =>
|
|
6
|
+
match.groups ?? {};
|
|
7
|
+
|
|
8
|
+
const DECISION_LINE =
|
|
9
|
+
/^Decision\s*:\s*(?<decision>proceed|revise|blocked)\s*$/iu;
|
|
10
|
+
const CODE_FENCE = /^ {0,3}(?<marker>`{3,}|~{3,})(?<suffix>.*)$/u;
|
|
11
|
+
const LINE_BREAK = /\r?\n/u;
|
|
7
12
|
|
|
8
13
|
interface FenceState {
|
|
9
14
|
character: string;
|
|
10
15
|
length: number;
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
interface FenceAdvance {
|
|
19
|
+
closed: boolean;
|
|
20
|
+
openingFence: FenceState | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
const advanceFence = (
|
|
14
24
|
openingFence: FenceState | undefined,
|
|
15
25
|
marker: string,
|
|
16
26
|
suffix: string
|
|
17
|
-
):
|
|
27
|
+
): FenceAdvance => {
|
|
18
28
|
if (!openingFence) {
|
|
19
29
|
if (marker[0] === "`" && suffix.includes("`")) {
|
|
20
30
|
return { closed: false, openingFence: undefined };
|
|
@@ -59,7 +69,8 @@ export const parseAutomaticDecision = (
|
|
|
59
69
|
ok: false,
|
|
60
70
|
};
|
|
61
71
|
}
|
|
62
|
-
|
|
72
|
+
// SAFETY: DECISION_LINE's decision group only matches proceed, revise, or blocked.
|
|
73
|
+
const decision = namedGroups(match).decision.toLowerCase() as GateDecision;
|
|
63
74
|
let openingFence: FenceState | undefined;
|
|
64
75
|
const decisions: string[] = [];
|
|
65
76
|
let pendingFencedDecisions: string[] = [];
|
|
@@ -72,10 +83,11 @@ export const parseAutomaticDecision = (
|
|
|
72
83
|
// open.
|
|
73
84
|
const fence = CODE_FENCE.exec(line);
|
|
74
85
|
if (fence) {
|
|
86
|
+
const groups = namedGroups(fence);
|
|
75
87
|
const { closed, openingFence: nextOpeningFence } = advanceFence(
|
|
76
88
|
openingFence,
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
groups.marker,
|
|
90
|
+
groups.suffix
|
|
79
91
|
);
|
|
80
92
|
openingFence = nextOpeningFence;
|
|
81
93
|
if (closed) {
|
|
@@ -87,7 +99,7 @@ export const parseAutomaticDecision = (
|
|
|
87
99
|
if (!subsequent) {
|
|
88
100
|
continue;
|
|
89
101
|
}
|
|
90
|
-
const repeated = subsequent
|
|
102
|
+
const repeated = namedGroups(subsequent).decision.trim().toLowerCase();
|
|
91
103
|
if (openingFence) {
|
|
92
104
|
pendingFencedDecisions.push(repeated);
|
|
93
105
|
} else {
|
|
@@ -97,21 +109,22 @@ export const parseAutomaticDecision = (
|
|
|
97
109
|
if (openingFence) {
|
|
98
110
|
decisions.push(...pendingFencedDecisions);
|
|
99
111
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
const [repeated] = decisions;
|
|
113
|
+
if (repeated !== undefined) {
|
|
114
|
+
return repeated === decision
|
|
115
|
+
? {
|
|
116
|
+
category: "duplicate-decision",
|
|
117
|
+
markdown: text,
|
|
118
|
+
message: "Advisor gate response contains duplicate decision lines.",
|
|
119
|
+
ok: false,
|
|
120
|
+
}
|
|
121
|
+
: {
|
|
122
|
+
category: "contradictory-decision",
|
|
123
|
+
markdown: text,
|
|
124
|
+
message:
|
|
125
|
+
"Advisor gate response contains contradictory decision lines.",
|
|
126
|
+
ok: false,
|
|
127
|
+
};
|
|
115
128
|
}
|
|
116
129
|
return {
|
|
117
130
|
decision,
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Fetch } from "@typesafe-ai/sdk";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
advisorJevFilterEnabledRef,
|
|
6
|
+
advisorJevFilterNoulMarginRef,
|
|
7
|
+
advisorJevFilterOverrideWindowRef,
|
|
8
|
+
advisorJevFilterSkipConfidenceRef,
|
|
9
|
+
isSimpleMode,
|
|
10
|
+
} from "../config/state.ts";
|
|
11
|
+
import { JevFailure, jevClientFromCredentials } from "../jev/client.ts";
|
|
12
|
+
import { consumePlaintextKeyWarning } from "../jev/key-store.ts";
|
|
13
|
+
import {
|
|
14
|
+
composeScreeningVerdict,
|
|
15
|
+
screeningQuestions,
|
|
16
|
+
} from "../jev/questions.ts";
|
|
17
|
+
import { buildJevState } from "../jev/state.ts";
|
|
18
|
+
import { resolveJevTransport } from "../jev/transport.ts";
|
|
19
|
+
import type { JevCredentials } from "../jev/transport.ts";
|
|
20
|
+
import type { AdvisorSessionState } from "../session-state.ts";
|
|
21
|
+
import { createOutageNotifier } from "./outage-notifier.ts";
|
|
22
|
+
|
|
23
|
+
export type JevSkipKind = "screened" | "repeat";
|
|
24
|
+
|
|
25
|
+
export type ScreeningOutcome =
|
|
26
|
+
| { decision: "allow" }
|
|
27
|
+
| {
|
|
28
|
+
decision: "skip";
|
|
29
|
+
kind: JevSkipKind;
|
|
30
|
+
reason: string;
|
|
31
|
+
reattachedAdvice?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface ScreenConsultationOptions {
|
|
35
|
+
draft?: string;
|
|
36
|
+
force?: boolean;
|
|
37
|
+
question?: string;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ScreeningDeps {
|
|
42
|
+
fetch?: Fetch;
|
|
43
|
+
resolveTransport?: () => Promise<JevCredentials | undefined>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const normalizeScreeningQuestion = (question: string | undefined) =>
|
|
47
|
+
question?.trim().toLowerCase().replaceAll(/\s+/gu, " ") || undefined;
|
|
48
|
+
|
|
49
|
+
const REATTACHED_ADVICE_CAP_BYTES = 4 * 1024;
|
|
50
|
+
|
|
51
|
+
const SCREENED_SKIP_TEXT =
|
|
52
|
+
"Advisor consultation skipped (screened out): the stakes are low and you can resolve this yourself with available tools and context. Proceed on your own judgment with what you already have.";
|
|
53
|
+
|
|
54
|
+
const repeatSkipText = (advice: string) =>
|
|
55
|
+
`Advisor consultation skipped (already answered): this question was answered earlier in this session; the earlier advice is reattached below. Consult again only if the situation has materially changed.\n\n${advice}`;
|
|
56
|
+
|
|
57
|
+
const outageNotifier = createOutageNotifier(
|
|
58
|
+
(category, message) =>
|
|
59
|
+
`Advisor Jev filter failed (${category}); allowing consultations. ${message}`
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const notifyOutageOnce = outageNotifier.notify;
|
|
63
|
+
|
|
64
|
+
/** Test-only: re-arms the once-per-outage notification. */
|
|
65
|
+
export const resetJevOutageNotification = outageNotifier.reset;
|
|
66
|
+
|
|
67
|
+
const allow = (): ScreeningOutcome => ({ decision: "allow" });
|
|
68
|
+
|
|
69
|
+
const bypassOutcome = (
|
|
70
|
+
session: AdvisorSessionState,
|
|
71
|
+
options: ScreenConsultationOptions,
|
|
72
|
+
normalizedQuestion: string | undefined
|
|
73
|
+
): ScreeningOutcome | undefined => {
|
|
74
|
+
const lastSkip = session.lastJevSkip;
|
|
75
|
+
if (options.force) {
|
|
76
|
+
if (
|
|
77
|
+
lastSkip?.normalizedQuestion !== undefined &&
|
|
78
|
+
lastSkip.normalizedQuestion === normalizedQuestion
|
|
79
|
+
) {
|
|
80
|
+
session.recordJevFilterOverride();
|
|
81
|
+
}
|
|
82
|
+
return allow();
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
normalizedQuestion !== undefined &&
|
|
86
|
+
lastSkip?.normalizedQuestion === normalizedQuestion &&
|
|
87
|
+
session.sessionTurnOrdinal - lastSkip.turn <=
|
|
88
|
+
advisorJevFilterOverrideWindowRef
|
|
89
|
+
) {
|
|
90
|
+
session.recordJevFilterOverride();
|
|
91
|
+
return allow();
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const screenWithJev = async (
|
|
97
|
+
ctx: ExtensionContext,
|
|
98
|
+
session: AdvisorSessionState,
|
|
99
|
+
options: ScreenConsultationOptions,
|
|
100
|
+
deps: ScreeningDeps,
|
|
101
|
+
normalizedQuestion: string | undefined
|
|
102
|
+
): Promise<ScreeningOutcome> => {
|
|
103
|
+
const credentials = await (deps.resolveTransport ?? resolveJevTransport)(ctx);
|
|
104
|
+
if (!credentials) {
|
|
105
|
+
session.recordJevFilterFailure();
|
|
106
|
+
notifyOutageOnce(
|
|
107
|
+
ctx,
|
|
108
|
+
"missing-key",
|
|
109
|
+
"No Jev credentials resolved (no TypeSafe key and no OpenRouter login)."
|
|
110
|
+
);
|
|
111
|
+
return allow();
|
|
112
|
+
}
|
|
113
|
+
if (credentials.source === "advisor-json") {
|
|
114
|
+
const warning = consumePlaintextKeyWarning();
|
|
115
|
+
if (warning && ctx.hasUI) {
|
|
116
|
+
ctx.ui.notify(warning, "warning");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const client = jevClientFromCredentials(credentials, deps.fetch);
|
|
121
|
+
try {
|
|
122
|
+
const result = await client.ask(
|
|
123
|
+
buildJevState(ctx, options),
|
|
124
|
+
screeningQuestions,
|
|
125
|
+
options.signal
|
|
126
|
+
);
|
|
127
|
+
session.recordJevFilterUsage(result.usage);
|
|
128
|
+
const verdict = composeScreeningVerdict(result.answers, {
|
|
129
|
+
noulMargin: advisorJevFilterNoulMarginRef,
|
|
130
|
+
skipConfidence: advisorJevFilterSkipConfidenceRef,
|
|
131
|
+
});
|
|
132
|
+
if (verdict.skip) {
|
|
133
|
+
session.recordJevFilterSkipped(false, normalizedQuestion);
|
|
134
|
+
return {
|
|
135
|
+
decision: "skip",
|
|
136
|
+
kind: "screened",
|
|
137
|
+
reason: "low stakes and resolvable without a consultation",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
session.recordJevFilterAllowed();
|
|
141
|
+
return allow();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
session.recordJevFilterFailure();
|
|
144
|
+
if (error instanceof JevFailure) {
|
|
145
|
+
notifyOutageOnce(ctx, error.category, error.message);
|
|
146
|
+
} else if (options.signal?.aborted) {
|
|
147
|
+
throw error;
|
|
148
|
+
} else {
|
|
149
|
+
notifyOutageOnce(
|
|
150
|
+
ctx,
|
|
151
|
+
"error",
|
|
152
|
+
error instanceof Error ? error.message : String(error)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return allow();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Fail-open screening: decides run/skip for one ask_advisor call. Disabled or
|
|
161
|
+
* simple mode, missing credentials, and every Jev failure allow the
|
|
162
|
+
* consultation; only the hard conjunction (negligible-mass ≥ skip confidence
|
|
163
|
+
* AND confidently self-answerable) or an exact repeat skips.
|
|
164
|
+
*/
|
|
165
|
+
export const screenConsultation = (
|
|
166
|
+
ctx: ExtensionContext,
|
|
167
|
+
session: AdvisorSessionState,
|
|
168
|
+
options: ScreenConsultationOptions,
|
|
169
|
+
deps: ScreeningDeps = {}
|
|
170
|
+
): Promise<ScreeningOutcome> => {
|
|
171
|
+
if (isSimpleMode()) {
|
|
172
|
+
return Promise.resolve(allow());
|
|
173
|
+
}
|
|
174
|
+
// Repeat reattachment and its force/passthrough escape hatch are code-side
|
|
175
|
+
// and free: they run regardless of the Jev enable flag, which only gates
|
|
176
|
+
// the (paid, network) Jev screening call.
|
|
177
|
+
const normalizedQuestion = normalizeScreeningQuestion(options.question);
|
|
178
|
+
const bypass = bypassOutcome(session, options, normalizedQuestion);
|
|
179
|
+
if (bypass) {
|
|
180
|
+
return Promise.resolve(bypass);
|
|
181
|
+
}
|
|
182
|
+
const reattached = session.reattachedAdviceFor(normalizedQuestion);
|
|
183
|
+
if (reattached) {
|
|
184
|
+
session.recordJevFilterSkipped(true, normalizedQuestion);
|
|
185
|
+
return Promise.resolve({
|
|
186
|
+
decision: "skip",
|
|
187
|
+
kind: "repeat",
|
|
188
|
+
reason: "already answered earlier in this session",
|
|
189
|
+
reattachedAdvice: reattached.slice(0, REATTACHED_ADVICE_CAP_BYTES),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (!advisorJevFilterEnabledRef) {
|
|
193
|
+
return Promise.resolve(allow());
|
|
194
|
+
}
|
|
195
|
+
return screenWithJev(ctx, session, options, deps, normalizedQuestion);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const screeningSkipText = (outcome: {
|
|
199
|
+
kind: JevSkipKind;
|
|
200
|
+
reattachedAdvice?: string;
|
|
201
|
+
}) =>
|
|
202
|
+
outcome.kind === "repeat" && outcome.reattachedAdvice
|
|
203
|
+
? repeatSkipText(outcome.reattachedAdvice)
|
|
204
|
+
: SCREENED_SKIP_TEXT;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionContext,
|
|
3
|
+
TurnEndEvent,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Fetch } from "@typesafe-ai/sdk";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
advisorJevTurnGateEveryTurnsRef,
|
|
9
|
+
advisorJevTurnGateNoulThresholdRef,
|
|
10
|
+
getAdvisorMaxCallsPerSession,
|
|
11
|
+
isSimpleMode,
|
|
12
|
+
} from "../config/state.ts";
|
|
13
|
+
import { herdrAdvisorActivity } from "../herdr.ts";
|
|
14
|
+
import { JevFailure, jevClientFromCredentials } from "../jev/client.ts";
|
|
15
|
+
import { composeTurnGateVerdict } from "../jev/questions.ts";
|
|
16
|
+
import { buildJevState } from "../jev/state.ts";
|
|
17
|
+
import { resolveJevTransport } from "../jev/transport.ts";
|
|
18
|
+
import type { JevCredentials } from "../jev/transport.ts";
|
|
19
|
+
import type { AdvisorSessionState } from "../session-state.ts";
|
|
20
|
+
import { advisorUsageCost } from "../usage.ts";
|
|
21
|
+
import type { consultAdvisor } from "./consultation.ts";
|
|
22
|
+
import { updateAdvisorUsageStatus } from "./gate-policy.ts";
|
|
23
|
+
import { advisorModelIsAllowed } from "./model-access.ts";
|
|
24
|
+
import { createOutageNotifier } from "./outage-notifier.ts";
|
|
25
|
+
|
|
26
|
+
export const turnGateQuestion = {
|
|
27
|
+
criteria: {
|
|
28
|
+
false:
|
|
29
|
+
"The executor is progressing soundly on work that matches the user's intent; interrupting would add nothing material.",
|
|
30
|
+
true: "The executor is approaching a material decision, repeating a failure, about to claim success without validation, or drifting from the user's intent; a second opinion now would change what happens next.",
|
|
31
|
+
},
|
|
32
|
+
instructions:
|
|
33
|
+
"Should a senior engineering advisor be consulted right now, before the executor continues? Judge from `recent_conversation`.",
|
|
34
|
+
type: "noul",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
export interface JevTurnGateDeps {
|
|
38
|
+
consult?: typeof consultAdvisor;
|
|
39
|
+
fetch?: Fetch;
|
|
40
|
+
resolveTransport?: () => Promise<JevCredentials | undefined>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TurnGateCallDetails {
|
|
44
|
+
question: string;
|
|
45
|
+
turn: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TurnGateResultDetails {
|
|
49
|
+
advisor: string;
|
|
50
|
+
text: string;
|
|
51
|
+
usage?: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface JevTurnGateRegistration {
|
|
55
|
+
activeTools: () => string[];
|
|
56
|
+
consult: typeof consultAdvisor;
|
|
57
|
+
deps?: JevTurnGateDeps;
|
|
58
|
+
send: (message: {
|
|
59
|
+
content: string;
|
|
60
|
+
customType: string;
|
|
61
|
+
details: TurnGateCallDetails | TurnGateResultDetails;
|
|
62
|
+
display: boolean;
|
|
63
|
+
}) => void;
|
|
64
|
+
session: AdvisorSessionState;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const outageNotifier = createOutageNotifier(
|
|
68
|
+
(category, message) =>
|
|
69
|
+
`Advisor Jev turn gate failed (${category}); continuing without a proactive consultation. ${message}`
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const notifyFailureOnce = outageNotifier.notify;
|
|
73
|
+
|
|
74
|
+
/** Test-only: re-arms the once-per-outage notification. */
|
|
75
|
+
export const resetJevTurnGateNotification = outageNotifier.reset;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* turn_end handler: counts turns without a consultation and, every Nth such
|
|
79
|
+
* turn, asks Jev whether the Advisor should proactively weigh in. Confident
|
|
80
|
+
* true runs a real consultation delivered as a steer message; everything else
|
|
81
|
+
* keeps the status quo.
|
|
82
|
+
*/
|
|
83
|
+
export const handleJevTurnEnd = async (
|
|
84
|
+
registration: JevTurnGateRegistration,
|
|
85
|
+
ctx: ExtensionContext
|
|
86
|
+
): Promise<void> => {
|
|
87
|
+
const { session } = registration;
|
|
88
|
+
session.recordCompletedTurn();
|
|
89
|
+
const interval = advisorJevTurnGateEveryTurnsRef;
|
|
90
|
+
if (
|
|
91
|
+
interval <= 0 ||
|
|
92
|
+
session.turnsSinceConsultation <= 0 ||
|
|
93
|
+
session.turnsSinceConsultation % interval !== 0
|
|
94
|
+
) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
isSimpleMode() ||
|
|
99
|
+
session.blocked ||
|
|
100
|
+
!registration.activeTools().includes("ask_advisor") ||
|
|
101
|
+
!advisorModelIsAllowed(ctx) ||
|
|
102
|
+
!session.canConsult(getAdvisorMaxCallsPerSession())
|
|
103
|
+
) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const consult = registration.deps?.consult ?? registration.consult;
|
|
108
|
+
const deps = registration.deps ?? {};
|
|
109
|
+
try {
|
|
110
|
+
const credentials = await (deps.resolveTransport ?? resolveJevTransport)(
|
|
111
|
+
ctx
|
|
112
|
+
);
|
|
113
|
+
if (!credentials) {
|
|
114
|
+
session.recordJevGateFailure();
|
|
115
|
+
notifyFailureOnce(
|
|
116
|
+
ctx,
|
|
117
|
+
"missing-key",
|
|
118
|
+
"No Jev credentials resolved (no TypeSafe key and no OpenRouter login)."
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const client = jevClientFromCredentials(credentials, deps.fetch);
|
|
123
|
+
const result = await client.ask(buildJevState(ctx, {}), {
|
|
124
|
+
should_consult: turnGateQuestion,
|
|
125
|
+
});
|
|
126
|
+
session.recordJevGateCheck(result.usage);
|
|
127
|
+
const shouldConsult = composeTurnGateVerdict(
|
|
128
|
+
result.answers,
|
|
129
|
+
advisorJevTurnGateNoulThresholdRef
|
|
130
|
+
);
|
|
131
|
+
if (!shouldConsult) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
session.consumeCall();
|
|
136
|
+
herdrAdvisorActivity.start();
|
|
137
|
+
registration.send({
|
|
138
|
+
content: "Proactive Advisor turn review",
|
|
139
|
+
customType: "advisor-turn-gate-call",
|
|
140
|
+
details: {
|
|
141
|
+
question: `Turn gate: ${session.turnsSinceConsultation} turns without a consultation`,
|
|
142
|
+
turn: session.sessionTurnOrdinal,
|
|
143
|
+
},
|
|
144
|
+
display: true,
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
const consulted = await consult(
|
|
148
|
+
ctx,
|
|
149
|
+
undefined,
|
|
150
|
+
ctx.signal,
|
|
151
|
+
undefined,
|
|
152
|
+
"turn-gate"
|
|
153
|
+
);
|
|
154
|
+
session.recordJevGateConsultation();
|
|
155
|
+
session.resetTurnsSinceConsultation();
|
|
156
|
+
session.recordInvocation({
|
|
157
|
+
cost: advisorUsageCost(consulted.usage),
|
|
158
|
+
executionEffect: "continued",
|
|
159
|
+
kind: "markdown",
|
|
160
|
+
model: consulted.model,
|
|
161
|
+
trigger: "turn-gate",
|
|
162
|
+
usage: consulted.usage,
|
|
163
|
+
});
|
|
164
|
+
updateAdvisorUsageStatus(ctx, session);
|
|
165
|
+
registration.send({
|
|
166
|
+
content: consulted.markdown,
|
|
167
|
+
customType: "advisor-turn-gate-result",
|
|
168
|
+
details: {
|
|
169
|
+
advisor: consulted.model,
|
|
170
|
+
text: consulted.markdown,
|
|
171
|
+
usage: consulted.usage,
|
|
172
|
+
},
|
|
173
|
+
display: true,
|
|
174
|
+
});
|
|
175
|
+
} finally {
|
|
176
|
+
herdrAdvisorActivity.finish();
|
|
177
|
+
}
|
|
178
|
+
} catch (error) {
|
|
179
|
+
session.recordJevGateFailure();
|
|
180
|
+
if (error instanceof JevFailure) {
|
|
181
|
+
notifyFailureOnce(ctx, error.category, error.message);
|
|
182
|
+
} else if (!ctx.signal?.aborted) {
|
|
183
|
+
notifyFailureOnce(
|
|
184
|
+
ctx,
|
|
185
|
+
"error",
|
|
186
|
+
error instanceof Error ? error.message : String(error)
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
type TurnEndRegistrar = (
|
|
193
|
+
event: "turn_end",
|
|
194
|
+
handler: (event: TurnEndEvent, ctx: ExtensionContext) => void | Promise<void>
|
|
195
|
+
) => void;
|
|
196
|
+
|
|
197
|
+
/** Wires the turn_end handler on an ExtensionAPI-like surface. */
|
|
198
|
+
export const registerJevTurnGate = (
|
|
199
|
+
on: TurnEndRegistrar,
|
|
200
|
+
registration: JevTurnGateRegistration
|
|
201
|
+
): void => {
|
|
202
|
+
on("turn_end", (_event, ctx) => handleJevTurnEnd(registration, ctx));
|
|
203
|
+
};
|
package/src/tools/loop-gate.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
ToolCallEvent,
|
|
5
5
|
ToolCallEventResult,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
7
8
|
import {
|
|
8
9
|
advisorAutoLoopGateRef,
|
|
9
10
|
advisorFailureModeRef,
|
|
@@ -15,8 +16,9 @@ import {
|
|
|
15
16
|
} from "../config/state.ts";
|
|
16
17
|
import type { GateFailureMode } from "../config/types.ts";
|
|
17
18
|
import { herdrAdvisorActivity } from "../herdr.ts";
|
|
18
|
-
import type { AdvisorSessionState } from "../session-state.ts";
|
|
19
|
+
import type { AdvisorSessionState, GateDecision } from "../session-state.ts";
|
|
19
20
|
import { advisorUsageCost, snapshotAdvisorUsage } from "../usage.ts";
|
|
21
|
+
import type { AdvisorUsageSnapshot } from "../usage.ts";
|
|
20
22
|
import type { runAdvisorGate } from "./consultation.ts";
|
|
21
23
|
import {
|
|
22
24
|
blockedDecisionEffect,
|
|
@@ -26,10 +28,9 @@ import {
|
|
|
26
28
|
updateAdvisorUsageStatus,
|
|
27
29
|
} from "./gate-policy.ts";
|
|
28
30
|
import { adviceForGateText } from "./gate-protocol.ts";
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} from "./scout-status.ts";
|
|
31
|
+
import { advisorModelIsAllowed } from "./model-access.ts";
|
|
32
|
+
import { appendScoutLifecycleEntry } from "./scout-status.ts";
|
|
33
|
+
import type { ScoutStatusManager } from "./scout-status.ts";
|
|
33
34
|
import type { AdvisorGateResult, ScoutToolDetails } from "./types.ts";
|
|
34
35
|
|
|
35
36
|
const sendAutomaticGateCall = (pi: ExtensionAPI, event: ToolCallEvent) => {
|
|
@@ -46,20 +47,27 @@ const sendAutomaticGateCall = (pi: ExtensionAPI, event: ToolCallEvent) => {
|
|
|
46
47
|
);
|
|
47
48
|
};
|
|
48
49
|
|
|
50
|
+
interface LoopResultDetails {
|
|
51
|
+
advisor?: string;
|
|
52
|
+
decision?: GateDecision;
|
|
53
|
+
text: string;
|
|
54
|
+
usage?: AdvisorUsageSnapshot;
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
const sendAutomaticGateFailure = (
|
|
50
58
|
pi: ExtensionAPI,
|
|
51
59
|
markdown: string,
|
|
52
|
-
usage?:
|
|
60
|
+
usage?: AdvisorUsageSnapshot
|
|
53
61
|
) => {
|
|
54
|
-
const
|
|
62
|
+
const details: LoopResultDetails = { text: markdown };
|
|
63
|
+
if (usage) {
|
|
64
|
+
details.usage = usage;
|
|
65
|
+
}
|
|
55
66
|
pi.sendMessage(
|
|
56
67
|
{
|
|
57
68
|
content: markdown,
|
|
58
69
|
customType: "advisor-loop-result",
|
|
59
|
-
details
|
|
60
|
-
text: markdown,
|
|
61
|
-
...(normalizedUsage ? { usage: normalizedUsage } : {}),
|
|
62
|
-
},
|
|
70
|
+
details,
|
|
63
71
|
display: true,
|
|
64
72
|
},
|
|
65
73
|
{ deliverAs: "steer" }
|
|
@@ -70,18 +78,20 @@ const sendAutomaticGateResult = (
|
|
|
70
78
|
pi: ExtensionAPI,
|
|
71
79
|
result: AdvisorGateResult
|
|
72
80
|
) => {
|
|
81
|
+
const details: LoopResultDetails = {
|
|
82
|
+
advisor: result.model,
|
|
83
|
+
decision: result.decision,
|
|
84
|
+
text: result.markdown,
|
|
85
|
+
};
|
|
86
|
+
const normalizedUsage = snapshotAdvisorUsage(result.usage);
|
|
87
|
+
if (normalizedUsage) {
|
|
88
|
+
details.usage = normalizedUsage;
|
|
89
|
+
}
|
|
73
90
|
pi.sendMessage(
|
|
74
91
|
{
|
|
75
92
|
content: adviceForGateText(result),
|
|
76
93
|
customType: "advisor-loop-result",
|
|
77
|
-
details
|
|
78
|
-
advisor: result.model,
|
|
79
|
-
decision: result.decision,
|
|
80
|
-
text: result.markdown,
|
|
81
|
-
...(snapshotAdvisorUsage(result.usage)
|
|
82
|
-
? { usage: snapshotAdvisorUsage(result.usage) }
|
|
83
|
-
: {}),
|
|
84
|
-
},
|
|
94
|
+
details,
|
|
85
95
|
display: true,
|
|
86
96
|
},
|
|
87
97
|
{ deliverAs: "steer" }
|
|
@@ -118,7 +128,7 @@ const applyGateDecision = (
|
|
|
118
128
|
sendAutomaticGateFailure(
|
|
119
129
|
pi,
|
|
120
130
|
`**Advisor gate failure (${result.category}):** ${result.message}`,
|
|
121
|
-
result.usage
|
|
131
|
+
snapshotAdvisorUsage(result.usage)
|
|
122
132
|
);
|
|
123
133
|
return failure.block
|
|
124
134
|
? { block: true, reason: `${reason}\n${failure.reason}` }
|
|
@@ -158,6 +168,7 @@ export const handleAutomaticGate = async (
|
|
|
158
168
|
if (
|
|
159
169
|
isSimpleMode() ||
|
|
160
170
|
event.toolName === "ask_advisor" ||
|
|
171
|
+
!advisorModelIsAllowed(ctx) ||
|
|
161
172
|
!advisorAutoLoopGateRef ||
|
|
162
173
|
!session.recordToolCall(
|
|
163
174
|
event.toolName,
|
|
@@ -180,6 +191,7 @@ export const handleAutomaticGate = async (
|
|
|
180
191
|
return failure.block ? { block: true, reason: failure.reason } : undefined;
|
|
181
192
|
}
|
|
182
193
|
session.consumeCall();
|
|
194
|
+
session.resetTurnsSinceConsultation();
|
|
183
195
|
herdrAdvisorActivity.start();
|
|
184
196
|
let scoutDetails: ScoutToolDetails | undefined;
|
|
185
197
|
const scoutStatusToken = Symbol("automatic-gate-scout");
|