@cohortapp/agent-sdk 2.18.13 → 2.18.15
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 +58 -7
- package/docs/runbooks/recovery-and-failover.md +18 -0
- package/lib/assurance/batch.mjs +353 -0
- package/lib/assurance/first-reply.mjs +423 -0
- package/lib/assurance/notice-voice.mjs +357 -0
- package/lib/assurance/plan-note.mjs +43 -0
- package/lib/assurance/room-budget.mjs +55 -6
- 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 +59 -0
- 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/persona.mjs +31 -2
- 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/alerts.mjs +94 -0
- package/lib/telemetry/collect.mjs +155 -2
- package/lib/upgrade/pinned-drift.mjs +467 -0
- package/package.json +1 -1
- 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/daemon/agent-daemon.mjs +75 -5
- package/scripts/daemon/assurance.mjs +709 -44
- package/scripts/daemon/cadence-consumer.mjs +281 -34
- package/scripts/daemon/deliver.mjs +109 -0
- package/scripts/daemon/dispatcher.mjs +21 -3
- package/scripts/daemon/inbox-deferral.mjs +102 -9
- package/scripts/daemon/session-lock.mjs +41 -1
- package/scripts/emergency-stop.sh +114 -13
- package/scripts/fleet/rollout.mjs +256 -10
- package/scripts/healthcheck.sh +131 -33
- package/scripts/local-triggers/autoupdate.sh +144 -11
- 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 {
|
package/lib/identity/persona.mjs
CHANGED
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
|
|
43
43
|
import { readFileSync } from "fs";
|
|
44
44
|
import { join } from "path";
|
|
45
|
+
// A seat's config/agent.json is seat-local prose too — `maestro upgrade` never
|
|
46
|
+
// touches it — and its free-text fields render straight into the persona block
|
|
47
|
+
// at the TOP of every prompt both planes build. See scrubAgentConfig.
|
|
48
|
+
import { scrubSeatText } from "./disclosure-scrub.mjs";
|
|
45
49
|
|
|
46
50
|
/**
|
|
47
51
|
* Human-readable gloss for each archetype altitude band (archetypes/altitudes/).
|
|
@@ -349,13 +353,33 @@ export function configuredStr(v) {
|
|
|
349
353
|
return t;
|
|
350
354
|
}
|
|
351
355
|
|
|
352
|
-
/**
|
|
356
|
+
/**
|
|
357
|
+
* The free-text fields of config/agent.json — the ones that render as PROSE
|
|
358
|
+
* rather than as a name or a title, and so can carry a standing instruction.
|
|
359
|
+
*
|
|
360
|
+
* `persona`, `background` and `bio` reach `### Background` and the persona
|
|
361
|
+
* prose at the very top of every prompt, ahead of the framework's own rules.
|
|
362
|
+
* That is the path that made this list necessary: the CLAUDE.md scrape and the
|
|
363
|
+
* sender profiles were filtered first, a poisoned seat was rendered end to end,
|
|
364
|
+
* and the opener came out of `config/agent.json#persona` — a file nobody had
|
|
365
|
+
* thought of as prompt text because it looks like configuration.
|
|
366
|
+
*/
|
|
367
|
+
const AGENT_PROSE_FIELDS = ["companyDescription", "persona", "background", "bio"];
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Strip scaffold sentinels out of a parsed config/agent.json — and scrub the
|
|
371
|
+
* free-text fields, which are seat-local prose in an instruction position.
|
|
372
|
+
* @param {object} a @returns {object}
|
|
373
|
+
*/
|
|
353
374
|
export function scrubAgentConfig(a) {
|
|
354
375
|
const src = a && typeof a === "object" ? a : {};
|
|
355
376
|
const out = { ...src };
|
|
356
377
|
for (const k of ["firstName", "lastName", "fullName", "title", "company", "companyDescription", "persona", "background", "bio"]) {
|
|
357
378
|
if (k in out) out[k] = configuredStr(out[k]);
|
|
358
379
|
}
|
|
380
|
+
for (const k of AGENT_PROSE_FIELDS) {
|
|
381
|
+
if (typeof out[k] === "string" && out[k]) out[k] = scrubSeatText(out[k]).trim();
|
|
382
|
+
}
|
|
359
383
|
// A surname with no first name and no full name is not an identity — better
|
|
360
384
|
// to render no name at all than "You are AGENT." (the scaffold ships
|
|
361
385
|
// firstName "UNCONFIGURED" / lastName "AGENT").
|
|
@@ -375,6 +399,11 @@ export function scrubCompanyConfig(c) {
|
|
|
375
399
|
for (const k of ["name", "legalName", "description", "tagline", "industry", "stage"]) {
|
|
376
400
|
if (k in out) out[k] = configuredStr(out[k]);
|
|
377
401
|
}
|
|
402
|
+
// `description` and `tagline` render as prose in the persona block, the same
|
|
403
|
+
// as the agent's own free-text fields.
|
|
404
|
+
for (const k of ["description", "tagline"]) {
|
|
405
|
+
if (typeof out[k] === "string" && out[k]) out[k] = scrubSeatText(out[k]).trim();
|
|
406
|
+
}
|
|
378
407
|
return out;
|
|
379
408
|
}
|
|
380
409
|
|
|
@@ -408,4 +437,4 @@ export function renderSeatPersona(root, opts = {}) {
|
|
|
408
437
|
}
|
|
409
438
|
}
|
|
410
439
|
|
|
411
|
-
export const _test = { ALTITUDE_STANDING, personaProse, reportingLine, voiceRules, list, SELF_PRESENTATION_RULES };
|
|
440
|
+
export const _test = { ALTITUDE_STANDING, personaProse, reportingLine, voiceRules, list, SELF_PRESENTATION_RULES, AGENT_PROSE_FIELDS };
|
package/lib/org/quota.mjs
CHANGED
|
@@ -22,6 +22,20 @@
|
|
|
22
22
|
* Money stays a decimal string of integer micro-USD end to end; nothing here
|
|
23
23
|
* turns it into a float except the display helpers budget-guard calls.
|
|
24
24
|
*
|
|
25
|
+
* ## The one import from `lib/engine`, and why it is deliberate (W16)
|
|
26
|
+
*
|
|
27
|
+
* `readEnforcementState` / `readQuotaSourceState` below come from
|
|
28
|
+
* `lib/engine/wire/errors.mjs` — the only non-test import from `lib/org` into
|
|
29
|
+
* `lib/engine` in this repo. It is a considered choice, not an accident:
|
|
30
|
+
* `parseCohortSseFrame` here and `readCohortFrame` there are two parsers of the
|
|
31
|
+
* SAME `event: cohort` frame, and the §4.8 vocabularies they read
|
|
32
|
+
* (`off|shadow|on`, `ledger|unreadable|not_read|not_admitted`) are exactly the
|
|
33
|
+
* kind of pinned value set that drifts when it is written down twice — a
|
|
34
|
+
* gateway that adds a fourth mode would then reach one parser as a new word and
|
|
35
|
+
* the other as silence. The alternative was a duplicated set, i.e. the drift.
|
|
36
|
+
* If this layering is ever unwanted, move those two readers to a leaf module
|
|
37
|
+
* both layers may import; do NOT re-declare the value sets here.
|
|
38
|
+
*
|
|
25
39
|
* @module lib/org/quota
|
|
26
40
|
*/
|
|
27
41
|
|
|
@@ -31,6 +45,11 @@ import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
|
31
45
|
import { join, dirname, resolve } from "node:path";
|
|
32
46
|
|
|
33
47
|
import { writeJsonAtomic } from "../fs-atomic.mjs";
|
|
48
|
+
// W16: the §4.8 vocabularies for `enforcement` and `quota_source` are parsed in
|
|
49
|
+
// exactly one place, the engine's wire reader. Both helpers are pure and total.
|
|
50
|
+
// Restating the value sets here is how the two frame parsers in this repo would
|
|
51
|
+
// start disagreeing about what the gateway said.
|
|
52
|
+
import { readEnforcementState, readQuotaSourceState } from "../engine/wire/errors.mjs";
|
|
34
53
|
|
|
35
54
|
/** The cache may never be older than this (the pre-spawn gate reads it). */
|
|
36
55
|
export const QUOTA_MAX_TTL_MS = 30_000;
|
|
@@ -192,6 +211,14 @@ export function parseCohortSseFrame(input) {
|
|
|
192
211
|
requestId: str(obj.request_id) || str(obj.requestId),
|
|
193
212
|
modelTier: str(obj.model_tier) || str(obj.modelTier),
|
|
194
213
|
costMicros: micros(obj.cost_micros ?? obj.costMicros),
|
|
214
|
+
// W16 (hq a60b9595, design §4.8). `enforcement` qualifies `costMicros`:
|
|
215
|
+
// only under "on" did money move. `shadowCostMicros` is what enforcement
|
|
216
|
+
// WOULD have charged and is never a charge. `quotaSource` says why `quota`
|
|
217
|
+
// is empty when it is. Unrecognised or absent values read as null — an
|
|
218
|
+
// unknown `enforcement` is never promoted to "on".
|
|
219
|
+
enforcement: readEnforcementState(obj.enforcement),
|
|
220
|
+
shadowCostMicros: micros(obj.shadow_cost_micros ?? obj.shadowCostMicros),
|
|
221
|
+
quotaSource: readQuotaSourceState(obj.quota_source ?? obj.quotaSource),
|
|
195
222
|
funding: str(obj.funding),
|
|
196
223
|
windows,
|
|
197
224
|
usage: obj.usage && typeof obj.usage === "object" ? { ...obj.usage } : null,
|
package/lib/session/config.mjs
CHANGED
|
@@ -127,6 +127,10 @@ export function sessionPaths(agentRoot) {
|
|
|
127
127
|
upgradeNoticeFile: join(stateDir, "upgrade-notice.json"),
|
|
128
128
|
lastExitFile: join(stateDir, "last-exit"),
|
|
129
129
|
attentionFile: join(stateDir, "attention.json"),
|
|
130
|
+
// Consecutive identical launch failures, carried across supervisor
|
|
131
|
+
// lifetimes (lib/session/launch-failure.mjs). A counter held in memory is
|
|
132
|
+
// zero on every launch, which is why the bound never bound.
|
|
133
|
+
launchFailuresFile: join(stateDir, "launch-failures.json"),
|
|
130
134
|
daemonHealthFile: join(agentRoot, "state", "dashboards", "daemon-health.yaml"),
|
|
131
135
|
daemonPidFile: join(agentRoot, "state", "daemon.pid"),
|
|
132
136
|
configFile: join(agentRoot, CONFIG_REL),
|
package/lib/session/identity.mjs
CHANGED
|
@@ -65,6 +65,7 @@ export function parseMainSession(text) {
|
|
|
65
65
|
};
|
|
66
66
|
if (typeof raw.lastLaunchAt === "string") rec.lastLaunchAt = raw.lastLaunchAt;
|
|
67
67
|
if (typeof raw.rotatedFrom === "string") rec.rotatedFrom = raw.rotatedFrom;
|
|
68
|
+
if (typeof raw.rotatedReason === "string") rec.rotatedReason = raw.rotatedReason;
|
|
68
69
|
return rec;
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -178,38 +179,101 @@ export function recordLaunch(record, opts = {}) {
|
|
|
178
179
|
*
|
|
179
180
|
* relaunch — the session ended (cleanly, or after living past the window):
|
|
180
181
|
* exit 75 and let launchd bring it back with the same id.
|
|
181
|
-
* rotate —
|
|
182
|
+
* rotate — the resume target is unusable: mint a fresh id first.
|
|
182
183
|
* backoff — the rotation budget for this hour is spent: sleep, then exit 75.
|
|
183
184
|
*
|
|
184
185
|
* An UNKNOWN exit code (the exit file was not written) inside the window is
|
|
185
186
|
* treated as a failure: the cost of a wrong rotation is a fresh transcript,
|
|
186
187
|
* the cost of a wrong relaunch is a seat stuck in a 30-second crash loop.
|
|
187
188
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
189
|
+
* ── PROVEN OUTRANKS INFERRED, AND IS NOT RATIONED ───────────────────────────
|
|
190
|
+
* `resumeTargetMissing` is the runtime's own verdict, read off the screen by
|
|
191
|
+
* `launch-failure#classifyLaunchFailure` ("No conversation found with session
|
|
192
|
+
* ID: …") or off the disk by `resume-target#resumeTargetState`. When it is
|
|
193
|
+
* true, rotating is not a guess that might help — it is the ONLY repair, and
|
|
194
|
+
* it provably works. So it bypasses {@link MAX_ROTATIONS_PER_HOUR}, which
|
|
195
|
+
* exists to bound blind rotation. Leaving it inside the budget is precisely
|
|
196
|
+
* how James Kirkland's seat spent days relaunching a dead id on a ten-minute
|
|
197
|
+
* backoff: the budget throttled the one action that would have fixed it.
|
|
198
|
+
*
|
|
199
|
+
* ── `beatSeen` NARROWS THE GUESS, IT DOES NOT WIDEN IT ──────────────────────
|
|
200
|
+
* The twenty-second window is a proxy for "it never really started". A launch
|
|
201
|
+
* that DID write a heartbeat and then died inside that window did start, so it
|
|
202
|
+
* no longer rotates: its transcript is good and the fault is elsewhere. The
|
|
203
|
+
* converse is deliberately NOT symmetric — "ran an hour, never beat, unknown
|
|
204
|
+
* exit" is not evidence enough to destroy a transcript on, because a broken
|
|
205
|
+
* heartbeat writer looks exactly like it.
|
|
206
|
+
*
|
|
207
|
+
* The one place a never-beaten launch rotates outside the window is when it
|
|
208
|
+
* has ALREADY failed identically to the escalation limit (`streakAtLimit`):
|
|
209
|
+
* every other repair has been tried, the seat is escalating anyway, and a
|
|
210
|
+
* fresh id is the last thing left that could work. It is suppressed when WE
|
|
211
|
+
* ended the session (`selfStopped`: the watchdog restarting a wedged session),
|
|
212
|
+
* since that is exactly the case where the transcript is still good.
|
|
213
|
+
*
|
|
214
|
+
* @param {object} a
|
|
215
|
+
* @param {object} a.record
|
|
216
|
+
* @param {"session-id"|"resume"} a.mode
|
|
217
|
+
* @param {number|null} a.exitCode
|
|
218
|
+
* @param {number} a.startedAt
|
|
219
|
+
* @param {number} a.endedAt
|
|
220
|
+
* @param {boolean} [a.resumeTargetMissing] PROVEN: the conversation is not on this machine
|
|
221
|
+
* @param {boolean} [a.beatSeen] did this launch write a heartbeat? (undefined = could not tell)
|
|
222
|
+
* @param {boolean} [a.streakAtLimit] has this launch failed identically to the escalation limit?
|
|
223
|
+
* @param {boolean} [a.selfStopped] did the supervisor itself end the session?
|
|
224
|
+
* @param {number|Function} [a.now]
|
|
225
|
+
* @param {number} [a.failureWindowMs]
|
|
226
|
+
* @param {number} [a.maxRotationsPerHour]
|
|
227
|
+
* @returns {{action:"relaunch"}|{action:"rotate", proven:boolean, why:string}|{action:"backoff", sleepMs:number}}
|
|
190
228
|
*/
|
|
191
229
|
export function rotationDecision(a) {
|
|
192
230
|
const windowMs = a.failureWindowMs ?? FAILURE_WINDOW_MS;
|
|
193
231
|
const max = a.maxRotationsPerHour ?? MAX_ROTATIONS_PER_HOUR;
|
|
194
232
|
const durationMs = Number(a.endedAt) - Number(a.startedAt);
|
|
195
233
|
const failed = a.exitCode === null || a.exitCode === undefined || a.exitCode !== 0;
|
|
196
|
-
if (a.mode !== "resume"
|
|
234
|
+
if (a.mode !== "resume") return { action: "relaunch" };
|
|
235
|
+
if (a.resumeTargetMissing === true) {
|
|
236
|
+
return { action: "rotate", proven: true, why: "the conversation this launch resumed does not exist on this machine" };
|
|
237
|
+
}
|
|
238
|
+
if (!failed || a.selfStopped === true) return { action: "relaunch" };
|
|
239
|
+
const insideWindow = durationMs < windowMs && a.beatSeen !== true;
|
|
240
|
+
const lastResort = a.beatSeen === false && a.streakAtLimit === true;
|
|
241
|
+
if (!insideWindow && !lastResort) return { action: "relaunch" };
|
|
197
242
|
const t = nowMs(a.now);
|
|
198
243
|
const recent = (a.record && a.record.rotations || []).filter((r) => t - Date.parse(r) < HOUR_MS);
|
|
199
244
|
if (recent.length >= max) return { action: "backoff", sleepMs: BACKOFF_MS };
|
|
200
|
-
return {
|
|
245
|
+
return {
|
|
246
|
+
action: "rotate",
|
|
247
|
+
proven: false,
|
|
248
|
+
why: insideWindow
|
|
249
|
+
? "the resume died inside the failure window"
|
|
250
|
+
: "every recent launch has failed identically and never beaten — a fresh id is the last untried repair",
|
|
251
|
+
};
|
|
201
252
|
}
|
|
202
253
|
|
|
203
254
|
/**
|
|
204
255
|
* Pure: the record after a rotation — fresh id, zero resumes, this rotation
|
|
205
256
|
* stamped, and the rotation history pruned to the last hour.
|
|
206
|
-
*
|
|
257
|
+
*
|
|
258
|
+
* THIS IS BOTH HALVES OF THE REPAIR, AND THAT IS THE POINT. A dead resume id
|
|
259
|
+
* needs the stale record INVALIDATED (so nothing ever resumes it again) and a
|
|
260
|
+
* FRESH id to fall back to (so the next launch has somewhere to go). Doing
|
|
261
|
+
* only the first leaves the supervisor with no id and `newMainSession` would
|
|
262
|
+
* be minted anyway; doing only the second leaves the dead id in the file for
|
|
263
|
+
* the next reader. One write does both: the dead id survives only as
|
|
264
|
+
* `rotatedFrom`, which is provenance, not a target — nothing resumes it.
|
|
265
|
+
*
|
|
266
|
+
* `reason` is recorded so the next reader of the file (a person, `maestro
|
|
267
|
+
* session status`) can tell a proven repair from a heuristic one.
|
|
268
|
+
*
|
|
269
|
+
* @param {object} record @param {{now?:number|Function, uuid?:Function, reason?:string}} [opts]
|
|
207
270
|
*/
|
|
208
271
|
export function rotateMainSession(record, opts = {}) {
|
|
209
272
|
const t = nowMs(opts.now);
|
|
210
273
|
const fresh = newMainSession({ now: t, uuid: opts.uuid });
|
|
211
274
|
const kept = (record.rotations || []).filter((r) => t - Date.parse(r) < HOUR_MS);
|
|
212
|
-
|
|
275
|
+
const reason = typeof opts.reason === "string" && opts.reason.trim() ? opts.reason.trim().slice(0, 300) : "";
|
|
276
|
+
return { ...fresh, rotations: [...kept, iso(t)], rotatedFrom: record.sessionId, ...(reason ? { rotatedReason: reason } : {}) };
|
|
213
277
|
}
|
|
214
278
|
|
|
215
279
|
export default {
|