@yagni-app/code-staging 1.1.4-staging.1409.1 → 1.1.4-staging.1412.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/dist/extension/index.js +11 -1
- package/dist/extension/permission/gate.d.ts +30 -0
- package/dist/extension/permission/gate.js +109 -46
- package/dist/extension/permission/prefixExtract.d.ts +62 -4
- package/dist/extension/permission/prefixExtract.js +186 -40
- package/dist/extension/pipeline/directConsult.d.ts +47 -0
- package/dist/extension/pipeline/directConsult.js +52 -0
- package/package.json +2 -2
package/dist/extension/index.js
CHANGED
|
@@ -925,7 +925,16 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
925
925
|
// byte-identical to the pre-consult behavior.
|
|
926
926
|
...(evalMode || env.YAGNI_DISABLE_PREFIX_CONSULT === "1" || env.YAGNI_DISABLE_PREFIX_CONSULT === "true"
|
|
927
927
|
? {}
|
|
928
|
-
: {
|
|
928
|
+
: {
|
|
929
|
+
prefixConsult: {
|
|
930
|
+
// The in-process direct transport (no child spawn): the consult
|
|
931
|
+
// rides /v1/chat/completions with the prefix persona as the
|
|
932
|
+
// system prompt, the session's token, and the session's
|
|
933
|
+
// attribution headers. The gate sets x-yagni-caller itself.
|
|
934
|
+
transport: { baseUrl, getToken: getTokenFn },
|
|
935
|
+
},
|
|
936
|
+
}),
|
|
937
|
+
prefixConsultTransportAttribution: () => attributionHeaders(env),
|
|
929
938
|
onPrefixConsult: makePrefixErrorSink(),
|
|
930
939
|
guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
|
|
931
940
|
onGuardianReview: (ev) => {
|
|
@@ -1040,6 +1049,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
1040
1049
|
...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
|
|
1041
1050
|
...(ev.tier ? { tier: ev.tier } : {}),
|
|
1042
1051
|
...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
|
|
1052
|
+
...(ev.prefixConsult ? { prefixConsult: ev.prefixConsult } : {}),
|
|
1043
1053
|
};
|
|
1044
1054
|
if (guardianStorageTier === "raw") {
|
|
1045
1055
|
body.command = redactCommand(ev.command);
|
|
@@ -137,6 +137,15 @@ export declare function decideGate(toolName: string, params: Record<string, unkn
|
|
|
137
137
|
* call actually happened (grants/cache hits skip it).
|
|
138
138
|
*/
|
|
139
139
|
export type GuardianGateOutcome = "prefix_allow" | "cached_allow" | "sandbox_auto_allow" | "allow" | "deny" | "ask_approved" | "ask_approved_remembered" | "ask_denied" | "ask_headless_blocked" | "breaker_ask_approved" | "breaker_blocked" | "escape_grant_allow" | "escape_cached_allow" | "escape_ask_approved" | "escape_ask_approved_remembered" | "escape_hook_allowed" | "escape_auto_sandboxed" | "escape_ask_denied" | "escape_ask_headless_blocked" | "escape_forbidden_blocked" | "escape_aborted" | "escape_ask_failed" | GuardianError;
|
|
140
|
+
/** The L2 prefix fold's summary at dialog-build time (the funnel field on
|
|
141
|
+
* escape ask events): disabled — no consult layer; skipped — the consult
|
|
142
|
+
* layer is on but every segment was pre-gated out (heredoc/substitution/
|
|
143
|
+
* control flow) or already L1-token-covered; in_window — at least one
|
|
144
|
+
* validated candidate landed before the dialog opened; late — every
|
|
145
|
+
* candidate that ever landed arrived after open (or the consult was still
|
|
146
|
+
* unanswered at emit time — the dialog was unrefined either way);
|
|
147
|
+
* none_answered — the fold settled with zero candidates ever. */
|
|
148
|
+
export type PrefixFoldStatus = "in_window" | "late" | "none_answered" | "skipped" | "disabled";
|
|
140
149
|
/**
|
|
141
150
|
* Rich per-decision event for opt-in storage (YAG-510). Carries the RAW
|
|
142
151
|
* command — the wiring layer (index.ts) hashes/redacts per the workspace's
|
|
@@ -156,6 +165,11 @@ export interface GuardianGateEvent {
|
|
|
156
165
|
durationMs?: number;
|
|
157
166
|
/** Whether a Guardian LLM consult actually ran for this decision. */
|
|
158
167
|
consulted: boolean;
|
|
168
|
+
/** Escape ask dialogs only: the L2 prefix fold's summary at dialog-build
|
|
169
|
+
* time — whether a validated consult candidate was visible in the dialog's
|
|
170
|
+
* options (the funnel's "displayed in-window" step). Absent on every other
|
|
171
|
+
* outcome. */
|
|
172
|
+
prefixConsult?: "in_window" | "late" | "none_answered" | "skipped" | "disabled";
|
|
159
173
|
/** Present when the terminal outcome followed a Guardian error (e.g. an
|
|
160
174
|
* error-fallback ask that the user then approved). */
|
|
161
175
|
guardianError?: GuardianError;
|
|
@@ -349,7 +363,23 @@ export interface RegisterPermissionDeps {
|
|
|
349
363
|
*/
|
|
350
364
|
prefixConsult?: {
|
|
351
365
|
tier?: string;
|
|
366
|
+
/** Grace window override for the dialog open (the race's clock). The
|
|
367
|
+
* default is PREFIX_CONSULT_GRACE_MS; tests inject a small value so the
|
|
368
|
+
* in-window vs late paths are drivable in milliseconds. */
|
|
369
|
+
graceMs?: number;
|
|
370
|
+
/** The consult transport: an in-process direct HTTP call to the backend
|
|
371
|
+
* proxy (see pipeline/directConsult.ts). Absent → the transport is not
|
|
372
|
+
* wired and the consult fails soft (the dialog opens with L1 seeds) —
|
|
373
|
+
* the kill-switch shape. */
|
|
374
|
+
transport?: {
|
|
375
|
+
baseUrl: string;
|
|
376
|
+
getToken: () => string | undefined;
|
|
377
|
+
};
|
|
352
378
|
};
|
|
379
|
+
/** Attribution headers for the consult transport (session/run ids from the
|
|
380
|
+
* wiring layer's env). Optional; the consult always sets x-yagni-caller
|
|
381
|
+
* itself. */
|
|
382
|
+
prefixConsultTransportAttribution?: () => Record<string, string>;
|
|
353
383
|
/**
|
|
354
384
|
* Injectable consult function (tests pass a stub; the default is
|
|
355
385
|
* consultPrefixMemoized — the wiring layer keeps ONE memo per session).
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* the context so the model doesn't keep believing it is restricted.
|
|
28
28
|
*/
|
|
29
29
|
import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
|
|
30
|
-
import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, } from "./prefixExtract.js";
|
|
30
|
+
import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, segmentCannotYieldCandidate, } from "./prefixExtract.js";
|
|
31
31
|
import { logEvent } from "../errorSink.js";
|
|
32
32
|
import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
|
|
33
33
|
import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
|
|
@@ -526,6 +526,17 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
526
526
|
cwd,
|
|
527
527
|
signal,
|
|
528
528
|
memo: prefixMemo,
|
|
529
|
+
...(deps.prefixConsult?.transport
|
|
530
|
+
? {
|
|
531
|
+
transport: {
|
|
532
|
+
baseUrl: deps.prefixConsult.transport.baseUrl,
|
|
533
|
+
getToken: deps.prefixConsult.transport.getToken,
|
|
534
|
+
...(deps.prefixConsultTransportAttribution
|
|
535
|
+
? { attribution: deps.prefixConsultTransportAttribution }
|
|
536
|
+
: {}),
|
|
537
|
+
},
|
|
538
|
+
}
|
|
539
|
+
: {}),
|
|
529
540
|
...(tier ? { modelTier: tier } : {}),
|
|
530
541
|
onEvent: (ev) => {
|
|
531
542
|
try {
|
|
@@ -569,55 +580,106 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
569
580
|
const foldConsultSeeds = async (args) => {
|
|
570
581
|
const { l1Seeds } = args;
|
|
571
582
|
if (!deps.prefixConsult)
|
|
572
|
-
return l1Seeds;
|
|
583
|
+
return { seeds: l1Seeds, fold: () => "disabled" };
|
|
573
584
|
const l1TokenPatterns = new Set((l1Seeds?.seeds ?? []).map((s) => s.pattern.join(" ")).filter((p) => p.length > 0));
|
|
574
585
|
const l1LiteralSeeds = (l1Seeds?.seeds ?? []).filter((s) => s.pattern.length === 0);
|
|
575
586
|
// RAW segments, deduped, capped at the seed cap.
|
|
576
587
|
const segments = [...new Set(args.uncoveredSegments.map((s) => s.trim()).filter((s) => s.length > 0))].slice(0, 5);
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
588
|
+
// The ONLY skip rule is the syntactic pre-gate (SEGMENT level): a
|
|
589
|
+
// segment carrying heredocs, substitution, or control flow can never
|
|
590
|
+
// yield a candidate — the persona's own rules answer `none` for exactly
|
|
591
|
+
// these shapes — so spending the call proves nothing. Every OTHER
|
|
592
|
+
// uncovered segment consults, including ones an L1 token seed already
|
|
593
|
+
// covers: the consult's answer confirms or refines the seed, and its
|
|
594
|
+
// landing feeds the funnel's in-window field (an L1-covered segment
|
|
595
|
+
// whose consult answers in-window is exactly the healthy path the
|
|
596
|
+
// metric watches — skipping it would read every L1-covered compound as
|
|
597
|
+
// "no consult", and the ticket's acceptance is the opposite: clean
|
|
598
|
+
// segments inside compounds still consult).
|
|
599
|
+
const consultable = segments.filter((seg) => !segmentCannotYieldCandidate(seg));
|
|
600
|
+
if (consultable.length === 0)
|
|
601
|
+
return { seeds: l1Seeds, fold: () => "skipped" };
|
|
602
|
+
// Snapshot in-window candidates PER SEGMENT: Promise.allSettled would
|
|
603
|
+
// wait for the SLOWEST consult, so one past-window segment would sink
|
|
604
|
+
// every candidate that landed in time (the exact live failure — a 728ms
|
|
605
|
+
// curl answer invisible because a sibling took 4.5s). The window closes
|
|
606
|
+
// at the grace and the dialog opens with whatever validated by then.
|
|
607
|
+
let validatedCount = 0;
|
|
608
|
+
const inWindowCandidates = [];
|
|
609
|
+
const foldSettledAt = { done: false };
|
|
610
|
+
let windowOpen = true;
|
|
611
|
+
// close() flips the window; settleEarly lets a consult callback close it
|
|
612
|
+
// the moment the first validated candidate lands (the dialog has its
|
|
613
|
+
// refinement at that point — with the grace raised to 4s, holding the
|
|
614
|
+
// open for a slow sibling is pure latency).
|
|
615
|
+
let settleEarly = () => { };
|
|
616
|
+
const consults = consultable.map((seg) => consultSegment(seg, args.cwd, args.signal, args.tier).then((r) => {
|
|
617
|
+
if (r.candidate) {
|
|
618
|
+
validatedCount += 1;
|
|
619
|
+
if (windowOpen) {
|
|
620
|
+
inWindowCandidates.push(r.candidate);
|
|
621
|
+
settleEarly();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return r;
|
|
625
|
+
}));
|
|
626
|
+
await new Promise((resolve) => {
|
|
627
|
+
const close = () => {
|
|
628
|
+
windowOpen = false;
|
|
629
|
+
resolve();
|
|
630
|
+
};
|
|
631
|
+
settleEarly = close;
|
|
632
|
+
const t = setTimeout(close, deps.prefixConsult?.graceMs ?? PREFIX_CONSULT_GRACE_MS);
|
|
633
|
+
t.unref?.();
|
|
634
|
+
// Settle EARLY when every consult landed (the common case), or on the
|
|
635
|
+
// first in-window candidate (see settleEarly above).
|
|
636
|
+
Promise.allSettled(consults).then(close);
|
|
637
|
+
});
|
|
638
|
+
// extra = in-window candidates, de-duped against L1 token patterns; the
|
|
639
|
+
// fold keeps running past the window for the memo + the fold status.
|
|
640
|
+
const extra = inWindowCandidates
|
|
641
|
+
.filter((c) => !l1TokenPatterns.has(c.pattern.join(" ")))
|
|
642
|
+
.map((c) => ({ pattern: c.pattern, repoKey: "", addedAt: "", cwd: "" }));
|
|
643
|
+
void Promise.allSettled(consults).then(() => { foldSettledAt.done = true; });
|
|
644
|
+
// foldStatus is READ AT EMIT TIME (the terminal gate event fires after
|
|
645
|
+
// the user answers the dialog), so the window is always closed by then:
|
|
646
|
+
// in_window iff a candidate landed before close (visible in the
|
|
647
|
+
// dialog); late iff validated candidates exist but all arrived after,
|
|
648
|
+
// or the fold was still pending at emit (the dialog was unrefined
|
|
649
|
+
// either way — a pending fold must never read none_answered);
|
|
650
|
+
// none_answered iff the fold settled with zero validated candidates
|
|
651
|
+
// ever. The fold keeps running past the window; whatever lands late
|
|
652
|
+
// memoizes and serves a retry instantly.
|
|
653
|
+
const foldStatus = () => {
|
|
654
|
+
if (inWindowCandidates.length > 0)
|
|
655
|
+
return "in_window";
|
|
656
|
+
if (validatedCount > 0)
|
|
657
|
+
return "late";
|
|
658
|
+
return foldSettledAt.done ? "none_answered" : "late";
|
|
659
|
+
};
|
|
605
660
|
if (extra.length === 0)
|
|
606
|
-
return l1Seeds;
|
|
607
|
-
// Did every consulted segment yield a candidate? Only then do
|
|
608
|
-
// tokens supersede the literal; a partial fold keeps the literal
|
|
609
|
-
// coverage floor (token + literal, deduped, capped at 5).
|
|
610
|
-
|
|
661
|
+
return { seeds: l1Seeds, fold: foldStatus };
|
|
662
|
+
// Did every consulted segment yield a candidate IN-WINDOW? Only then do
|
|
663
|
+
// the L2 tokens supersede the literal; a partial fold keeps the literal
|
|
664
|
+
// as the coverage floor (token + literal, deduped, capped at 5). The
|
|
665
|
+
// completeness check counts IN-WINDOW candidates only — `combined`
|
|
666
|
+
// carries exactly those, so a late-landing sibling must not flip the
|
|
667
|
+
// fold "complete" and drop the literal without its candidate present
|
|
668
|
+
// (the coverage-regression class).
|
|
669
|
+
const complete = inWindowCandidates.length >= consultable.length;
|
|
611
670
|
const combined = [
|
|
612
671
|
...(l1Seeds?.seeds ?? []).filter((s) => s.pattern.length > 0),
|
|
613
672
|
...extra,
|
|
614
673
|
...(complete ? [] : l1LiteralSeeds),
|
|
615
674
|
].slice(0, 5);
|
|
616
675
|
return {
|
|
617
|
-
seeds:
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
676
|
+
seeds: {
|
|
677
|
+
seeds: combined,
|
|
678
|
+
description: combined
|
|
679
|
+
.map((s) => (s.pattern.length > 0 ? describePrefix(s.pattern) : (s.literal ?? "")))
|
|
680
|
+
.join(", "),
|
|
681
|
+
},
|
|
682
|
+
fold: foldStatus,
|
|
621
683
|
};
|
|
622
684
|
};
|
|
623
685
|
// Per-USER-PROMPT bounds (reset in before_agent_start, which fires once per
|
|
@@ -1255,13 +1317,13 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1255
1317
|
// uncovered mutating segments; failures are invisible (the
|
|
1256
1318
|
// dialog opens with L1 seeds).
|
|
1257
1319
|
const seedsForAsk = modeAtEntry === "auto"
|
|
1258
|
-
? await foldConsultSeeds({
|
|
1320
|
+
? (await foldConsultSeeds({
|
|
1259
1321
|
l1Seeds: askSeeds,
|
|
1260
1322
|
uncoveredSegments: askSeeds === null ? [] : segmentsUncoveredForAsk(command, effectivePolicy.execPolicy ?? DEFAULT_EXEC_POLICY),
|
|
1261
1323
|
cwd,
|
|
1262
1324
|
signal: ctx.signal,
|
|
1263
1325
|
tier: deps.prefixConsult?.tier,
|
|
1264
|
-
})
|
|
1326
|
+
})).seeds
|
|
1265
1327
|
: askSeeds;
|
|
1266
1328
|
// The settings-rule option rides TOKEN seeds ONLY: a literal
|
|
1267
1329
|
// seed persisted as `Bash(<literal>:*)` is a prefix rule with no
|
|
@@ -1756,7 +1818,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1756
1818
|
// uncovered segments, grace-bounded, literal-fallback preserved. Runs
|
|
1757
1819
|
// only with a UI (the headless gate returned above), and consult failure
|
|
1758
1820
|
// is invisible — the dialog opens with the L1 seeds.
|
|
1759
|
-
const seeds = await foldConsultSeeds({
|
|
1821
|
+
const { seeds, fold: foldStatus } = await foldConsultSeeds({
|
|
1760
1822
|
l1Seeds,
|
|
1761
1823
|
uncoveredSegments: compound.uncovered,
|
|
1762
1824
|
cwd,
|
|
@@ -1852,7 +1914,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1852
1914
|
grants.push(customGrant);
|
|
1853
1915
|
persistGrantFailSoft(customGrant);
|
|
1854
1916
|
rememberApproved(cwd, command);
|
|
1855
|
-
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false });
|
|
1917
|
+
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false, prefixConsult: foldStatus() });
|
|
1856
1918
|
return {};
|
|
1857
1919
|
}
|
|
1858
1920
|
// A custom prefix that does not cover this command is a dead rule —
|
|
@@ -1911,7 +1973,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1911
1973
|
}
|
|
1912
1974
|
if (resolution === "yes") {
|
|
1913
1975
|
rememberApproved(cwd, command);
|
|
1914
|
-
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved", consulted: false });
|
|
1976
|
+
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved", consulted: false, prefixConsult: foldStatus() });
|
|
1915
1977
|
return {};
|
|
1916
1978
|
}
|
|
1917
1979
|
if (resolution === "remember" && seeds) {
|
|
@@ -1926,7 +1988,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1926
1988
|
persistGrantFailSoft(grantRecord);
|
|
1927
1989
|
}
|
|
1928
1990
|
rememberApproved(cwd, command);
|
|
1929
|
-
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false });
|
|
1991
|
+
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false, prefixConsult: foldStatus() });
|
|
1930
1992
|
return {};
|
|
1931
1993
|
}
|
|
1932
1994
|
// A dialog-layer FAILURE (a select threw for a non-abort reason — RPC
|
|
@@ -1941,6 +2003,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1941
2003
|
...eventBase,
|
|
1942
2004
|
outcome: resolution === "error" ? "escape_ask_failed" : "escape_ask_denied",
|
|
1943
2005
|
consulted: false,
|
|
2006
|
+
prefixConsult: foldStatus(),
|
|
1944
2007
|
});
|
|
1945
2008
|
return {
|
|
1946
2009
|
block: true,
|
|
@@ -1949,7 +2012,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1949
2012
|
: "The permission dialog was dismissed; the command was not run. Ask the user how to proceed.",
|
|
1950
2013
|
};
|
|
1951
2014
|
}
|
|
1952
|
-
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_denied", consulted: false });
|
|
2015
|
+
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_denied", consulted: false, prefixConsult: foldStatus() });
|
|
1953
2016
|
if (resolution === "no") {
|
|
1954
2017
|
return {
|
|
1955
2018
|
block: true,
|
|
@@ -38,19 +38,38 @@ export interface PrefixConsultResult {
|
|
|
38
38
|
/** True when the model answered command_injection_detected (the candidate
|
|
39
39
|
* is null but the outcome class is injection, not a plain decline). */
|
|
40
40
|
injection?: boolean;
|
|
41
|
+
/** True when the model ANSWERED but the validation ladder rejected it
|
|
42
|
+
* (banned shape, word shape, self-coverage) — the `invalid` outcome class,
|
|
43
|
+
* distinct from `none` (the model declined) and from the error classes. */
|
|
44
|
+
invalid?: boolean;
|
|
41
45
|
/** Error CLASS only (constructor name) when the consult threw — never the
|
|
42
46
|
* thrown message (it can carry command content or provider payloads). */
|
|
43
47
|
errorClass?: string;
|
|
44
48
|
}
|
|
45
49
|
/** The model tier the prefix consult runs on (Haiku-equivalent, like the Guardian). */
|
|
46
50
|
export declare const PREFIX_MODEL_TIER = "efficient";
|
|
51
|
+
export declare function _setPrefixPersonaBodyResolverForTest(resolver: (() => string) | null): void;
|
|
47
52
|
/**
|
|
48
53
|
* Grace window for the dialog open: the consult fires at interception and the
|
|
49
|
-
* dialog opens after at most this delay with whatever seeds have landed
|
|
50
|
-
*
|
|
51
|
-
*
|
|
54
|
+
* dialog opens after at most this delay with whatever seeds have landed. The
|
|
55
|
+
* window races the CLIENT-side total (transport + LLM round trip), NOT the
|
|
56
|
+
* server-side llm_call_log.duration_ms (the LLM call alone) — the old 1500ms
|
|
57
|
+
* constant was tuned against the wrong clock, and the child-spawn transport
|
|
58
|
+
* (~4s boot before the first event) made every first-encounter consult lose
|
|
59
|
+
* the race. 4000ms is a ceiling we hope the in-process transport's in-window
|
|
60
|
+
* rate never needs (post-fix server durations: p50 ~1.1s, ~87% under 2s).
|
|
61
|
+
* Fails open instantly on any consult error.
|
|
52
62
|
*/
|
|
53
|
-
export declare const PREFIX_CONSULT_GRACE_MS =
|
|
63
|
+
export declare const PREFIX_CONSULT_GRACE_MS = 4000;
|
|
64
|
+
/**
|
|
65
|
+
* The syntactic pre-gate (SEGMENT level): a command segment carrying
|
|
66
|
+
* heredocs, command substitution, or control flow can never yield a
|
|
67
|
+
* validated candidate — the prefix persona's own rules answer `none` for
|
|
68
|
+
* exactly these shapes, and the ladder would dispose the answer anyway.
|
|
69
|
+
* Consulting such a segment spends the call to learn nothing. Clean
|
|
70
|
+
* segments inside compounds still consult.
|
|
71
|
+
*/
|
|
72
|
+
export declare function segmentCannotYieldCandidate(segment: string): boolean;
|
|
54
73
|
/** Consult deadline (the consult keeps running past the grace window — its
|
|
55
74
|
* result memoizes and serves any retry of the same segment). */
|
|
56
75
|
export declare const PREFIX_CONSULT_TIMEOUT_MS = 15000;
|
|
@@ -76,6 +95,23 @@ export declare function validatePrefixAnswer(answer: string, command: string): {
|
|
|
76
95
|
} | {
|
|
77
96
|
kind: "reject";
|
|
78
97
|
};
|
|
98
|
+
/**
|
|
99
|
+
* Classify ONE model answer (already fetched, whatever the transport) against
|
|
100
|
+
* a command segment: parse → injection → none → ladder → candidate. The
|
|
101
|
+
* SINGLE ladder both transports feed — the child-spawn path and the direct
|
|
102
|
+
* HTTP path differ only in how they obtain the text, never in how the answer
|
|
103
|
+
* is disposed (the two copies had already begun to drift when `invalid`
|
|
104
|
+
* landed; this is the fix for that).
|
|
105
|
+
*/
|
|
106
|
+
export declare function classifyPrefixAnswer(output: string, segment: string, cost: number): PrefixConsultResult;
|
|
107
|
+
/**
|
|
108
|
+
* Classify a transport THREW error into the consult's error classes —
|
|
109
|
+
* shared by both transports. The caller's abort wins, then the consult's
|
|
110
|
+
* own deadline, then everything else is `network` with the error class
|
|
111
|
+
* (constructor name only — never the thrown message, which can carry
|
|
112
|
+
* command content or provider payloads).
|
|
113
|
+
*/
|
|
114
|
+
export declare function classifyPrefixConsultThrow(err: unknown, callerAborted: boolean, deadlineAborted: boolean): PrefixConsultResult;
|
|
79
115
|
/** A settled memo entry: successes AND settled rejections (both are stable
|
|
80
116
|
* answers — `none` must not re-consult forever, and a malformed answer is a
|
|
81
117
|
* stable property of the model+segment pair). The terminal OUTCOME CLASS is
|
|
@@ -108,12 +144,26 @@ export declare function createPrefixMemo(capacity?: number): {
|
|
|
108
144
|
size(): number;
|
|
109
145
|
};
|
|
110
146
|
export interface PrefixConsultDeps {
|
|
147
|
+
/** Legacy child-spawn transport seam — retained for the existing test
|
|
148
|
+
* stubs. When `transport` is absent but `runStage` is injected, the
|
|
149
|
+
* stubbed runStage path still runs (the unit tests' seam). */
|
|
111
150
|
runStage?: typeof defaultRunStage;
|
|
112
151
|
cwd: string;
|
|
113
152
|
signal?: AbortSignal;
|
|
114
153
|
modelTier?: string;
|
|
115
154
|
timeoutMs?: number;
|
|
116
155
|
memo?: ReturnType<typeof createPrefixMemo>;
|
|
156
|
+
/** The in-process direct transport. The gate supplies it from its
|
|
157
|
+
* prefixConsult config; when BOTH transport and runStage are absent, the
|
|
158
|
+
* consult fails soft (network class — the dialog opens with L1 seeds). */
|
|
159
|
+
transport?: {
|
|
160
|
+
baseUrl: string;
|
|
161
|
+
getToken: () => string | undefined;
|
|
162
|
+
/** Attribution headers (session/run ids); the consult sets its own
|
|
163
|
+
* x-yagni-caller. */
|
|
164
|
+
attribution?: () => Record<string, string>;
|
|
165
|
+
fetchImpl?: typeof fetch;
|
|
166
|
+
};
|
|
117
167
|
}
|
|
118
168
|
/**
|
|
119
169
|
* The synthetic stage a prefix consult runs as. Borrows the `plan` StageId
|
|
@@ -141,6 +191,14 @@ export declare function buildPrefixDiagnosticEvent(outcome: PrefixDiagnosticEven
|
|
|
141
191
|
errorClass?: string;
|
|
142
192
|
debug?: boolean;
|
|
143
193
|
}): PrefixDiagnosticEvent;
|
|
194
|
+
/**
|
|
195
|
+
* The consult's terminal outcome class, flat (no nested ternaries — the
|
|
196
|
+
* workspace TS rules prohibit them): a candidate is a hit; a malformed
|
|
197
|
+
* answer is unparseable; an answered-but-disposed ladder rejection is
|
|
198
|
+
* invalid; an injection flag is injection_rejected; a plain decline is
|
|
199
|
+
* none; everything else is the transport error class verbatim.
|
|
200
|
+
*/
|
|
201
|
+
export declare function prefixOutcomeClass(res: PrefixConsultResult): PrefixDiagnosticEvent["outcome"];
|
|
144
202
|
/**
|
|
145
203
|
* Run ONE prefix consult on a single command segment. Pure on top of an
|
|
146
204
|
* injectable runStage; the caller (the gate) fans out per-segment and races
|
|
@@ -24,16 +24,93 @@
|
|
|
24
24
|
* is capped at "wasted a suggestion".
|
|
25
25
|
*/
|
|
26
26
|
import { runStage as defaultRunStage } from "../pipeline/runner.js";
|
|
27
|
+
import { directConsult } from "../pipeline/directConsult.js";
|
|
28
|
+
import { PERSONA_BODIES } from "../pipeline/personas.js";
|
|
29
|
+
import { logEvent } from "../errorSink.js";
|
|
27
30
|
import { BANNED_PREFIXES, canonicalizeForGrants, matchesGrant, } from "./approvedPrefixes.js";
|
|
28
31
|
/** The model tier the prefix consult runs on (Haiku-equivalent, like the Guardian). */
|
|
29
32
|
export const PREFIX_MODEL_TIER = "efficient";
|
|
33
|
+
/**
|
|
34
|
+
* The prefix persona body — the system prompt on the direct transport. A
|
|
35
|
+
* missing/renamed persona must never silently degrade to an empty system
|
|
36
|
+
* prompt (every consult would run unprompted and the funnel would just
|
|
37
|
+
* read as a low hit-rate), so a warn fires ONCE when it resolves empty.
|
|
38
|
+
*/
|
|
39
|
+
let prefixPersonaBodyWarned = false;
|
|
40
|
+
/** The persona-body resolver — injectable so tests can drive the
|
|
41
|
+
* missing-persona branch (the memo's reset-export pattern). */
|
|
42
|
+
let resolvePrefixPersonaBody = () => PERSONA_BODIES.prefix ?? "";
|
|
43
|
+
export function _setPrefixPersonaBodyResolverForTest(resolver) {
|
|
44
|
+
resolvePrefixPersonaBody = resolver ?? (() => PERSONA_BODIES.prefix ?? "");
|
|
45
|
+
prefixPersonaBodyWarned = false;
|
|
46
|
+
}
|
|
47
|
+
function prefixPersonaBody() {
|
|
48
|
+
const body = resolvePrefixPersonaBody();
|
|
49
|
+
if (body.length === 0 && !prefixPersonaBodyWarned) {
|
|
50
|
+
prefixPersonaBodyWarned = true;
|
|
51
|
+
// logEvent is failure-isolated end to end (errorSink never throws), so
|
|
52
|
+
// no guard here: the consult path must never pay for telemetry.
|
|
53
|
+
logEvent({
|
|
54
|
+
source: "prefix",
|
|
55
|
+
level: "warn",
|
|
56
|
+
event: "prefix_persona_body_missing",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return body;
|
|
60
|
+
}
|
|
30
61
|
/**
|
|
31
62
|
* Grace window for the dialog open: the consult fires at interception and the
|
|
32
|
-
* dialog opens after at most this delay with whatever seeds have landed
|
|
33
|
-
*
|
|
34
|
-
*
|
|
63
|
+
* dialog opens after at most this delay with whatever seeds have landed. The
|
|
64
|
+
* window races the CLIENT-side total (transport + LLM round trip), NOT the
|
|
65
|
+
* server-side llm_call_log.duration_ms (the LLM call alone) — the old 1500ms
|
|
66
|
+
* constant was tuned against the wrong clock, and the child-spawn transport
|
|
67
|
+
* (~4s boot before the first event) made every first-encounter consult lose
|
|
68
|
+
* the race. 4000ms is a ceiling we hope the in-process transport's in-window
|
|
69
|
+
* rate never needs (post-fix server durations: p50 ~1.1s, ~87% under 2s).
|
|
70
|
+
* Fails open instantly on any consult error.
|
|
71
|
+
*/
|
|
72
|
+
export const PREFIX_CONSULT_GRACE_MS = 4_000;
|
|
73
|
+
/**
|
|
74
|
+
* The syntactic pre-gate (SEGMENT level): a command segment carrying
|
|
75
|
+
* heredocs, command substitution, or control flow can never yield a
|
|
76
|
+
* validated candidate — the prefix persona's own rules answer `none` for
|
|
77
|
+
* exactly these shapes, and the ladder would dispose the answer anyway.
|
|
78
|
+
* Consulting such a segment spends the call to learn nothing. Clean
|
|
79
|
+
* segments inside compounds still consult.
|
|
35
80
|
*/
|
|
36
|
-
export
|
|
81
|
+
export function segmentCannotYieldCandidate(segment) {
|
|
82
|
+
// Heredoc — the gate's compound splitter refuses to split heredoc bodies,
|
|
83
|
+
// so a heredoc-carrying command arrives as ONE segment that is all body.
|
|
84
|
+
if (/<<[-~]?\s*("|')?(\w+)/.test(segment))
|
|
85
|
+
return true;
|
|
86
|
+
// Command substitution — $(...) or `...` feeding another command.
|
|
87
|
+
if (/\$\(/.test(segment) || /`/.test(segment))
|
|
88
|
+
return true;
|
|
89
|
+
// Control flow — the shell reserved words that make a command multi-part.
|
|
90
|
+
// ANCHORED to command positions (the segment start, or after a statement
|
|
91
|
+
// separator): the words must be what makes the COMMAND multi-part, not
|
|
92
|
+
// any word anywhere — `git commit -m "do the thing"`, `man while`, and
|
|
93
|
+
// `grep in file` are plain single commands the persona can answer for,
|
|
94
|
+
// and over-skipping them is the false-positive class the acceptance
|
|
95
|
+
// criteria argue against.
|
|
96
|
+
const first = segment.trim().split(/[\s;|&]+/)[0] ?? "";
|
|
97
|
+
if (/^(if|then|elif|else|fi|for|while|until|do|done|case|esac)$/.test(first))
|
|
98
|
+
return true;
|
|
99
|
+
// A reserved word after a statement separator (`; done`, `&& do`) — a
|
|
100
|
+
// segment can carry a trailing control-flow clause only via ; & | &&.
|
|
101
|
+
// QUOTE-AWARE: quoted argument bodies are data, not shell structure —
|
|
102
|
+
// `curl 'https://x/a;if=1'` is a plain single command (its separator-like
|
|
103
|
+
// text lives inside quotes), so the scan runs on the segment with quoted
|
|
104
|
+
// spans blanked out.
|
|
105
|
+
// PAIRED spans only: an unpaired apostrophe (`echo don't; done`) is prose,
|
|
106
|
+
// not an opening quote — blanking through it would hide a real separator
|
|
107
|
+
// and fail the pre-gate open (extra consult spend). A paired span with no
|
|
108
|
+
// closing quote is left intact for the same reason.
|
|
109
|
+
const unquoted = segment.replace(/"[^"]*"|'[^']*'/g, (m) => " ".repeat(m.length));
|
|
110
|
+
if (/(?:^|[;|&]{1,2})\s*(?:if|then|elif|else|fi|for|while|until|do|done|case|esac)\b/.test(unquoted))
|
|
111
|
+
return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
37
114
|
/** Consult deadline (the consult keeps running past the grace window — its
|
|
38
115
|
* result memoizes and serves any retry of the same segment). */
|
|
39
116
|
export const PREFIX_CONSULT_TIMEOUT_MS = 15_000;
|
|
@@ -122,6 +199,54 @@ export function validatePrefixAnswer(answer, command) {
|
|
|
122
199
|
candidate: { pattern: words, description: `${words.join(" ")} …` },
|
|
123
200
|
};
|
|
124
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Classify ONE model answer (already fetched, whatever the transport) against
|
|
204
|
+
* a command segment: parse → injection → none → ladder → candidate. The
|
|
205
|
+
* SINGLE ladder both transports feed — the child-spawn path and the direct
|
|
206
|
+
* HTTP path differ only in how they obtain the text, never in how the answer
|
|
207
|
+
* is disposed (the two copies had already begun to drift when `invalid`
|
|
208
|
+
* landed; this is the fix for that).
|
|
209
|
+
*/
|
|
210
|
+
export function classifyPrefixAnswer(output, segment, cost) {
|
|
211
|
+
const parsed = parsePrefixAnswer(output);
|
|
212
|
+
if (!parsed) {
|
|
213
|
+
return { candidate: null, error: "malformed", cost };
|
|
214
|
+
}
|
|
215
|
+
const verdict = validatePrefixAnswer(parsed, segment);
|
|
216
|
+
if (verdict.kind === "candidate") {
|
|
217
|
+
return { candidate: verdict.candidate, cost };
|
|
218
|
+
}
|
|
219
|
+
if (verdict.kind === "injection") {
|
|
220
|
+
return { candidate: null, injection: true, cost };
|
|
221
|
+
}
|
|
222
|
+
// Rejection by the ladder — an expected outcome, not an error, but a
|
|
223
|
+
// DISTINCT class from the model's own `none`: the counters must be able
|
|
224
|
+
// to say "the model answered and pure code disposed" (invalid) vs "the
|
|
225
|
+
// model declined" (none). The literal `none` answer is the decline.
|
|
226
|
+
if (parsed === NONE_TOKEN) {
|
|
227
|
+
return { candidate: null, cost };
|
|
228
|
+
}
|
|
229
|
+
return { candidate: null, invalid: true, cost };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Classify a transport THREW error into the consult's error classes —
|
|
233
|
+
* shared by both transports. The caller's abort wins, then the consult's
|
|
234
|
+
* own deadline, then everything else is `network` with the error class
|
|
235
|
+
* (constructor name only — never the thrown message, which can carry
|
|
236
|
+
* command content or provider payloads).
|
|
237
|
+
*/
|
|
238
|
+
export function classifyPrefixConsultThrow(err, callerAborted, deadlineAborted) {
|
|
239
|
+
if (callerAborted)
|
|
240
|
+
return { candidate: null, error: "aborted", cost: 0 };
|
|
241
|
+
if (deadlineAborted)
|
|
242
|
+
return { candidate: null, error: "timeout", cost: 0 };
|
|
243
|
+
return {
|
|
244
|
+
candidate: null,
|
|
245
|
+
error: "network",
|
|
246
|
+
cost: 0,
|
|
247
|
+
errorClass: err instanceof Error ? err.constructor.name : typeof err,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
125
250
|
/**
|
|
126
251
|
* LRU memo (Claude's memoizeWithLRU shape, reject-evict): caches SETTLED
|
|
127
252
|
* consults keyed by the command segment string. In-flight promises are
|
|
@@ -181,6 +306,26 @@ export function buildPrefixDiagnosticEvent(outcome, opts) {
|
|
|
181
306
|
}
|
|
182
307
|
return ev;
|
|
183
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* The consult's terminal outcome class, flat (no nested ternaries — the
|
|
311
|
+
* workspace TS rules prohibit them): a candidate is a hit; a malformed
|
|
312
|
+
* answer is unparseable; an answered-but-disposed ladder rejection is
|
|
313
|
+
* invalid; an injection flag is injection_rejected; a plain decline is
|
|
314
|
+
* none; everything else is the transport error class verbatim.
|
|
315
|
+
*/
|
|
316
|
+
export function prefixOutcomeClass(res) {
|
|
317
|
+
if (res.candidate)
|
|
318
|
+
return "hit";
|
|
319
|
+
if (res.error === "malformed")
|
|
320
|
+
return "unparseable";
|
|
321
|
+
if (res.error !== undefined)
|
|
322
|
+
return res.error;
|
|
323
|
+
if (res.injection === true)
|
|
324
|
+
return "injection_rejected";
|
|
325
|
+
if (res.invalid === true)
|
|
326
|
+
return "invalid";
|
|
327
|
+
return "none";
|
|
328
|
+
}
|
|
184
329
|
/**
|
|
185
330
|
* Run ONE prefix consult on a single command segment. Pure on top of an
|
|
186
331
|
* injectable runStage; the caller (the gate) fans out per-segment and races
|
|
@@ -199,6 +344,40 @@ export async function consultPrefix(segment, deps) {
|
|
|
199
344
|
else
|
|
200
345
|
deps.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
201
346
|
}
|
|
347
|
+
// The in-process direct transport (the production path): one
|
|
348
|
+
// /v1/chat/completions POST with the prefix persona as the system prompt
|
|
349
|
+
// and the raw segment as the user content — no child spawn, no grounded
|
|
350
|
+
// context, ~1k input tokens. The result flows through the SAME parser +
|
|
351
|
+
// ladder + error classes as the child path.
|
|
352
|
+
if (deps.transport) {
|
|
353
|
+
const t = deps.transport;
|
|
354
|
+
try {
|
|
355
|
+
const res = await directConsult({ systemPrompt: prefixPersonaBody(), userContent: segment, modelTier: deps.modelTier ?? PREFIX_MODEL_TIER }, {
|
|
356
|
+
baseUrl: t.baseUrl,
|
|
357
|
+
getToken: t.getToken,
|
|
358
|
+
attribution: t.attribution ?? (() => ({})),
|
|
359
|
+
callerLabel: "prefix",
|
|
360
|
+
signal: controller.signal,
|
|
361
|
+
...(t.fetchImpl ? { fetchImpl: t.fetchImpl } : {}),
|
|
362
|
+
});
|
|
363
|
+
const output = res.text.trim();
|
|
364
|
+
if (deps.signal?.aborted) {
|
|
365
|
+
return { candidate: null, error: "aborted", cost: res.cost };
|
|
366
|
+
}
|
|
367
|
+
if (!output) {
|
|
368
|
+
return { candidate: null, error: "empty", cost: res.cost };
|
|
369
|
+
}
|
|
370
|
+
return classifyPrefixAnswer(output, segment, res.cost);
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
return classifyPrefixConsultThrow(err, deps.signal?.aborted === true, controller.signal.aborted);
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Legacy child-spawn path (retained for the injectable test seam; no
|
|
380
|
+
// production wiring uses it once the gate supplies `transport`).
|
|
202
381
|
try {
|
|
203
382
|
const result = await runStage(stage, { ticket: segment }, {
|
|
204
383
|
cwd: deps.cwd,
|
|
@@ -218,35 +397,10 @@ export async function consultPrefix(segment, deps) {
|
|
|
218
397
|
}
|
|
219
398
|
return { candidate: null, error: "empty", cost };
|
|
220
399
|
}
|
|
221
|
-
|
|
222
|
-
if (!parsed) {
|
|
223
|
-
return {
|
|
224
|
-
candidate: null,
|
|
225
|
-
error: "malformed",
|
|
226
|
-
cost,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
const verdict = validatePrefixAnswer(parsed, segment);
|
|
230
|
-
if (verdict.kind === "candidate") {
|
|
231
|
-
return { candidate: verdict.candidate, cost };
|
|
232
|
-
}
|
|
233
|
-
if (verdict.kind === "injection") {
|
|
234
|
-
return { candidate: null, injection: true, cost };
|
|
235
|
-
}
|
|
236
|
-
// rejection by the ladder — an expected outcome, not an error
|
|
237
|
-
return { candidate: null, injection: false, cost };
|
|
400
|
+
return classifyPrefixAnswer(output, segment, cost);
|
|
238
401
|
}
|
|
239
402
|
catch (err) {
|
|
240
|
-
|
|
241
|
-
return { candidate: null, error: "aborted", cost: 0 };
|
|
242
|
-
if (controller.signal.aborted)
|
|
243
|
-
return { candidate: null, error: "timeout", cost: 0 };
|
|
244
|
-
return {
|
|
245
|
-
candidate: null,
|
|
246
|
-
error: "network",
|
|
247
|
-
cost: 0,
|
|
248
|
-
errorClass: err instanceof Error ? err.constructor.name : typeof err,
|
|
249
|
-
};
|
|
403
|
+
return classifyPrefixConsultThrow(err, deps.signal?.aborted === true, controller.signal.aborted);
|
|
250
404
|
}
|
|
251
405
|
finally {
|
|
252
406
|
clearTimeout(timer);
|
|
@@ -292,15 +446,7 @@ export async function consultPrefixMemoized(segment, deps) {
|
|
|
292
446
|
if (res.error === "empty" && !deps.signal?.aborted) {
|
|
293
447
|
res = await consultPrefix(segment, deps);
|
|
294
448
|
}
|
|
295
|
-
const outcome = res
|
|
296
|
-
? "hit"
|
|
297
|
-
: res.error === "malformed"
|
|
298
|
-
? "unparseable"
|
|
299
|
-
: res.error === undefined
|
|
300
|
-
? res.injection === true
|
|
301
|
-
? "injection_rejected"
|
|
302
|
-
: "none"
|
|
303
|
-
: res.error;
|
|
449
|
+
const outcome = prefixOutcomeClass(res);
|
|
304
450
|
deps.onEvent?.(buildPrefixDiagnosticEvent(outcome, {
|
|
305
451
|
durationMs: Date.now() - started,
|
|
306
452
|
tier: deps.modelTier ?? PREFIX_MODEL_TIER,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process direct LLM consult transport (the replacement for spawning a
|
|
3
|
+
* child pi per consult): one non-streaming /v1/chat/completions POST to the
|
|
4
|
+
* backend proxy with a system prompt + user content, Bearer auth, and the
|
|
5
|
+
* session's attribution headers (caller label included).
|
|
6
|
+
*
|
|
7
|
+
* The pattern is webFetch.ts's extract() — the extension's existing
|
|
8
|
+
* in-process proxy call — generalized so any consult (the prefix persona
|
|
9
|
+
* today; the Guardian's, tracked separately, can adopt the same seam) runs
|
|
10
|
+
* without paying the ~4s child-boot tax that made every first-encounter
|
|
11
|
+
* consult lose the dialog's grace race.
|
|
12
|
+
*
|
|
13
|
+
* The caller owns parsing/validation/timeout policy; this module only moves
|
|
14
|
+
* the request. Every error throws — the consult's own error classifier
|
|
15
|
+
* (timeout/network) derives the outcome class from the failure mode.
|
|
16
|
+
*/
|
|
17
|
+
export interface DirectConsultRequest {
|
|
18
|
+
/** The system prompt (the persona body). */
|
|
19
|
+
systemPrompt: string;
|
|
20
|
+
/** The user content (the consulted material — a command segment, a verdict
|
|
21
|
+
* request, …). */
|
|
22
|
+
userContent: string;
|
|
23
|
+
/** Model tier label the proxy resolves (efficient/standard/advanced). */
|
|
24
|
+
modelTier: string;
|
|
25
|
+
}
|
|
26
|
+
export interface DirectConsultOptions {
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
/** Bearer token source (the session's token provider). */
|
|
29
|
+
getToken: () => string | undefined;
|
|
30
|
+
/** Attribution headers (x-yagni-session-id / x-yagni-run-id /
|
|
31
|
+
* x-yagni-caller). The consult overrides the caller label. */
|
|
32
|
+
attribution: () => Record<string, string>;
|
|
33
|
+
/** The caller label (x-yagni-caller) — attribution, labeling the consult's
|
|
34
|
+
* spend and failures in the backend's dashboards. */
|
|
35
|
+
callerLabel: string;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
/** Injectable for tests (defaults to the global fetch). */
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
}
|
|
40
|
+
export interface DirectConsultResult {
|
|
41
|
+
/** The model's reply text (trimmed by the caller, not here). */
|
|
42
|
+
text: string;
|
|
43
|
+
/** Cost in USD when the response carries usage, else 0. */
|
|
44
|
+
cost: number;
|
|
45
|
+
}
|
|
46
|
+
export declare function directConsult(req: DirectConsultRequest, opts: DirectConsultOptions): Promise<DirectConsultResult>;
|
|
47
|
+
//# sourceMappingURL=directConsult.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process direct LLM consult transport (the replacement for spawning a
|
|
3
|
+
* child pi per consult): one non-streaming /v1/chat/completions POST to the
|
|
4
|
+
* backend proxy with a system prompt + user content, Bearer auth, and the
|
|
5
|
+
* session's attribution headers (caller label included).
|
|
6
|
+
*
|
|
7
|
+
* The pattern is webFetch.ts's extract() — the extension's existing
|
|
8
|
+
* in-process proxy call — generalized so any consult (the prefix persona
|
|
9
|
+
* today; the Guardian's, tracked separately, can adopt the same seam) runs
|
|
10
|
+
* without paying the ~4s child-boot tax that made every first-encounter
|
|
11
|
+
* consult lose the dialog's grace race.
|
|
12
|
+
*
|
|
13
|
+
* The caller owns parsing/validation/timeout policy; this module only moves
|
|
14
|
+
* the request. Every error throws — the consult's own error classifier
|
|
15
|
+
* (timeout/network) derives the outcome class from the failure mode.
|
|
16
|
+
*/
|
|
17
|
+
export async function directConsult(req, opts) {
|
|
18
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
19
|
+
const res = await fetchImpl(`${opts.baseUrl}/v1/chat/completions`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
24
|
+
...opts.attribution(),
|
|
25
|
+
"x-yagni-caller": opts.callerLabel,
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
model: req.modelTier,
|
|
29
|
+
messages: [
|
|
30
|
+
{ role: "system", content: req.systemPrompt },
|
|
31
|
+
{ role: "user", content: req.userContent },
|
|
32
|
+
],
|
|
33
|
+
}),
|
|
34
|
+
signal: opts.signal,
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
// The status rides the message (the consult keeps thrown messages out of
|
|
38
|
+
// telemetry; the local sink line + errorClass carry it) so a persistent
|
|
39
|
+
// 401/403 is distinguishable from a transient gateway blip when reading
|
|
40
|
+
// the sink — the closed outcome vocabulary stays untouched.
|
|
41
|
+
throw new Error(`direct consult failed: HTTP ${res.status}`);
|
|
42
|
+
}
|
|
43
|
+
const data = (await res.json());
|
|
44
|
+
// A 200 with no/blank content is an EMPTY result, not a transport error:
|
|
45
|
+
// the consult's error classifier treats `empty` as retryable-once (the
|
|
46
|
+
// backend's own 502-shape body invites exactly that), and a thrown error
|
|
47
|
+
// here would misclassify that shape as `network`. A genuinely malformed
|
|
48
|
+
// (non-JSON) body still throws below.
|
|
49
|
+
const text = data.choices?.[0]?.message?.content ?? "";
|
|
50
|
+
return { text, cost: typeof data.usage?.cost === "number" ? data.usage.cost : 0 };
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=directConsult.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.4-staging.
|
|
3
|
+
"version": "1.1.4-staging.1412.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "4266630468e144c9ac86fc902b44a9de1b05409f"
|
|
62
62
|
}
|