@cohortapp/agent-sdk 2.18.12 → 2.18.14
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/bin/maestro.mjs +38 -1
- package/docs/runbooks/fleet-rollout.md +14 -7
- package/docs/runbooks/recovery-and-failover.md +18 -0
- package/lib/cadence-failure-class.mjs +245 -0
- package/lib/claude-bin.mjs +26 -7
- package/lib/cli/doctor-checks.mjs +149 -1
- package/lib/comms/send-gate.mjs +6 -4
- package/lib/diagnostics/alerts.mjs +33 -0
- package/lib/engine/agents/usage.mjs +45 -0
- package/lib/engine/budget.mjs +293 -29
- package/lib/engine/cli.mjs +54 -5
- package/lib/engine/loop.mjs +30 -0
- package/lib/engine/output/json.mjs +26 -0
- package/lib/engine/wire/errors.mjs +179 -0
- package/lib/engine/wire/search.mjs +44 -8
- package/lib/identity/claude-md.mjs +107 -0
- package/lib/identity/disclosure-instructions.mjs +148 -0
- package/lib/identity/disclosure-scrub.mjs +207 -0
- package/lib/identity/persona.mjs +141 -6
- package/lib/org/inbound/conversation-frame.mjs +289 -0
- package/lib/org/inbound/directedness.mjs +27 -7
- package/lib/org/quota.mjs +27 -0
- package/lib/session/config.mjs +4 -0
- package/lib/session/identity.mjs +71 -7
- package/lib/session/launch-failure.mjs +251 -0
- package/lib/session/resume-target.mjs +86 -0
- package/lib/telemetry/collect.mjs +129 -0
- package/lib/upgrade/pinned-drift.mjs +467 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/skills/persona-discipline.md +24 -2
- package/scaffold/config/alerts.yaml +7 -0
- package/scripts/ci/check-cadence-prompts-exist.mjs +96 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/run-tests.mjs +16 -2
- package/scripts/daemon/cadence-consumer.mjs +281 -34
- package/scripts/daemon/context-compiler.mjs +9 -1
- package/scripts/daemon/prompt-builder.mjs +219 -137
- package/scripts/daemon/responder.mjs +226 -26
- package/scripts/emergency-stop.sh +114 -13
- package/scripts/fleet/rollout.mjs +10 -3
- package/scripts/healthcheck.sh +131 -33
- package/scripts/resume-operations.sh +101 -6
- package/scripts/session/supervisor.mjs +198 -5
|
@@ -100,6 +100,28 @@ export function buildResult({ outcome, sessionId, durationMs, wire, session = nu
|
|
|
100
100
|
modelTier: outcome.modelTier,
|
|
101
101
|
requestIds: outcome.requestIds,
|
|
102
102
|
costMicros: outcome.costMicros,
|
|
103
|
+
// W16 (hq a60b9595): what the gateway says ABOUT `costMicros` above.
|
|
104
|
+
// `enforcement` is the billing mode that produced it — only "on" means
|
|
105
|
+
// money moved, `null` means the endpoint did not say — so a consumer
|
|
106
|
+
// summing `costMicros` (or `total_cost_usd`) as spend must read this in
|
|
107
|
+
// the same breath. `shadowCostMicros` is what enforcement WOULD have
|
|
108
|
+
// charged, kept under its own name because folding a counterfactual into
|
|
109
|
+
// a charge is the misread the gateway split the fields to prevent; it is
|
|
110
|
+
// null in production today. `quotaSource` says why `quota` is empty when
|
|
111
|
+
// it is: the ledger answered and there is no allowance, the read failed,
|
|
112
|
+
// or nothing was read — three answers one `{}` used to collapse.
|
|
113
|
+
//
|
|
114
|
+
// SCOPE: these three have the SAME scope as `costMicros` above, on purpose.
|
|
115
|
+
// On a run with subagents cli.mjs replaces `costMicros` with the
|
|
116
|
+
// parent+children sum and replaces these three with the fold over the same
|
|
117
|
+
// set (agents/usage.mjs), so a parent served under "on" whose child was
|
|
118
|
+
// served under "shadow" reports "mixed" — never "on" beside a total that
|
|
119
|
+
// includes the child's shadow figure. Per-child modes are on
|
|
120
|
+
// `cohort.agents[]`. (`gatewayUsage` below does NOT follow that rule; its
|
|
121
|
+
// own comment says so.)
|
|
122
|
+
enforcement: outcome.enforcement ?? null,
|
|
123
|
+
shadowCostMicros: outcome.shadowCostMicros ?? null,
|
|
124
|
+
quotaSource: outcome.quotaSource ?? null,
|
|
103
125
|
quota: outcome.quota,
|
|
104
126
|
usage: outcome.usage,
|
|
105
127
|
// CF-158: the gateway's own view of the same turns, off the terminal
|
|
@@ -151,6 +173,10 @@ export function buildSetupErrorResult({ sessionId, code, message, durationMs, wi
|
|
|
151
173
|
modelTier: null,
|
|
152
174
|
requestIds: [],
|
|
153
175
|
costMicros: null,
|
|
176
|
+
// No request was made, so the endpoint stated nothing (W16).
|
|
177
|
+
enforcement: null,
|
|
178
|
+
shadowCostMicros: null,
|
|
179
|
+
quotaSource: null,
|
|
154
180
|
quota: null,
|
|
155
181
|
usage: emptyUsage("estimated"),
|
|
156
182
|
// No request was made, so the gateway reported nothing (CF-158).
|
|
@@ -379,6 +379,14 @@ export function readCohortHeaders(headers) {
|
|
|
379
379
|
// Headers never carry usage — only the terminal frame does (CF-158). The key
|
|
380
380
|
// is present so the merged shape is uniform whether a frame arrived or not.
|
|
381
381
|
usage: null,
|
|
382
|
+
// §4.6 defines no header for any of these three either: only the terminal
|
|
383
|
+
// frame — and the identical object on a non-streamed response — carries
|
|
384
|
+
// them. Present and null so a caller reads one shape whether a frame
|
|
385
|
+
// arrived or not, and so "the endpoint did not say" is never silently the
|
|
386
|
+
// same value as a mode it did say.
|
|
387
|
+
enforcement: null,
|
|
388
|
+
shadowCostMicros: null,
|
|
389
|
+
quotaSource: null,
|
|
382
390
|
quota: {
|
|
383
391
|
windows,
|
|
384
392
|
creditsRemainingMicros: micros(credits, true),
|
|
@@ -400,6 +408,132 @@ function micros(v, signed) {
|
|
|
400
408
|
return (signed ? /^-?\d+$/ : /^\d+$/).test(s) ? s : null;
|
|
401
409
|
}
|
|
402
410
|
|
|
411
|
+
/**
|
|
412
|
+
* The billing modes the gateway names in the frame's `enforcement` field
|
|
413
|
+
* (design §4.8, shipped hq a60b9595). `on` is the only one under which a
|
|
414
|
+
* reported `cost_micros` is money that moved.
|
|
415
|
+
*
|
|
416
|
+
* A value outside this set reads as UNKNOWN (`null`), never as `"on"`: the
|
|
417
|
+
* field exists to say whether a figure is money, and promoting a word this
|
|
418
|
+
* client has never seen to `"on"` would put a counterfactual charge into a
|
|
419
|
+
* seat's spend — the very misread this contract was extended to prevent.
|
|
420
|
+
*/
|
|
421
|
+
export const ENFORCEMENT_MODES = new Set(["off", "shadow", "on"]);
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* `enforcement` as one of the three modes, or null for absent, non-string or
|
|
425
|
+
* unrecognised. PURE, never throws.
|
|
426
|
+
* @param {unknown} v @returns {'off'|'shadow'|'on'|null}
|
|
427
|
+
*/
|
|
428
|
+
export function readEnforcement(v) {
|
|
429
|
+
return typeof v === "string" && ENFORCEMENT_MODES.has(v) ? /** @type {any} */ (v) : null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The four answers `quota_source` splits a previously-ambiguous empty `quota`
|
|
434
|
+
* object into (design §4.8):
|
|
435
|
+
*
|
|
436
|
+
* ledger the ledger answered — this seat genuinely has no included
|
|
437
|
+
* allowance (a money-only band reports no windows by design)
|
|
438
|
+
* unreadable the quota read FAILED. An empty quota here is not headroom.
|
|
439
|
+
* not_read admission is off, so nothing was read.
|
|
440
|
+
* not_admitted a defensive fourth answer no served request produces today:
|
|
441
|
+
* read it as "unknown", not as a state to branch on.
|
|
442
|
+
*
|
|
443
|
+
* An unrecognised value is also unknown, and is reported as such rather than
|
|
444
|
+
* being rounded to the nearest known one.
|
|
445
|
+
*/
|
|
446
|
+
export const QUOTA_SOURCES = new Set(["ledger", "unreadable", "not_read", "not_admitted"]);
|
|
447
|
+
|
|
448
|
+
/** @param {unknown} v @returns {'ledger'|'unreadable'|'not_read'|'not_admitted'|null} */
|
|
449
|
+
export function readQuotaSource(v) {
|
|
450
|
+
return typeof v === "string" && QUOTA_SOURCES.has(v) ? /** @type {any} */ (v) : null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* `enforcement` as a CLOSED four-word vocabulary: one of the three modes, the
|
|
455
|
+
* client's own `"unknown"` when the endpoint stated something else, or null when
|
|
456
|
+
* it stated nothing. PURE, never throws.
|
|
457
|
+
*
|
|
458
|
+
* The fourth word exists so the seam does not erase the difference between "the
|
|
459
|
+
* endpoint said nothing" and "the endpoint said a word this client has never
|
|
460
|
+
* seen" — a gateway that ships a fourth mode would otherwise reach a seat as
|
|
461
|
+
* silence, and nobody would learn it had spoken. `"unknown"` can never be
|
|
462
|
+
* confused with `"on"`, which is the one guarantee that matters for money.
|
|
463
|
+
* @param {unknown} v @returns {'off'|'shadow'|'on'|'unknown'|null}
|
|
464
|
+
*/
|
|
465
|
+
export function readEnforcementState(v) {
|
|
466
|
+
return v == null || v === "" ? null : readEnforcement(v) ?? "unknown";
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* `quota_source` as the same closed vocabulary: one of the four §4.8 answers,
|
|
471
|
+
* `"unknown"` for anything else stated, null for silence. PURE.
|
|
472
|
+
* @param {unknown} v @returns {'ledger'|'unreadable'|'not_read'|'not_admitted'|'unknown'|null}
|
|
473
|
+
*/
|
|
474
|
+
export function readQuotaSourceState(v) {
|
|
475
|
+
return v == null || v === "" ? null : readQuotaSource(v) ?? "unknown";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Fold one request's stated `enforcement` into what a whole run has observed.
|
|
480
|
+
* PURE. Silence never changes what was already stated; a stated word this
|
|
481
|
+
* client does not know folds to `"unknown"` (distinct from "never stated");
|
|
482
|
+
* two different modes in one run fold to `"mixed"`.
|
|
483
|
+
* @param {'off'|'shadow'|'on'|'unknown'|'mixed'|null|undefined} prev
|
|
484
|
+
* @param {unknown} next
|
|
485
|
+
* @returns {'off'|'shadow'|'on'|'unknown'|'mixed'|null}
|
|
486
|
+
*/
|
|
487
|
+
export function foldEnforcement(prev, next) {
|
|
488
|
+
return fold(prev, next, readEnforcement);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Fold one request's stated `quota_source` into what a whole run has observed.
|
|
493
|
+
* Same rules as {@link foldEnforcement}.
|
|
494
|
+
* @param {'ledger'|'unreadable'|'not_read'|'not_admitted'|'unknown'|'mixed'|null|undefined} prev
|
|
495
|
+
* @param {unknown} next
|
|
496
|
+
* @returns {'ledger'|'unreadable'|'not_read'|'not_admitted'|'unknown'|'mixed'|null}
|
|
497
|
+
*/
|
|
498
|
+
export function foldQuotaSource(prev, next) {
|
|
499
|
+
return fold(prev, next, readQuotaSource);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Combine two ALREADY-FOLDED statements — each already one of the closed
|
|
504
|
+
* vocabulary's words, `"mixed"`, or null for "nothing stated". PURE.
|
|
505
|
+
*
|
|
506
|
+
* This is the fold one level up: a parent run folds its own requests with
|
|
507
|
+
* {@link foldEnforcement} (which reads RAW wire values), and then folds that
|
|
508
|
+
* result with each subagent's. Passing a folded `"mixed"` back through
|
|
509
|
+
* `foldEnforcement` would flatten it to `"unknown"`, because `"mixed"` is this
|
|
510
|
+
* client's word and not one the gateway ever sends.
|
|
511
|
+
*
|
|
512
|
+
* Silence never unsays a statement; two different statements are `"mixed"`.
|
|
513
|
+
* @template {string} T
|
|
514
|
+
* @param {T|'mixed'|null|undefined} a @param {T|'mixed'|null|undefined} b
|
|
515
|
+
* @returns {T|'mixed'|null}
|
|
516
|
+
*/
|
|
517
|
+
export function combineStated(a, b) {
|
|
518
|
+
const x = a ?? null;
|
|
519
|
+
const y = b ?? null;
|
|
520
|
+
if (y === null) return /** @type {any} */ (x);
|
|
521
|
+
if (x === null) return /** @type {any} */ (y);
|
|
522
|
+
return /** @type {any} */ (x === y ? x : "mixed");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* @param {any} prev @param {unknown} next @param {(v:unknown)=>string|null} read
|
|
527
|
+
* @returns {any}
|
|
528
|
+
*/
|
|
529
|
+
function fold(prev, next, read) {
|
|
530
|
+
const before = prev ?? null;
|
|
531
|
+
if (next == null || next === "") return before;
|
|
532
|
+
const m = read(next) ?? "unknown";
|
|
533
|
+
if (before === null) return m;
|
|
534
|
+
return before === m ? before : "mixed";
|
|
535
|
+
}
|
|
536
|
+
|
|
403
537
|
/** SSE event name of the gateway's terminal cost/quota frame. */
|
|
404
538
|
export const COHORT_FRAME_EVENT = "cohort";
|
|
405
539
|
|
|
@@ -435,6 +569,31 @@ export const COHORT_FRAME_EVENT = "cohort";
|
|
|
435
569
|
* nothing fails when it is absent. See docs/engine/eval.md §3; the fix for the
|
|
436
570
|
* contract itself belongs to hq, which this repo does not edit.
|
|
437
571
|
*
|
|
572
|
+
* ## W16 — `enforcement`, `shadow_cost_micros`, `quota_source` (hq a60b9595)
|
|
573
|
+
*
|
|
574
|
+
* The frame gained three fields, all normative in §4.8, all optional here:
|
|
575
|
+
*
|
|
576
|
+
* `enforcement` "off" | "shadow" | "on" — the billing mode that
|
|
577
|
+
* produced the two cost figures. Only under `on` is
|
|
578
|
+
* `cost_micros` money that moved. A word outside the
|
|
579
|
+
* three reaches the merged result as `"unknown"`, never
|
|
580
|
+
* as `"on"` and never as silence.
|
|
581
|
+
* `shadow_cost_micros` what enforcement WOULD have charged. It is a separate
|
|
582
|
+
* field on purpose and is carried under the separate key
|
|
583
|
+
* `shadowCostMicros`; it is never folded into
|
|
584
|
+
* `costMicros` and never counted as spend. Non-null only
|
|
585
|
+
* where the retail card is partial — null in production
|
|
586
|
+
* today, which has no published price book.
|
|
587
|
+
* `quota_source` "ledger" | "unreadable" | "not_read" | "not_admitted"
|
|
588
|
+
* — why `quota` is empty when it is. `{}` used to
|
|
589
|
+
* collapse "no allowance" and "the read failed", which
|
|
590
|
+
* call for opposite behaviour.
|
|
591
|
+
*
|
|
592
|
+
* Each is read through its own total parser ({@link readEnforcement},
|
|
593
|
+
* {@link readQuotaSource}, and `micros` for the figure): absent, wrong-typed
|
|
594
|
+
* and unrecognised all yield null, nothing throws, and an unrecognised
|
|
595
|
+
* `enforcement` is NEVER promoted to `"on"`.
|
|
596
|
+
*
|
|
438
597
|
* @param {string} data
|
|
439
598
|
* @returns {Partial<ReturnType<typeof readCohortHeaders>> & { quota?: any }}
|
|
440
599
|
*/
|
|
@@ -469,6 +628,18 @@ export function readCohortFrame(data) {
|
|
|
469
628
|
// CF-158: undocumented upstream, so absence is normal and never an error.
|
|
470
629
|
const usage = readFrameUsage(o.usage);
|
|
471
630
|
if (usage) patch.usage = usage;
|
|
631
|
+
// W16 (hq a60b9595): enforcement, shadow_cost_micros and quota_source. Each
|
|
632
|
+
// is read defensively and only SET when it is usable, so an omitted or
|
|
633
|
+
// unrecognised value leaves the merged value at null rather than overwriting
|
|
634
|
+
// a mode an earlier frame stated. `shadow_cost_micros` lands under a name
|
|
635
|
+
// that cannot be confused with `costMicros`, because the gateway's whole
|
|
636
|
+
// reason for splitting the fields is that one is money and the other is not.
|
|
637
|
+
const mode = readEnforcementState(o.enforcement);
|
|
638
|
+
if (mode) patch.enforcement = mode;
|
|
639
|
+
const shadow = micros(o.shadow_cost_micros, false);
|
|
640
|
+
if (shadow !== null) patch.shadowCostMicros = shadow;
|
|
641
|
+
const qs = readQuotaSourceState(o.quota_source);
|
|
642
|
+
if (qs) patch.quotaSource = qs;
|
|
472
643
|
return patch;
|
|
473
644
|
}
|
|
474
645
|
|
|
@@ -486,6 +657,14 @@ export function mergeCohort(base, patch) {
|
|
|
486
657
|
modelTier: patch.modelTier ?? base.modelTier,
|
|
487
658
|
costMicros: patch.costMicros ?? base.costMicros,
|
|
488
659
|
usage: patch.usage ?? base.usage ?? null,
|
|
660
|
+
// W16. `enforcement` qualifies `costMicros`: only under "on" is that figure
|
|
661
|
+
// money that moved. `shadowCostMicros` is what enforcement WOULD have
|
|
662
|
+
// charged and is never spend. `quotaSource` says why `quota` is empty when
|
|
663
|
+
// it is. null means the endpoint did not say — read as unknown, and NOT as
|
|
664
|
+
// a licence to assume "on"; the budget meter decides what to do with that.
|
|
665
|
+
enforcement: patch.enforcement ?? base.enforcement ?? null,
|
|
666
|
+
shadowCostMicros: patch.shadowCostMicros ?? base.shadowCostMicros ?? null,
|
|
667
|
+
quotaSource: patch.quotaSource ?? base.quotaSource ?? null,
|
|
489
668
|
quota: {
|
|
490
669
|
windows: { ...base.quota.windows, ...(pq.windows || {}) },
|
|
491
670
|
creditsRemainingMicros: pq.creditsRemainingMicros ?? base.quota.creditsRemainingMicros,
|
|
@@ -6,9 +6,19 @@
|
|
|
6
6
|
* POST <base>/cohort/v1/search
|
|
7
7
|
* Authorization: Bearer <seat token>
|
|
8
8
|
* body {"query":"…","maxResults":8,"allowedDomains":["…"],"blockedDomains":["…"]}
|
|
9
|
-
* 200 {"results":[{"title":"…","url":"…","snippet":"…"}],"costMicros":"1200"
|
|
9
|
+
* 200 {"results":[{"title":"…","url":"…","snippet":"…"}],"costMicros":"1200",
|
|
10
|
+
* "shadowCostMicros":"…|null","enforcement":"off|shadow|on"}
|
|
10
11
|
* (costMicros: decimal integer string of micro-USD, or a safe integer;
|
|
11
12
|
* `x-cohort-request-id` and `x-cohort-cost-micros` headers as on every route)
|
|
13
|
+
*
|
|
14
|
+
* **`costMicros` is money only under `enforcement: "on"` (W16, design §4.8).**
|
|
15
|
+
* The search body spells the three billing fields in camelCase because it is a
|
|
16
|
+
* plain JSON response rather than the `cohort` object, but they carry exactly
|
|
17
|
+
* the frame's meanings: under `shadow` a priced search settles for real and
|
|
18
|
+
* moves no money, and `shadowCostMicros` is what enforcement WOULD have charged.
|
|
19
|
+
* All three ride the returned `cohort` object, so the budget meter applies the
|
|
20
|
+
* same rule to a search as to a model call — a search's cost would otherwise be
|
|
21
|
+
* the one figure still counted as spend under shadow billing.
|
|
12
22
|
* 404 the gateway has no search → the tool reports search as unavailable
|
|
13
23
|
* 4xx/5xx refusals use the §4.6/§4.8 error bodies
|
|
14
24
|
*
|
|
@@ -19,7 +29,7 @@
|
|
|
19
29
|
* @module lib/engine/wire/search
|
|
20
30
|
*/
|
|
21
31
|
|
|
22
|
-
import { classifyHttpError, classifyTransportError, tokenFailureError, header } from "./errors.mjs";
|
|
32
|
+
import { classifyHttpError, classifyTransportError, tokenFailureError, header, readEnforcementState } from "./errors.mjs";
|
|
23
33
|
|
|
24
34
|
export const SEARCH_ROUTE = "/cohort/v1/search";
|
|
25
35
|
export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
|
|
@@ -41,7 +51,8 @@ function micros(v) {
|
|
|
41
51
|
/**
|
|
42
52
|
* Validate the 200 body (pure).
|
|
43
53
|
* @param {unknown} body
|
|
44
|
-
* @returns {{ok:true, results:Array<{title:string, url:string, snippet:string}>, costMicros:string|null
|
|
54
|
+
* @returns {{ok:true, results:Array<{title:string, url:string, snippet:string}>, costMicros:string|null,
|
|
55
|
+
* shadowCostMicros:string|null, enforcement:'off'|'shadow'|'on'|'unknown'|null}|{ok:false, message:string}}
|
|
45
56
|
*/
|
|
46
57
|
export function parseSearchBody(body) {
|
|
47
58
|
if (!body || typeof body !== "object" || !Array.isArray(/** @type any */ (body).results)) return { ok: false, message: "the search response has no results array" };
|
|
@@ -49,7 +60,15 @@ export function parseSearchBody(body) {
|
|
|
49
60
|
const results = b.results
|
|
50
61
|
.filter((r) => r && typeof r === "object" && typeof r.url === "string" && r.url !== "")
|
|
51
62
|
.map((r) => ({ title: typeof r.title === "string" ? r.title : r.url, url: r.url, snippet: typeof r.snippet === "string" ? r.snippet : "" }));
|
|
52
|
-
return {
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
results,
|
|
66
|
+
costMicros: micros(b.costMicros),
|
|
67
|
+
// W16. Read defensively and kept apart from the charge, exactly as on the
|
|
68
|
+
// frame: an unrecognised enforcement is "unknown", never "on".
|
|
69
|
+
shadowCostMicros: micros(b.shadowCostMicros),
|
|
70
|
+
enforcement: readEnforcementState(b.enforcement),
|
|
71
|
+
};
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
/**
|
|
@@ -65,8 +84,8 @@ export function parseSearchBody(body) {
|
|
|
65
84
|
* @param {AbortSignal} [p.signal]
|
|
66
85
|
* @param {number} [p.timeoutMs]
|
|
67
86
|
* @param {() => number} [p.now]
|
|
68
|
-
* @returns {Promise<{ok:true, results:Array<{title:string,url:string,snippet:string}>, cohort:{requestId:string|null, costMicros:string|null}}
|
|
69
|
-
* | {ok:false, error:{kind:string, code:string, status:number|null, message:string}, cohort:{requestId:string|null, costMicros:string|null}|null, accepted:boolean}>}
|
|
87
|
+
* @returns {Promise<{ok:true, results:Array<{title:string,url:string,snippet:string}>, cohort:{requestId:string|null, costMicros:string|null, shadowCostMicros:string|null, enforcement:string|null}}
|
|
88
|
+
* | {ok:false, error:{kind:string, code:string, status:number|null, message:string}, cohort:{requestId:string|null, costMicros:string|null, shadowCostMicros:string|null, enforcement:string|null}|null, accepted:boolean}>}
|
|
70
89
|
*/
|
|
71
90
|
export async function searchWeb(p) {
|
|
72
91
|
const fetchImpl = p.fetchImpl ?? globalThis.fetch;
|
|
@@ -106,7 +125,15 @@ export async function searchWeb(p) {
|
|
|
106
125
|
const err = classifyTransportError(e, controller.signal);
|
|
107
126
|
return { ok: false, error: { kind: err.kind, code: err.code, status: null, message: err.message }, cohort: null, accepted: false };
|
|
108
127
|
}
|
|
109
|
-
|
|
128
|
+
// §4.6 defines no header for the billing fields; only the body carries
|
|
129
|
+
// them. Null here so the shape is uniform on a refusal, which has no body
|
|
130
|
+
// to read them from.
|
|
131
|
+
const cohort = {
|
|
132
|
+
requestId: header(res.headers, "x-cohort-request-id"),
|
|
133
|
+
costMicros: micros(header(res.headers, "x-cohort-cost-micros")),
|
|
134
|
+
shadowCostMicros: null,
|
|
135
|
+
enforcement: null,
|
|
136
|
+
};
|
|
110
137
|
if (res.status === 401 && typeof p.token === "function" && !refresh) {
|
|
111
138
|
await res.body?.cancel().catch(() => {});
|
|
112
139
|
refresh = true;
|
|
@@ -130,7 +157,16 @@ export async function searchWeb(p) {
|
|
|
130
157
|
}
|
|
131
158
|
const parsed = parseSearchBody(json);
|
|
132
159
|
if (!parsed.ok) return { ok: false, error: { kind: "protocol", code: "search_protocol_error", status: res.status, message: parsed.message }, cohort, accepted: true };
|
|
133
|
-
return {
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
results: parsed.results.slice(0, body.maxResults),
|
|
163
|
+
cohort: {
|
|
164
|
+
requestId: cohort.requestId,
|
|
165
|
+
costMicros: parsed.costMicros ?? cohort.costMicros,
|
|
166
|
+
shadowCostMicros: parsed.shadowCostMicros,
|
|
167
|
+
enforcement: parsed.enforcement,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
134
170
|
}
|
|
135
171
|
return { ok: false, error: { kind: "auth", code: "http_401", status: 401, message: "the gateway refused the refreshed token" }, cohort: null, accepted: false };
|
|
136
172
|
} finally {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-md.mjs — reading a seat's CLAUDE.md into a prompt preamble.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS
|
|
5
|
+
* Both prompt planes scrape the seat's CLAUDE.md for house-style sections:
|
|
6
|
+
* the 60-second quick reply (scripts/daemon/responder.mjs#loadPreamble) and
|
|
7
|
+
* the full session (scripts/daemon/prompt-builder.mjs#extractPreamble). They
|
|
8
|
+
* had SEPARATE copies of the scrape, and only one of them was ever fixed — so
|
|
9
|
+
* the fix for "the preamble hands the model an unresolved identity template"
|
|
10
|
+
* landed on the quick reply while the session plane, which is the tier a
|
|
11
|
+
* substantive reply actually escalates to, kept every defect. One definition,
|
|
12
|
+
* two callers, is the only shape that cannot drift that way again.
|
|
13
|
+
*
|
|
14
|
+
* PURE. Every function here takes the file's text as a parameter and returns a
|
|
15
|
+
* string; nothing reads the filesystem, the clock or the environment, so what a
|
|
16
|
+
* given seat file yields is a unit test (claude-md.test.mjs).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A scaffold placeholder line — `*Configured by \`maestro setup\`*` and variants.
|
|
21
|
+
*
|
|
22
|
+
* The scaffold ships these under `## Company Context` and `### Autonomy Model`,
|
|
23
|
+
* and the scrape handed them to the model verbatim: a standing note, on every
|
|
24
|
+
* turn, that the seat's company context and autonomy bands are unconfigured.
|
|
25
|
+
* That is the same "you are not set up" signal that pushes a model into the
|
|
26
|
+
* generic-assistant register in which it introduces itself.
|
|
27
|
+
*/
|
|
28
|
+
export const SCAFFOLD_SENTINEL = /^\s*\*?\s*(?:Configured by|To be configured|TBD)\b[^\n]*$/i;
|
|
29
|
+
|
|
30
|
+
/** Markdown heading depth, 0 for a non-heading. @param {string} l @returns {number} */
|
|
31
|
+
function headingDepth(l) {
|
|
32
|
+
const m = /^(#{2,6})\s+\S/.exec(l);
|
|
33
|
+
return m ? m[1].length : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Drop scaffold placeholder lines, and any heading they leave empty. PURE.
|
|
38
|
+
*
|
|
39
|
+
* A heading with nothing under it says less than nothing, so it goes with its
|
|
40
|
+
* placeholder rather than standing as an unanswered promise.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function stripScaffoldSentinels(text) {
|
|
46
|
+
const kept = String(text || "").split("\n").filter((l) => !SCAFFOLD_SENTINEL.test(l));
|
|
47
|
+
// Second pass: a heading whose whole body was a sentinel is now empty.
|
|
48
|
+
const out = [];
|
|
49
|
+
for (let i = 0; i < kept.length; i++) {
|
|
50
|
+
const line = kept[i];
|
|
51
|
+
const depth = headingDepth(line);
|
|
52
|
+
if (depth) {
|
|
53
|
+
let j = i + 1;
|
|
54
|
+
while (j < kept.length && kept[j].trim() === "") j++;
|
|
55
|
+
// Empty only when EOF follows, or a heading at the SAME OR SHALLOWER
|
|
56
|
+
// level. A parent heading followed by its own subheading has a body.
|
|
57
|
+
const next = j < kept.length ? headingDepth(kept[j]) : 0;
|
|
58
|
+
if (j >= kept.length || (next && next <= depth)) continue;
|
|
59
|
+
}
|
|
60
|
+
out.push(line);
|
|
61
|
+
}
|
|
62
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scrape the named `##` sections out of a CLAUDE.md. PURE, so which sections a
|
|
67
|
+
* given seat file yields is a unit test.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} raw CLAUDE.md contents
|
|
70
|
+
* @param {string[]} targets `## ` headings to keep
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function scrapeClaudeMdSections(raw, targets) {
|
|
74
|
+
const sections = [];
|
|
75
|
+
let capturing = false;
|
|
76
|
+
for (const line of String(raw || "").split("\n")) {
|
|
77
|
+
if (targets.some((h) => line.startsWith(h))) { capturing = true; sections.push(line); continue; }
|
|
78
|
+
if (capturing && /^## [A-Z]/.test(line) && !targets.some((h) => line.startsWith(h))) { capturing = false; continue; }
|
|
79
|
+
if (capturing) sections.push(line);
|
|
80
|
+
}
|
|
81
|
+
return sections.join("\n").trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The `##` headings a preamble scrape should keep.
|
|
86
|
+
*
|
|
87
|
+
* `## Identity` is kept ONLY when no persona block rendered. On an enrolled
|
|
88
|
+
* seat the persona is resolved from config/agent.json and the CLAUDE.md
|
|
89
|
+
* `## Identity` section is still the scaffold template — unresolved
|
|
90
|
+
* `{{agent.fullName}}` tokens followed by "If those tokens are still
|
|
91
|
+
* unresolved, your identity has not been configured yet — run … maestro setup".
|
|
92
|
+
* Rendering both gives the model a correct identity immediately followed by a
|
|
93
|
+
* notice that its identity is unconfigured; two identity blocks, one of them
|
|
94
|
+
* unresolved, is worse than either alone.
|
|
95
|
+
*
|
|
96
|
+
* On an UNENROLLED seat there is no persona, and the scaffold section — tokens
|
|
97
|
+
* and all — is the only identity prose there is. It is restored deliberately:
|
|
98
|
+
* a prompt that says "run maestro setup" is the honest rendering of a seat
|
|
99
|
+
* that has not been set up.
|
|
100
|
+
*
|
|
101
|
+
* @param {boolean} hasPersona did renderSeatPersona() produce a block?
|
|
102
|
+
* @param {string[]} houseStyle extra `## ` headings this plane wants
|
|
103
|
+
* @returns {string[]}
|
|
104
|
+
*/
|
|
105
|
+
export function preambleTargets(hasPersona, houseStyle) {
|
|
106
|
+
return hasPersona ? [...houseStyle] : ["## Identity", ...houseStyle];
|
|
107
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* disclosure-instructions.mjs — a standing instruction to VOLUNTEER what the
|
|
3
|
+
* agent is must not reach a prompt, wherever it was written.
|
|
4
|
+
*
|
|
5
|
+
* WHAT BROKE
|
|
6
|
+
* Seats posted a self-introduction into the middle of live threads — a rendering
|
|
7
|
+
* of `channels.<c>.identity_line` from policies/ai-disclosure.yaml, the
|
|
8
|
+
* opener naming the agent's own nature and pairing it with its principal —
|
|
9
|
+
* on a release-status post and on a correction to the agent's
|
|
10
|
+
* OWN earlier figures, in the org's own #risk-compliance channel. Two causes
|
|
11
|
+
* were framework-side and are fixed in code (an unresolved identity template,
|
|
12
|
+
* and an unframed transcript). The THIRD is not framework-side at all:
|
|
13
|
+
*
|
|
14
|
+
* The preamble scrape copies `## Communication Rules` out of the SEAT'S OWN
|
|
15
|
+
* CLAUDE.md, verbatim. A seat file carrying a standing rule of the
|
|
16
|
+
* shape "on first message in any thread, introduce yourself as <the
|
|
17
|
+
* identity_line>" puts that line straight into every prompt the seat builds, and it survives every
|
|
18
|
+
* `maestro` upgrade — the repo cannot see it, and a repo test that renders the
|
|
19
|
+
* scaffold will never fail on it.
|
|
20
|
+
*
|
|
21
|
+
* So the check has to run at RUNTIME over the composed preamble, on the text
|
|
22
|
+
* that is actually about to be handed to the model, rather than in a test over
|
|
23
|
+
* a file the fleet does not use.
|
|
24
|
+
*
|
|
25
|
+
* WHAT THIS IS NOT
|
|
26
|
+
* It is NOT a disclosure policy and it must never weaken one. The truthfulness
|
|
27
|
+
* invariant — a sincere question about whether someone is an AI is answered
|
|
28
|
+
* plainly — lives in lib/identity/persona.mjs#voiceRules and
|
|
29
|
+
* policies/ai-disclosure.yaml, and is untouched here. What these patterns
|
|
30
|
+
* describe is the UNPROMPTED self-introduction: an instruction to open with,
|
|
31
|
+
* attach, or lead with a statement of what the agent is.
|
|
32
|
+
*
|
|
33
|
+
* Because of that distinction the patterns are deliberately narrow, and a line
|
|
34
|
+
* that FORBIDS the behaviour is kept rather than dropped ({@link PROHIBITION}):
|
|
35
|
+
* stripping "never introduce yourself as an AI" would remove a rule that says
|
|
36
|
+
* exactly what this module wants said. A pointer to the disclosure policy —
|
|
37
|
+
* "policies/ai-disclosure.yaml is the authority on where proactive disclosure
|
|
38
|
+
* is legally required" — is likewise a restraint, not an instruction, and is
|
|
39
|
+
* kept; every pattern below therefore requires an imperative verb rather than
|
|
40
|
+
* matching the bare words "proactive disclosure".
|
|
41
|
+
*
|
|
42
|
+
* PURE. No I/O, no clock, no env — the caller supplies the text and receives the
|
|
43
|
+
* text plus what was dropped, so the exact behaviour is a unit test.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Instructions to volunteer what the agent is.
|
|
48
|
+
*
|
|
49
|
+
* Deliberately narrower than "the word AI appears": the persona block's
|
|
50
|
+
* truthfulness bullet contains "AI" and MUST keep containing it.
|
|
51
|
+
*/
|
|
52
|
+
export const DISCLOSURE_INSTRUCTION_PATTERNS = Object.freeze([
|
|
53
|
+
// "state that you are an AI assistant", "mention you're a bot", …
|
|
54
|
+
/\b(?:state|disclose|declare|mention|note|announce|say|confirm)\b[^.\n]{0,60}\b(?:you are|you're|that you are|yourself as)\b[^.\n]{0,40}\b(?:an? AI\b|AI (?:assistant|agent)|artificial intelligence|a bot\b|a language model)/i,
|
|
55
|
+
// "introduce yourself", "identify yourself as …"
|
|
56
|
+
/\bintroduce yourself\b/i,
|
|
57
|
+
/\bidentify yourself as\b/i,
|
|
58
|
+
// Prose that IS the self-introduction, quoted as a model to copy.
|
|
59
|
+
/\ban AI (?:assistant|agent) working (?:with|for|alongside|on behalf of)\b/i,
|
|
60
|
+
// "add the identity line", "lead with an AI-disclosure statement", …
|
|
61
|
+
/\b(?:add|include|append|prepend|attach|open with|lead with|start with|begin with|preface \w+ with|end with|sign off with)\b[^.\n]{0,60}\b(?:identity line|proactive[_ ]disclosure|AI[- ]disclosure|disclosure (?:line|statement|notice))/i,
|
|
62
|
+
// The observed opener itself, as a template to follow.
|
|
63
|
+
/\bbefore we (?:get into it|begin|start)\b/i,
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A line that FORBIDS the behaviour rather than demanding it.
|
|
68
|
+
*
|
|
69
|
+
* Checked against the text BEFORE the match, so "Do not introduce yourself" is
|
|
70
|
+
* kept while "On first message, introduce yourself" is dropped. Deliberately
|
|
71
|
+
* generous — keeping a line the stripper was unsure about is the safe error,
|
|
72
|
+
* because the only cost of a kept prohibition is a duplicate of a rule the
|
|
73
|
+
* framework already states, while the cost of a dropped one is a rule lost.
|
|
74
|
+
*/
|
|
75
|
+
const PROHIBITION = /\b(?:do not|don'?t|never|no need to|must not|should not|avoid|without|rather than|instead of|refrain from|stop)\b/i;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Does this ONE line read as an instruction to volunteer what the agent is?
|
|
79
|
+
*
|
|
80
|
+
* @param {string} line
|
|
81
|
+
* @returns {RegExp|null} the pattern that matched, or null
|
|
82
|
+
*/
|
|
83
|
+
export function disclosureInstructionMatch(line) {
|
|
84
|
+
const text = String(line || "");
|
|
85
|
+
if (!text.trim()) return null;
|
|
86
|
+
for (const rx of DISCLOSURE_INSTRUCTION_PATTERNS) {
|
|
87
|
+
const m = rx.exec(text);
|
|
88
|
+
if (!m) continue;
|
|
89
|
+
// A prohibition anywhere before the match keeps the line.
|
|
90
|
+
if (PROHIBITION.test(text.slice(0, m.index))) continue;
|
|
91
|
+
return rx;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Drop every line of `text` that instructs the agent to introduce or disclose
|
|
98
|
+
* itself, and report what went.
|
|
99
|
+
*
|
|
100
|
+
* LINE GRANULARITY IS THE POINT. This runs over seat-authored prose whose shape
|
|
101
|
+
* nothing here controls; a line is the largest unit that can be removed without
|
|
102
|
+
* guessing where a rule begins and ends. A bullet with indented sub-bullets
|
|
103
|
+
* therefore loses only its own line, which reads as a truncated rule rather than
|
|
104
|
+
* a silently rewritten one — and `dropped` is returned so the caller can say so
|
|
105
|
+
* out loud instead of editing the operator's file behind their back.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} text
|
|
108
|
+
* @returns {{text: string, dropped: string[]}}
|
|
109
|
+
*/
|
|
110
|
+
export function stripDisclosureInstructions(text) {
|
|
111
|
+
const src = String(text || "");
|
|
112
|
+
if (!src) return { text: "", dropped: [] };
|
|
113
|
+
const dropped = [];
|
|
114
|
+
const kept = src.split("\n").filter((line) => {
|
|
115
|
+
if (!disclosureInstructionMatch(line)) return true;
|
|
116
|
+
dropped.push(line.trim());
|
|
117
|
+
return false;
|
|
118
|
+
});
|
|
119
|
+
return { text: kept.join("\n").replace(/\n{3,}/g, "\n\n"), dropped };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Strip, and WARN ONCE PER PROCESS per distinct line, naming the file the line
|
|
124
|
+
* came from. The seat operator cannot see this from the repo, so silence is the
|
|
125
|
+
* failure mode that let it run for as long as it did.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} text
|
|
128
|
+
* @param {string} source a human-readable provenance, e.g. "<seat>/CLAUDE.md"
|
|
129
|
+
* @param {(msg:string)=>void} [warn]
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
const _warned = new Set();
|
|
133
|
+
export function stripDisclosureInstructionsAndWarn(text, source, warn = console.error) {
|
|
134
|
+
const { text: out, dropped } = stripDisclosureInstructions(text);
|
|
135
|
+
for (const line of dropped) {
|
|
136
|
+
const key = `${source}::${line}`;
|
|
137
|
+
if (_warned.has(key)) continue;
|
|
138
|
+
_warned.add(key);
|
|
139
|
+
warn(
|
|
140
|
+
`[identity] dropped a self-introduction instruction from ${source}: ${JSON.stringify(line.slice(0, 200))}` +
|
|
141
|
+
` — agents do not announce what they are unprompted; edit that file to remove the line.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** For tests. */
|
|
148
|
+
export const _test = { PROHIBITION };
|