@cohortapp/agent-sdk 2.11.3 → 2.11.5
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/lib/budget-guard.mjs +35 -4
- package/lib/budget-guard.test.mjs +25 -0
- package/lib/resource-governor.mjs +70 -14
- package/lib/resource-governor.test.mjs +45 -18
- package/package.json +1 -1
- package/scripts/daemon/classifier.mjs +14 -3
- package/scripts/daemon/classifier.test.mjs +19 -0
- package/scripts/daemon/responder.mjs +8 -0
- package/scripts/daemon/typing-registry.mjs +9 -1
package/lib/budget-guard.mjs
CHANGED
|
@@ -663,7 +663,21 @@ export function dailyAllowance(deps) {
|
|
|
663
663
|
* @param {object} [deps] { now?, ledgerDir?, agentRoot?, capUSD?, iterationCap?,
|
|
664
664
|
* seatEnvelope?, mandateCachePath?, log? }
|
|
665
665
|
*/
|
|
666
|
+
/**
|
|
667
|
+
* Is this seat authenticated by a flat-fee Max SUBSCRIPTION (not the metered
|
|
668
|
+
* API)? A subscription seat has no dollar meter: the CLI's `total_cost_usd` is
|
|
669
|
+
* API-EQUIVALENT, not spend, and every session reports "unmeasured" by design —
|
|
670
|
+
* that is normal, not a telemetry outage. Banding such a seat on dollars
|
|
671
|
+
* suspends a seat that costs a flat fee, stops its autonomous work, and floods
|
|
672
|
+
* the log every tick. `deps.subscriptionAuth` overrides for tests.
|
|
673
|
+
*/
|
|
674
|
+
export function isSubscriptionAuth(deps) {
|
|
675
|
+
if (deps && typeof deps.subscriptionAuth === "boolean") return deps.subscriptionAuth;
|
|
676
|
+
return process.env.MAESTRO_PREFER_SUBSCRIPTION_AUTH === "1" && !process.env.ANTHROPIC_API_KEY;
|
|
677
|
+
}
|
|
678
|
+
|
|
666
679
|
export function dailyStatus(deps) {
|
|
680
|
+
const subscriptionAuth = isSubscriptionAuth(deps);
|
|
667
681
|
const iterationCap = cfgIterationCap(deps);
|
|
668
682
|
const today = sumToday(deps);
|
|
669
683
|
const { spentUSD, sessions } = today;
|
|
@@ -708,15 +722,23 @@ export function dailyStatus(deps) {
|
|
|
708
722
|
// The binding constraint is whichever is closest to (or furthest past) the cap.
|
|
709
723
|
// A null day-reading contributes nothing; the exhausted-envelope case is
|
|
710
724
|
// already ≥100 % on the month, so nothing is lost by skipping it.
|
|
711
|
-
|
|
725
|
+
// A subscription-auth seat has no dollar meter (see isSubscriptionAuth): its
|
|
726
|
+
// dollar "spend" is API-equivalent fiction, so banding on it wrongly suspends a
|
|
727
|
+
// flat-fee seat and halts its autonomous work. Band it on SESSION VOLUME only
|
|
728
|
+
// (a real limit); concurrency (resource-governor) + the 429 rate-breaker are
|
|
729
|
+
// its other guards.
|
|
730
|
+
const pct = subscriptionAuth
|
|
731
|
+
? iterPct
|
|
732
|
+
: Math.max(spendPct ?? 0, monthPct, iterPct);
|
|
712
733
|
let band = bandForPct(pct);
|
|
713
734
|
|
|
714
735
|
// ── BLIND ⇒ DEGRADE ────────────────────────────────────────────────────────
|
|
715
736
|
// `blind` (sessions ran, none measured) was computed and reported and enforced
|
|
716
737
|
// by nothing. It is now load-bearing: we do not invent a price, we lower the
|
|
717
738
|
// rung. Only when the day is TRULY unpriceable — if a month mean was available
|
|
718
|
-
// the spend was imputed from it and the ordinary band applies.
|
|
719
|
-
|
|
739
|
+
// the spend was imputed from it and the ordinary band applies. Does NOT apply
|
|
740
|
+
// to subscription auth, where "unmeasured" is the normal, expected state.
|
|
741
|
+
if (!subscriptionAuth && today.blind && today.unpriceable && band < BLIND_MIN_BAND) {
|
|
720
742
|
extraDegradations.push(
|
|
721
743
|
`band raised ${band}% → ${BLIND_MIN_BAND}%: ${today.unmeasuredSessions} session(s) ran today and NONE could be ` +
|
|
722
744
|
"measured or priced. An unmeasurable seat is degraded, not trusted."
|
|
@@ -725,7 +747,16 @@ export function dailyStatus(deps) {
|
|
|
725
747
|
}
|
|
726
748
|
const posture = postureForBand(band);
|
|
727
749
|
|
|
728
|
-
|
|
750
|
+
let degradations = [...today.degradations, ...allowance.degradations, ...extraDegradations];
|
|
751
|
+
// On a subscription seat the dollar-meter degradations (imputed / unmeasured /
|
|
752
|
+
// unfunded-envelope / telemetry) are irrelevant — there is no dollar meter —
|
|
753
|
+
// and were logged EVERY tick, burying real signal (a DM not answered) under a
|
|
754
|
+
// flood. Drop them; keep any non-dollar note (e.g. a malformed ledger line).
|
|
755
|
+
if (subscriptionAuth) {
|
|
756
|
+
degradations = degradations.filter(
|
|
757
|
+
(d) => !/imputed|unmeasured|unpriceable|UNFUNDED SEAT|envelope|per-session|telemetry|blind/i.test(String(d)),
|
|
758
|
+
);
|
|
759
|
+
}
|
|
729
760
|
// Degradations ride on the returned status AND go to the log: the daemon is
|
|
730
761
|
// usually the only thing awake when telemetry breaks, and a governor that
|
|
731
762
|
// quietly under-counts is the failure mode this module now exists to prevent.
|
|
@@ -338,3 +338,28 @@ test("a ledger of only zero-token rows sums to $0 (proves the bug is the input,
|
|
|
338
338
|
assert.equal(s.sessions, 2);
|
|
339
339
|
} finally { await rm(dir); }
|
|
340
340
|
});
|
|
341
|
+
|
|
342
|
+
test("dailyStatus: a subscription-auth seat is banded on session VOLUME, not dollars", async () => {
|
|
343
|
+
const dir = await makeLedgerDir();
|
|
344
|
+
try {
|
|
345
|
+
seedLedger(dir, [{ usd: 40 }]); // $40 of $10 = 400%
|
|
346
|
+
// A metered (API) seat: dollars bind → band 150 (refused).
|
|
347
|
+
assert.equal(dailyStatus({ ledgerDir: dir, now: clk, capUSD: 10, iterationCap: 100 }).band, 150);
|
|
348
|
+
// A flat-fee subscription seat has no dollar meter — the same API-equivalent
|
|
349
|
+
// "spend" is fiction. It lands at band 0 / normal so autonomous work keeps
|
|
350
|
+
// running; only session VOLUME can band it.
|
|
351
|
+
const sub = dailyStatus({ ledgerDir: dir, now: clk, capUSD: 10, iterationCap: 100, subscriptionAuth: true });
|
|
352
|
+
assert.equal(sub.band, 0);
|
|
353
|
+
assert.equal(sub.mode, "normal");
|
|
354
|
+
} finally { await rm(dir); }
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("dailyStatus: a subscription-auth seat STILL bands on session volume (a real limit)", async () => {
|
|
358
|
+
const dir = await makeLedgerDir();
|
|
359
|
+
try {
|
|
360
|
+
seedLedger(dir, Array.from({ length: 12 }, () => ({ usd: 0.01 }))); // 12 sessions
|
|
361
|
+
const sub = dailyStatus({ ledgerDir: dir, now: clk, capUSD: 1000, iterationCap: 10, subscriptionAuth: true });
|
|
362
|
+
assert.equal(sub.band, 100, "12 sessions / cap 10 = 120% → degrade even on subscription");
|
|
363
|
+
assert.equal(sub.mode, "degraded");
|
|
364
|
+
} finally { await rm(dir); }
|
|
365
|
+
});
|
|
@@ -16,14 +16,26 @@
|
|
|
16
16
|
* never dropped — and the agent throttles instead of bricking.
|
|
17
17
|
*
|
|
18
18
|
* Dynamic ceiling (replaces the hardcoded MAX_CONCURRENT=10):
|
|
19
|
-
* dynamicMax = clamp(floor(totalmem * 0.60 / 750MB), 1, min(
|
|
19
|
+
* dynamicMax = clamp(floor(totalmem * 0.60 / 750MB), 1, min(HARD_MAX, cpus))
|
|
20
20
|
* effectiveMax = min(dynamicMax, throttleCeiling)
|
|
21
21
|
* where throttleCeiling comes from state/throttle.json (written by the memory
|
|
22
22
|
* watchdog's SOFT tier; auto-expiring). The governor only READS that file —
|
|
23
|
-
* it never writes it.
|
|
23
|
+
* it never writes it. HARD_MAX defaults to 4, not 10: the binding constraint on
|
|
24
|
+
* a subscription-auth seat is the ONE Max subscription, which cannot serve many
|
|
25
|
+
* parallel `claude --print` calls — ten concurrent sessions starved the reactive
|
|
26
|
+
* quick-reply's claude call into a 60s timeout. API/multi-key deployments raise
|
|
27
|
+
* it with GOV_HARD_MAX.
|
|
28
|
+
*
|
|
29
|
+
* The concurrency ceiling is PER SOURCE, because a dispatch slot is not the
|
|
30
|
+
* scarce resource — the subscription + host CPU are. Reactive work (inbox / a
|
|
31
|
+
* direct human or agent reply) may use the WHOLE envelope; self-directed backlog
|
|
32
|
+
* is held REACTIVE_RESERVE (default 2) slots short so a reply can always be
|
|
33
|
+
* GENERATED, not merely dispatched. This is the governor-level twin of the
|
|
34
|
+
* dispatcher's RESERVED_INBOX_SLOTS (which reserves a dispatch slot).
|
|
24
35
|
*
|
|
25
36
|
* Thresholds (env GOV_* overridable; tests inject `deps` instead):
|
|
26
|
-
* liveCount >= effectiveMax
|
|
37
|
+
* inbox liveCount >= effectiveMax → QUEUE
|
|
38
|
+
* backlog liveCount >= effectiveMax - REACTIVE_RESERVE → QUEUE
|
|
27
39
|
* freemem < totalmem*0.15 OR < 2*750MB headroom → DEFER
|
|
28
40
|
* loadavg1 > cpus*1.5 → DEFER
|
|
29
41
|
* liveRSS + 750MB > 0.60*totalmem → QUEUE
|
|
@@ -31,7 +43,9 @@
|
|
|
31
43
|
*
|
|
32
44
|
* Plus the budget ladder (`mode`, from `lib/budget-guard.dailyStatus().mode`),
|
|
33
45
|
* banded against the seat's hq-funded envelope rather than a local config cap:
|
|
34
|
-
* degraded (≥100%) source==="backlog"
|
|
46
|
+
* degraded (≥100%) source==="backlog" → THROTTLE to DEGRADED_BACKLOG_MAX
|
|
47
|
+
* (default 1) — a trickle in idle gaps,
|
|
48
|
+
* not a stop (owner instruction).
|
|
35
49
|
* suspended (≥125%) source==="backlog"|"cadence" → DEFER
|
|
36
50
|
* refused (>150%) anything that is not a direct human reply → DEFER
|
|
37
51
|
* Inbox always goes through the normal ADMIT/QUEUE path — never go dark on the
|
|
@@ -64,8 +78,29 @@ export const RAM_FRACTION = frac(process.env.GOV_RAM_FRACTION, 0.6);
|
|
|
64
78
|
export const FREEMEM_FLOOR_FRACTION = frac(process.env.GOV_FREEMEM_FLOOR, 0.15);
|
|
65
79
|
/** Load-average multiplier over cpu count that triggers a DEFER. */
|
|
66
80
|
export const LOAD_FACTOR = num(process.env.GOV_LOAD_FACTOR, 1.5);
|
|
67
|
-
/**
|
|
68
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Hard ceiling on dynamicMax regardless of RAM. The binding constraint on a
|
|
83
|
+
* subscription-auth seat is NOT host RAM — it is the ONE Max subscription, which
|
|
84
|
+
* cannot serve many parallel `claude --print` calls without throttling. Ten
|
|
85
|
+
* concurrent backlog sessions starved the reactive quick-reply's claude call
|
|
86
|
+
* into a 60s timeout (observed on A016/isla). Default is subscription-safe;
|
|
87
|
+
* API/multi-key deployments raise it with GOV_HARD_MAX.
|
|
88
|
+
*/
|
|
89
|
+
export const HARD_MAX = num(process.env.GOV_HARD_MAX, 4);
|
|
90
|
+
/**
|
|
91
|
+
* Slots the governor holds back from self-directed (backlog/cadence) work so a
|
|
92
|
+
* human/agent reply always has subscription + host headroom to GENERATE, not
|
|
93
|
+
* merely a dispatch slot. The dispatcher's RESERVED_INBOX_SLOTS reserves a
|
|
94
|
+
* dispatch slot; this reserves the scarce resource that actually timed out.
|
|
95
|
+
*/
|
|
96
|
+
export const REACTIVE_RESERVE = num(process.env.GOV_REACTIVE_RESERVE, 2);
|
|
97
|
+
/**
|
|
98
|
+
* The thin trickle of self-directed work a DEGRADED/unfunded seat still runs.
|
|
99
|
+
* Owner instruction (2026-08-25): throttle backlog on an unfunded seat, do not
|
|
100
|
+
* stop it — autonomous progress keeps ticking in idle gaps while the inbox lane
|
|
101
|
+
* gets near-all capacity.
|
|
102
|
+
*/
|
|
103
|
+
export const DEGRADED_BACKLOG_MAX = num(process.env.GOV_DEGRADED_BACKLOG_MAX, 1);
|
|
69
104
|
/** Minimum dynamicMax (always allow at least one session). */
|
|
70
105
|
const MIN_MAX = 1;
|
|
71
106
|
|
|
@@ -348,15 +383,36 @@ export function admit(req = {}, deps) {
|
|
|
348
383
|
if (mode === "suspended" && (source === "backlog" || source === "cadence")) {
|
|
349
384
|
return decide(DEFER, "budget suspended (>=125% of the seat envelope): deferring non-inbox work");
|
|
350
385
|
}
|
|
351
|
-
|
|
352
|
-
|
|
386
|
+
// NOTE: `degraded` (>=100% / unfunded) no longer hard-stops backlog. Owner
|
|
387
|
+
// instruction (2026-08-25): THROTTLE, don't stop — the seat keeps a thin
|
|
388
|
+
// trickle of self-directed work so autonomous progress never fully halts,
|
|
389
|
+
// while the inbox lane gets near-all capacity. Enforced as a lowered
|
|
390
|
+
// per-source concurrency ceiling immediately below, not a DEFER.
|
|
391
|
+
|
|
392
|
+
// (1) Concurrency ceiling → QUEUE (work re-derivable from the queue). The
|
|
393
|
+
// ceiling is PER SOURCE, because a dispatch slot is not the scarce
|
|
394
|
+
// resource — the single Max subscription + host CPU are. Reactive work
|
|
395
|
+
// (inbox / a direct human or agent reply) may use the WHOLE envelope;
|
|
396
|
+
// self-directed backlog is held REACTIVE_RESERVE slots short so a reply
|
|
397
|
+
// can always be GENERATED (10 parallel sessions once starved the
|
|
398
|
+
// quick-reply's claude call into a 60s timeout). A DEGRADED/unfunded seat
|
|
399
|
+
// throttles backlog to DEGRADED_BACKLOG_MAX — a trickle in idle gaps.
|
|
400
|
+
let sourceCeiling;
|
|
401
|
+
if (humanReply) {
|
|
402
|
+
sourceCeiling = effectiveMax;
|
|
403
|
+
} else if (mode === "degraded" && source === "backlog") {
|
|
404
|
+
sourceCeiling = Math.min(effectiveMax, DEGRADED_BACKLOG_MAX);
|
|
405
|
+
} else {
|
|
406
|
+
sourceCeiling = Math.max(1, effectiveMax - REACTIVE_RESERVE);
|
|
353
407
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
408
|
+
if (liveCount >= sourceCeiling) {
|
|
409
|
+
const why = mode === "degraded" && source === "backlog"
|
|
410
|
+
? `budget degraded: self-directed backlog throttled to ${sourceCeiling} (${liveCount} live; inbox unaffected)`
|
|
411
|
+
: throttleCeiling < dMax
|
|
412
|
+
? `at throttled ceiling (${liveCount}/${sourceCeiling}; soft-throttle active)`
|
|
413
|
+
: source === "inbox"
|
|
414
|
+
? `at concurrency ceiling (${liveCount}/${sourceCeiling})`
|
|
415
|
+
: `self-directed work yields ${REACTIVE_RESERVE} slots to the inbox (${liveCount}/${sourceCeiling})`;
|
|
360
416
|
return decide(QUEUE, why);
|
|
361
417
|
}
|
|
362
418
|
|
|
@@ -68,14 +68,16 @@ function deps(over = {}) {
|
|
|
68
68
|
// ---------------------------------------------------------------------------
|
|
69
69
|
|
|
70
70
|
test("dynamicMax sizes by RAM, clamped to min(HARD_MAX, cpus)", () => {
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
assert.equal(dynamicMax({ totalmem:
|
|
75
|
-
//
|
|
76
|
-
assert.equal(dynamicMax({ totalmem:
|
|
77
|
-
//
|
|
78
|
-
assert.equal(dynamicMax({ totalmem:
|
|
71
|
+
// HARD_MAX is 4 (subscription-safe): the single Max subscription, not host RAM,
|
|
72
|
+
// is the binding constraint, so anything with a few GB clamps to 4.
|
|
73
|
+
// 4GB: byRam=floor(4*0.6/0.75)=3 → RAM-bound, below HARD_MAX → 3.
|
|
74
|
+
assert.equal(dynamicMax({ totalmem: 4 * GB, cpus: 8 }), 3);
|
|
75
|
+
// 8GB: byRam=floor(8*0.6/0.75)=6 → clamp to min(4,8)=4 → HARD_MAX-bound.
|
|
76
|
+
assert.equal(dynamicMax({ totalmem: 8 * GB, cpus: 8 }), 4);
|
|
77
|
+
// 16GB with 16 cpus: byRam=12 → clamp to min(4,16)=4.
|
|
78
|
+
assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 16 }), 4);
|
|
79
|
+
// cpu-bound below HARD_MAX: 16GB with 2 cpus → min(4,2)=2.
|
|
80
|
+
assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 2 }), 2);
|
|
79
81
|
// Tiny box always yields at least 1.
|
|
80
82
|
assert.equal(dynamicMax({ totalmem: 1 * GB, cpus: 1 }), 1);
|
|
81
83
|
});
|
|
@@ -91,8 +93,8 @@ test("dynamicMax never returns 0 even on a starved box", () => {
|
|
|
91
93
|
test("admit: healthy box with spare capacity → ADMIT", () => {
|
|
92
94
|
const r = admit({ source: "backlog" }, deps());
|
|
93
95
|
assert.equal(r.decision, DECISIONS.ADMIT);
|
|
94
|
-
assert.equal(r.snapshot.dynamicMax,
|
|
95
|
-
assert.equal(r.snapshot.effectiveMax,
|
|
96
|
+
assert.equal(r.snapshot.dynamicMax, 4);
|
|
97
|
+
assert.equal(r.snapshot.effectiveMax, 4);
|
|
96
98
|
});
|
|
97
99
|
|
|
98
100
|
test("admit: inbox on a healthy box → ADMIT", () => {
|
|
@@ -104,9 +106,23 @@ test("admit: inbox on a healthy box → ADMIT", () => {
|
|
|
104
106
|
// QUEUE — concurrency ceiling
|
|
105
107
|
// ---------------------------------------------------------------------------
|
|
106
108
|
|
|
107
|
-
test("admit:
|
|
108
|
-
// effectiveMax =
|
|
109
|
-
|
|
109
|
+
test("admit: backlog stops REACTIVE_RESERVE slots short of the ceiling → QUEUE", () => {
|
|
110
|
+
// effectiveMax = 4; self-directed backlog may use effectiveMax - REACTIVE_RESERVE(2)
|
|
111
|
+
// = 2. With 2 already live, backlog QUEUEs — holding 2 slots for the inbox so a
|
|
112
|
+
// reply can always be GENERATED (subscription + host headroom, not just a slot).
|
|
113
|
+
const r = admit({ source: "backlog" }, deps({ liveClaude: { count: 2, rssMB: 500 } }));
|
|
114
|
+
assert.equal(r.decision, DECISIONS.QUEUE);
|
|
115
|
+
assert.match(r.reason, /yields 2 slots to the inbox/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("admit: inbox may use the whole envelope; the reactive reserve is for it", () => {
|
|
119
|
+
// 2 live (backlog's reserved cap) but INBOX still ADMITs — the reserve is its.
|
|
120
|
+
assert.equal(
|
|
121
|
+
admit({ source: "inbox" }, deps({ liveClaude: { count: 2, rssMB: 500 } })).decision,
|
|
122
|
+
DECISIONS.ADMIT,
|
|
123
|
+
);
|
|
124
|
+
// Only at the full envelope (effectiveMax=4 live) does even the inbox QUEUE.
|
|
125
|
+
const r = admit({ source: "inbox" }, deps({ liveClaude: { count: 4, rssMB: 1000 } }));
|
|
110
126
|
assert.equal(r.decision, DECISIONS.QUEUE);
|
|
111
127
|
assert.match(r.reason, /concurrency ceiling/);
|
|
112
128
|
});
|
|
@@ -125,9 +141,10 @@ test("admit: throttle ceiling clamps effectiveMax below dynamicMax → QUEUE ear
|
|
|
125
141
|
|
|
126
142
|
test("admit: projected RSS over the RAM fraction → QUEUE", () => {
|
|
127
143
|
// 16GB → RAM fraction cap = 16*0.6*1024 ≈ 9830MB. Live RSS 9500MB + 750 > cap.
|
|
128
|
-
// Keep count below
|
|
144
|
+
// Keep count below the reserved backlog ceiling (2) so concurrency passes and
|
|
145
|
+
// RSS is the trigger.
|
|
129
146
|
const r = admit({ source: "backlog" }, deps({
|
|
130
|
-
liveClaude: { count:
|
|
147
|
+
liveClaude: { count: 1, rssMB: 9500 },
|
|
131
148
|
freemem: 16 * GB * 0.5,
|
|
132
149
|
}));
|
|
133
150
|
assert.equal(r.decision, DECISIONS.QUEUE);
|
|
@@ -287,9 +304,19 @@ test("admit reads a real throttle.json from agentRoot and clamps", async () => {
|
|
|
287
304
|
// the budget ladder (banded against the seat's hq-funded envelope)
|
|
288
305
|
// ---------------------------------------------------------------------------
|
|
289
306
|
|
|
290
|
-
test("degraded (>=100%): self-directed backlog
|
|
291
|
-
|
|
292
|
-
|
|
307
|
+
test("degraded (>=100%): self-directed backlog is THROTTLED (not stopped), obligations + inbox continue", () => {
|
|
308
|
+
// Owner instruction (2026-08-25): throttle, don't stop. A degraded/unfunded seat
|
|
309
|
+
// runs DEGRADED_BACKLOG_MAX(1) backlog in idle gaps — with the box idle it ADMITs
|
|
310
|
+
// one so autonomous progress keeps ticking...
|
|
311
|
+
assert.equal(
|
|
312
|
+
admit({ source: "backlog", mode: "degraded" }, deps({ liveClaude: { count: 0, rssMB: 0 } })).decision,
|
|
313
|
+
"ADMIT",
|
|
314
|
+
);
|
|
315
|
+
// ...but with anything already live it QUEUEs — a trickle, never a flood.
|
|
316
|
+
const r = admit({ source: "backlog", mode: "degraded" }, deps({ liveClaude: { count: 1, rssMB: 300 } }));
|
|
317
|
+
assert.equal(r.decision, "QUEUE");
|
|
318
|
+
assert.match(r.reason, /throttled/);
|
|
319
|
+
// An obligation is not discretionary — it degrades, it does not stop.
|
|
293
320
|
assert.equal(admit({ source: "cadence", mode: "degraded" }, deps()).decision, "ADMIT",
|
|
294
321
|
"an obligation is not discretionary — it degrades, it does not stop");
|
|
295
322
|
assert.equal(admit({ source: "inbox", mode: "degraded" }, deps()).decision, "ADMIT");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.5",
|
|
4
4
|
"description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -384,7 +384,7 @@ function formatItemPrompt(item) {
|
|
|
384
384
|
|
|
385
385
|
// ── Parse LLM response into classification object ───────────────────────────
|
|
386
386
|
|
|
387
|
-
function parseClassification(text) {
|
|
387
|
+
export function parseClassification(text) {
|
|
388
388
|
// Strip markdown code fences if present
|
|
389
389
|
const cleaned = text.replace(/^```(?:json)?\s*/m, "").replace(/\s*```$/m, "").trim();
|
|
390
390
|
let parsed;
|
|
@@ -408,13 +408,24 @@ function parseClassification(text) {
|
|
|
408
408
|
const validModels = ["opus", "sonnet"];
|
|
409
409
|
const validCategories = ["action_required", "fyi", "ignore"];
|
|
410
410
|
|
|
411
|
+
const directedAtAgent = typeof parsed.directed_at_agent === "boolean" ? parsed.directed_at_agent : false;
|
|
412
|
+
let priority = validPriorities.includes(parsed.priority) ? parsed.priority : "normal";
|
|
413
|
+
// A message the classifier is confident is directed AT this agent (a DM or an
|
|
414
|
+
// @mention) is inherently latency-sensitive — a human or a peer agent is
|
|
415
|
+
// waiting on the reply. Floor it at "high" so the dispatcher's priority lane
|
|
416
|
+
// PREEMPTS self-directed backlog for it (dispatcher.isPriorityInbox gates on
|
|
417
|
+
// critical|high). Without this, a peer agent's routine question classified
|
|
418
|
+
// "normal" would queue behind a wall of the seat's own backlog and read as "no
|
|
419
|
+
// reply". Never downgrade critical; never touch an "ignore".
|
|
420
|
+
if (directedAtAgent && priority === "normal") priority = "high";
|
|
421
|
+
|
|
411
422
|
return {
|
|
412
|
-
priority
|
|
423
|
+
priority,
|
|
413
424
|
action: validActions.includes(parsed.action) ? parsed.action : "queue",
|
|
414
425
|
model: validModels.includes(parsed.model) ? parsed.model : "sonnet",
|
|
415
426
|
summary: typeof parsed.summary === "string" ? parsed.summary : "Unclassified message",
|
|
416
427
|
category: validCategories.includes(parsed.category) ? parsed.category : "fyi",
|
|
417
|
-
directed_at_agent:
|
|
428
|
+
directed_at_agent: directedAtAgent, // default false — don't respond unless we're confident the message is for this agent
|
|
418
429
|
// A directly-answerable question gets a quick reply instead of a session +
|
|
419
430
|
// holding ack (see responder.isQuickReply). Default false — when the field
|
|
420
431
|
// is absent or non-boolean, take the session path, never guess "answerable".
|
|
@@ -245,3 +245,22 @@ test("reaction kind short-circuits before any LLM tier (no fetch)", async () =>
|
|
|
245
245
|
assert.equal(out.action, "archive");
|
|
246
246
|
assert.equal(out.directed_at_agent, false);
|
|
247
247
|
});
|
|
248
|
+
|
|
249
|
+
test("parseClassification floors a directed-at-agent message at 'high' so it preempts backlog", () => {
|
|
250
|
+
// A directed DM/mention is latency-sensitive — someone (human OR peer agent)
|
|
251
|
+
// is waiting. Bumping normal→high lets dispatcher.isPriorityInbox PREEMPT
|
|
252
|
+
// self-directed backlog for it, so a peer's routine question isn't starved.
|
|
253
|
+
const directedNormal = classifier.parseClassification(
|
|
254
|
+
'{"priority":"normal","action":"respond","model":"sonnet","summary":"peer asks for status","category":"action_required","directed_at_agent":true,"answerable":false}'
|
|
255
|
+
);
|
|
256
|
+
assert.equal(directedNormal.priority, "high");
|
|
257
|
+
assert.equal(directedNormal.directed_at_agent, true);
|
|
258
|
+
|
|
259
|
+
// Ambient (not directed) work stays normal — no preemption for the firehose.
|
|
260
|
+
const ambient = classifier.parseClassification('{"priority":"normal","action":"archive","directed_at_agent":false}');
|
|
261
|
+
assert.equal(ambient.priority, "normal");
|
|
262
|
+
|
|
263
|
+
// Critical is never downgraded, and an "ignore" is never escalated.
|
|
264
|
+
assert.equal(classifier.parseClassification('{"priority":"critical","directed_at_agent":true}').priority, "critical");
|
|
265
|
+
assert.equal(classifier.parseClassification('{"priority":"ignore","directed_at_agent":true}').priority, "ignore");
|
|
266
|
+
});
|
|
@@ -55,6 +55,7 @@ import { rungById } from "../../lib/execution/route.mjs";
|
|
|
55
55
|
// put an exact string in front of a human with no model in the loop, which is
|
|
56
56
|
// the property the acknowledgement path needed and did not have.
|
|
57
57
|
import { deliver, deliverWithRetry, resolveSlackChannel } from "./deliver.mjs";
|
|
58
|
+
import { startTyping, stopTyping } from "./typing-registry.mjs";
|
|
58
59
|
// The acknowledgement is now COMPOSED, not generated. See assurance.mjs for why
|
|
59
60
|
// (in short: a 60s `claude --print` spawn to write "let me look into it" lost
|
|
60
61
|
// its own race 2 times in 3, and lost it silently).
|
|
@@ -855,6 +856,11 @@ export async function sendQuickResponse(item, classResult, routed = null) {
|
|
|
855
856
|
: null;
|
|
856
857
|
|
|
857
858
|
try {
|
|
859
|
+
// Show "is composing…" on the DM the instant we start generating. The quick
|
|
860
|
+
// path used to be silent — a human saw nothing until the reply landed, or
|
|
861
|
+
// (under load, when generation timed out) nothing at all. Best-effort;
|
|
862
|
+
// cleared in the finally so "composing" never outlives the turn.
|
|
863
|
+
startTyping(item);
|
|
858
864
|
const text = await _generateResponse(item, classResult);
|
|
859
865
|
|
|
860
866
|
// Validate before sending — block replies that violate critical rules
|
|
@@ -951,6 +957,8 @@ export async function sendQuickResponse(item, classResult, routed = null) {
|
|
|
951
957
|
console.error(`[responder] Quick response failed for ${item.sender}:`, err.message);
|
|
952
958
|
logResponse({ type: "quick_response_error", sender: item.sender, error: err.message });
|
|
953
959
|
return { sent: false, text: null, error: err.message };
|
|
960
|
+
} finally {
|
|
961
|
+
try { stopTyping(item); } catch { /* never throw from teardown */ }
|
|
954
962
|
}
|
|
955
963
|
}
|
|
956
964
|
|
|
@@ -114,7 +114,15 @@ export function startTyping(item) {
|
|
|
114
114
|
if (!adapter) return null;
|
|
115
115
|
const source = sourceFromItem(item);
|
|
116
116
|
if (!source || !source.chatId) return null;
|
|
117
|
-
|
|
117
|
+
const key = adapter.startTypingHeartbeat(source);
|
|
118
|
+
// Observability: a silent indicator was exactly the bug — "is it even
|
|
119
|
+
// firing?" was unanswerable from the logs. Emit one line when a heartbeat
|
|
120
|
+
// actually starts (startTypingHeartbeat is idempotent, so this is ~once per
|
|
121
|
+
// turn, not per beat).
|
|
122
|
+
if (key) {
|
|
123
|
+
try { console.log(`[typing] composing… ${service}:${source.chatId}${source.userScope === "dm" ? " (dm)" : ""}`); } catch { /* never throw from logging */ }
|
|
124
|
+
}
|
|
125
|
+
return key;
|
|
118
126
|
} catch {
|
|
119
127
|
return null;
|
|
120
128
|
}
|