@cohortapp/agent-sdk 2.15.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -2
- package/docs/guides/front-door-session.md +16 -5
- package/docs/guides/poller-daemon-setup.md +53 -2
- package/lib/assurance/plan-note.mjs +251 -0
- package/lib/assurance/plan-note.test.mjs +234 -0
- package/lib/assurance/room-budget.mjs +497 -0
- package/lib/assurance/room-budget.test.mjs +486 -0
- package/lib/assurance/tier.mjs +166 -0
- package/lib/assurance/tier.test.mjs +174 -0
- package/lib/comms/receipts.mjs +17 -1
- package/lib/context/budget.mjs +327 -0
- package/lib/context/budget.test.mjs +252 -0
- package/lib/context/history-scope.mjs +138 -0
- package/lib/context/history-scope.test.mjs +79 -0
- package/lib/model-router/economics.mjs +9 -0
- package/lib/model-router/resolve.mjs +6 -0
- package/lib/org/inbound/facts.mjs +4 -2
- package/lib/org/inbound/hydrate.mjs +555 -51
- package/lib/org/inbound/hydrate.test.mjs +456 -1
- package/package.json +3 -1
- package/plugins/maestro-skills/skills/inbound-triage.md +52 -24
- package/plugins/maestro-skills/skills/main-session.md +6 -4
- package/scripts/daemon/agent-daemon.mjs +35 -7
- package/scripts/daemon/agent-daemon.test.mjs +23 -6
- package/scripts/daemon/assurance-e2e.test.mjs +75 -19
- package/scripts/daemon/assurance.mjs +663 -159
- package/scripts/daemon/assurance.test.mjs +820 -140
- package/scripts/daemon/context-compiler.mjs +52 -21
- package/scripts/daemon/context-compiler.test.mjs +106 -0
- package/scripts/daemon/deliver.mjs +7 -4
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
- package/scripts/daemon/dispatcher.mjs +210 -9
- package/scripts/daemon/lib/session-router.mjs +310 -42
- package/scripts/daemon/lib/session-router.test.mjs +260 -1
- package/scripts/daemon/prompt-builder.mjs +160 -16
- package/scripts/daemon/prompt-builder.test.mjs +287 -7
- package/scripts/daemon/responder-history.test.mjs +37 -1
- package/scripts/daemon/responder.mjs +79 -72
|
@@ -69,6 +69,7 @@ import { sendQuickResponse, sendHoldingMessage, isQuickReply } from "./responder
|
|
|
69
69
|
// imported here because this file is where every one of those moments happens —
|
|
70
70
|
// the dispatch decision, the session close, and the tick loop.
|
|
71
71
|
import {
|
|
72
|
+
ACK_AFTER_MS,
|
|
72
73
|
shouldAcknowledge,
|
|
73
74
|
openAndAcknowledge,
|
|
74
75
|
noteSession,
|
|
@@ -933,6 +934,13 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
933
934
|
// paths; passing only the text to buildPrompt made every failed ack read to
|
|
934
935
|
// the session as a delivered one.
|
|
935
936
|
let holdingDelivered = false;
|
|
937
|
+
// An interim reached this human but THIS process does not hold its text —
|
|
938
|
+
// the re-delivery path, where the debt is already open with interimSaid and
|
|
939
|
+
// `openAndAcknowledge` answers {acked:true, ackText:null}. Without carrying
|
|
940
|
+
// the fact separately from the text, the prompt's no-contact block asserted
|
|
941
|
+
// silence at a session whose sender had already had a holding line and a
|
|
942
|
+
// "Hit a problem — … Retrying now".
|
|
943
|
+
let interimAlreadySent = false;
|
|
936
944
|
let obligationKeyForItem = null;
|
|
937
945
|
// FAIL-SAFE, the storm's OTHER half: never post a holding "let me look into
|
|
938
946
|
// it" the seat cannot keep. If the Claude CLI is not even available, the
|
|
@@ -942,8 +950,14 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
942
950
|
// still opening the durable debt, so the assurance sweep escalates instead of
|
|
943
951
|
// a human staring at "on it" that never resolves.
|
|
944
952
|
const ackVerdict = _claudeAvailable()
|
|
945
|
-
|
|
946
|
-
|
|
953
|
+
// `classResult` is passed so the verdict carries a TIER (answer / work /
|
|
954
|
+
// plan). The tier is what decides whether this ask may make the agent
|
|
955
|
+
// speak AT ALL: an answerable question at a cheap rung never earns an
|
|
956
|
+
// interim, and nothing at all is said at this point on any tier — the
|
|
957
|
+
// sweep owns the single interim, after ACK_AFTER_MS and inside the room's
|
|
958
|
+
// budget. `item.rung` is null today; WP-2 routes it.
|
|
959
|
+
? shouldAcknowledge({ willSpawnSession: true, item, source: "inbox", classResult, rung: item.rung })
|
|
960
|
+
: { ack: false, reason: "claude-unavailable", tier: "work" };
|
|
947
961
|
// ── DISPATCH GATE: emitter-class inbound spawns NOTHING ─────────────────
|
|
948
962
|
// `emitterClass: true` means the gate read the inbound and recognised output
|
|
949
963
|
// this seat's own machinery class produces — a peer agent's ack-shaped
|
|
@@ -973,17 +987,27 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
973
987
|
service,
|
|
974
988
|
traceId: trace_id,
|
|
975
989
|
ack: ackVerdict.ack,
|
|
990
|
+
tier: ackVerdict.tier,
|
|
991
|
+
// Retained seam: `openAndAcknowledge` no longer sends anything at open
|
|
992
|
+
// time, so this is never called from the acknowledgement path. It stays
|
|
993
|
+
// wired because `deps.sendHoldingMessage` is a documented injection
|
|
994
|
+
// point for the whole fleet and silently dropping it would break
|
|
995
|
+
// callers that still pass one.
|
|
976
996
|
deps: { ackSender: _sendHoldingMessage },
|
|
977
997
|
});
|
|
978
998
|
obligationKeyForItem = opened.key;
|
|
999
|
+
// SILENCE IS THE DEFAULT. Nothing has been said to this human yet and
|
|
1000
|
+
// nothing may be until ACK_AFTER_MS, so the session's prompt must not be
|
|
1001
|
+
// told a holding message exists — see prompt-builder's two blocks, which
|
|
1002
|
+
// now render only when one genuinely did go out.
|
|
979
1003
|
holdingText = opened.ackText;
|
|
980
1004
|
holdingDelivered = Boolean(opened.acked);
|
|
1005
|
+
interimAlreadySent = Boolean(opened.acked) && !opened.ackText;
|
|
981
1006
|
if (opened.acked) {
|
|
982
1007
|
updateLock(itemId, { holdingSent: true });
|
|
983
1008
|
} else if (ackVerdict.ack) {
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
counters.bump("assurance.ack_deferred", { service });
|
|
1009
|
+
console.log(`[daemon] no interim yet for ${itemId} (${opened.reason}) — the sweep owns it from ${Math.round(ACK_AFTER_MS / 1000)}s, inside the room's budget`);
|
|
1010
|
+
counters.bump("assurance.interim_deferred", { service, tier: ackVerdict.tier || "work" });
|
|
987
1011
|
} else {
|
|
988
1012
|
console.log(`[daemon] no acknowledgement for ${itemId} (${ackVerdict.reason}) — debt ${opened.key} still tracked`);
|
|
989
1013
|
}
|
|
@@ -1035,6 +1059,7 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
|
|
|
1035
1059
|
type: "inbox",
|
|
1036
1060
|
holdingMessage: holdingText,
|
|
1037
1061
|
holdingSent: holdingDelivered,
|
|
1062
|
+
interimAlreadySent,
|
|
1038
1063
|
});
|
|
1039
1064
|
// F1/H2: record a DURABLE in-flight admission and DEFER markProcessed() to
|
|
1040
1065
|
// the dispatch onClose SUCCESS path. The previous code marked the item
|
|
@@ -2528,8 +2553,11 @@ async function main() {
|
|
|
2528
2553
|
// sweep is the thing that speaks to humans.
|
|
2529
2554
|
Promise.resolve(sweepObligations())
|
|
2530
2555
|
.then((s) => {
|
|
2531
|
-
if (s.acked || s.
|
|
2532
|
-
|
|
2556
|
+
if (s.acked || s.suppressed || s.staled || s.interrupted) {
|
|
2557
|
+
// `suppressed` is the room budget doing its job — it is logged
|
|
2558
|
+
// BECAUSE it is the quiet half: a sweep that says nothing must still
|
|
2559
|
+
// be able to prove it decided to say nothing.
|
|
2560
|
+
console.log(`[daemon] assurance sweep — interim:${s.acked} suppressed:${s.suppressed} stale:${s.staled} interrupted:${s.interrupted} closed:${s.closed} (open:${s.swept})`);
|
|
2533
2561
|
}
|
|
2534
2562
|
})
|
|
2535
2563
|
.catch((err) => console.error("[daemon] assurance sweep error:", err.message));
|
|
@@ -683,13 +683,22 @@ test("ELECTION: an @mention/named-address responds WITHOUT calling electResponde
|
|
|
683
683
|
cls._resetAgentRegistry();
|
|
684
684
|
});
|
|
685
685
|
|
|
686
|
-
test("FAIL-SAFE:
|
|
686
|
+
test("FAIL-SAFE: an ask the reply session cannot answer is never promised one (claude unavailable)", async () => {
|
|
687
687
|
resetState();
|
|
688
688
|
// A slack DM — always directed, so this isolates the ack gate from the election.
|
|
689
689
|
// is_dm is normally set by enrichItem; answerItem is called directly here, so
|
|
690
690
|
// set it on the item as the poller/enrichment would.
|
|
691
691
|
// Distinct channel + subject per run so the request-claim from one run does not
|
|
692
692
|
// deny the other (the claim key is recipient + subject + action_type).
|
|
693
|
+
//
|
|
694
|
+
// WHAT THIS PINS SINCE 2026-09-12. Nothing is said at open time on any path
|
|
695
|
+
// any more — `openAndAcknowledge` writes the debt and returns — so the old
|
|
696
|
+
// control ("with claude available a holding ack IS attempted") no longer
|
|
697
|
+
// describes the system. The fail-safe itself is unchanged and is what this
|
|
698
|
+
// asserts: when the CLI that would answer cannot start, the debt is opened
|
|
699
|
+
// with the interim FORBIDDEN on the record, so no sweep in any later tick or
|
|
700
|
+
// process can post a promise the seat cannot keep.
|
|
701
|
+
const assurance = await import("./assurance.mjs");
|
|
693
702
|
const mkItem = (tag) => ({ id: `MSG-ACK-${tag}`, raw_ref: `slack:DACK${tag}:1`, service: "slack", channel: "dm/ceo", channel_id: `DACK${tag}0001`, is_dm: true, sender: "ceo", content: "please draft the memo" });
|
|
694
703
|
const baseDeps = (spy, claudeUp, tag) => ({
|
|
695
704
|
classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: `draft memo ${tag}`, category: "action_required", directed_at_agent: true }),
|
|
@@ -699,19 +708,27 @@ test("FAIL-SAFE: no holding ack is posted when the reply session cannot run (cla
|
|
|
699
708
|
claudeAvailable: () => claudeUp,
|
|
700
709
|
});
|
|
701
710
|
|
|
702
|
-
// CONTROL: claude available ⇒ the
|
|
711
|
+
// CONTROL: claude available ⇒ the debt is opened and an interim is PERMITTED
|
|
712
|
+
// later, by the sweep, after ACK_AFTER_MS and inside the room's budget.
|
|
703
713
|
const up = { calls: 0, dispatched: false };
|
|
704
714
|
await daemon.answerItem(mkItem("UP"), "slack", "slack:DACKUP:1", "trace-ack-up", baseDeps(up, true, "UP"));
|
|
705
|
-
assert.equal(up.calls,
|
|
715
|
+
assert.equal(up.calls, 0, "nothing is said at open time on ANY path — the interim belongs to the sweep");
|
|
706
716
|
assert.equal(up.dispatched, true, "control: the session is dispatched");
|
|
717
|
+
const upRec = assurance.readObligation("slack:DACKUP:1");
|
|
718
|
+
assert.ok(upRec, "the debt exists");
|
|
719
|
+
assert.notEqual(upRec.interimForbidden, true, "and an interim is permitted for it");
|
|
707
720
|
|
|
708
|
-
// TREATMENT: claude unavailable ⇒
|
|
709
|
-
// (the assurance sweep owns the outcome; the human
|
|
721
|
+
// TREATMENT: claude unavailable ⇒ the interim is forbidden on the record, but
|
|
722
|
+
// the session still runs (the assurance sweep owns the outcome; the human
|
|
723
|
+
// just isn't promised a reply the seat cannot produce).
|
|
710
724
|
const down = { calls: 0, dispatched: false };
|
|
711
725
|
const res = await daemon.answerItem(mkItem("DN"), "slack", "slack:DACKDN:1", "trace-ack-down", baseDeps(down, false, "DN"));
|
|
712
|
-
assert.equal(down.calls, 0, "with claude unavailable,
|
|
726
|
+
assert.equal(down.calls, 0, "with claude unavailable, no interim is ever posted for this ask");
|
|
713
727
|
assert.equal(down.dispatched, true, "the session is still dispatched");
|
|
714
728
|
assert.equal(res.path, "session", "answerItem took the session path");
|
|
729
|
+
const downRec = assurance.readObligation("slack:DACKDN:1");
|
|
730
|
+
assert.ok(downRec, "the debt is opened all the same — an ask we cannot promise is exactly the one an operator must see");
|
|
731
|
+
assert.equal(downRec.interimForbidden, true, "durable on the record, because the sweep runs in another tick");
|
|
715
732
|
});
|
|
716
733
|
|
|
717
734
|
// ===========================================================================
|
|
@@ -207,10 +207,42 @@ test("SCENARIO 1 — a fast ask gets a direct answer, with no pointless holding
|
|
|
207
207
|
});
|
|
208
208
|
|
|
209
209
|
// ===========================================================================
|
|
210
|
-
// SCENARIO 2 — A SLOW ASK.
|
|
210
|
+
// SCENARIO 2 — A SLOW ASK. Silent for ninety seconds, one line, then answered.
|
|
211
|
+
//
|
|
212
|
+
// WHY THIS SCENARIO CHANGED ON 2026-09-12, AND WHY THE PROGRESS PING IS GONE
|
|
213
|
+
//
|
|
214
|
+
// It used to assert an acknowledgement at t≈0 and EXACTLY ONE progress ping at
|
|
215
|
+
// six minutes. Both assertions were faithful to the code and both encoded the
|
|
216
|
+
// defect that code had become.
|
|
217
|
+
//
|
|
218
|
+
// Measured over fourteen days in org_default_adaptic: 10,667 agent messages, of
|
|
219
|
+
// which 3,069 (28.8%) were opening acknowledgements and 961 (9.0%) were progress
|
|
220
|
+
// nags — 37.8% of everything this fleet said carried no content at all. 272 of
|
|
221
|
+
// those acknowledgements were never followed by a substantive reply inside an
|
|
222
|
+
// hour, i.e. the promise in them ("I'll come back as soon as I've got
|
|
223
|
+
// something") was simply not kept. Agent-to-human volume went from 2:1 to 158:1
|
|
224
|
+
// in a fortnight, and 9,187 of ~11,400 messages in 21 days landed in ONE
|
|
225
|
+
// channel from eight agents, ~45% of them exact duplicates. Sampled live at
|
|
226
|
+
// 06:32Z: "Still on this — 5 minutes in" beside "Still going — 15 minutes in"
|
|
227
|
+
// at the SAME timestamp, two independent per-obligation timers narrating one
|
|
228
|
+
// piece of overlapping work.
|
|
229
|
+
//
|
|
230
|
+
// The progress ping could not have been better written. `composeProgress` was a
|
|
231
|
+
// pure function of `now - openedAt`; nothing read the session's stdout, its
|
|
232
|
+
// tool calls or its partial findings, so the message could not contain anything
|
|
233
|
+
// a reader could not already see on the clock. A message that says only what
|
|
234
|
+
// the timestamp says is not an update. It is deleted, and this scenario now
|
|
235
|
+
// asserts that NONE is emitted — not because the assertion was wrong about the
|
|
236
|
+
// code, but because the behaviour it pinned was the bug.
|
|
237
|
+
//
|
|
238
|
+
// What replaces it: silence below ACK_AFTER_MS (the typing indicator is the
|
|
239
|
+
// acknowledgement), then AT MOST ONE bespoke line, and only if the room has not
|
|
240
|
+
// already heard one inside its budget window. Beyond that the agent either says
|
|
241
|
+
// something with content in it — the plan the session itself stated — or it says
|
|
242
|
+
// nothing and lets the answer be the answer.
|
|
211
243
|
// ===========================================================================
|
|
212
244
|
|
|
213
|
-
test("SCENARIO 2 — a slow ask is
|
|
245
|
+
test("SCENARIO 2 — a slow ask is silent, then gets ONE line, then the answer — and no progress nag", async () => {
|
|
214
246
|
const ask = "with that in mind, fix these issues you just noted please and push their fixes to git";
|
|
215
247
|
beginScenario("SCENARIO 2 — SLOW ASK (the owner's actual message)", ask);
|
|
216
248
|
const item = makeItem("SLOW-1", ask);
|
|
@@ -224,23 +256,45 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
|
|
|
224
256
|
dispatch: (_p, _it, _cr, _s, opts) => { onCloseHook = opts.onClose; },
|
|
225
257
|
});
|
|
226
258
|
|
|
227
|
-
|
|
228
|
-
assert.ok(ack, "the acknowledgement must go out");
|
|
229
|
-
assert.ok(ack.ms < 1000, `acknowledged in ${ack.ms}ms`);
|
|
230
|
-
assert.match(ack.text, /git fixes/i, "the ack is bespoke to the actual ask, not a rotation pick");
|
|
231
|
-
assert.doesNotMatch(ack.text, /^(on it|looking now)[.!—]?\s*$/i, "never the retired canned lines");
|
|
232
|
-
assert.ok(ack.text.length <= 120, "the ack is one short human line, not a templated paragraph");
|
|
233
|
-
|
|
259
|
+
assert.equal(transcript.length, 0, "NOTHING at t=0 — the reflex ack is what 3,069 content-free messages were");
|
|
234
260
|
const key = assurance.openObligations()[0].key;
|
|
235
|
-
|
|
261
|
+
assert.equal(assurance.readObligation(key).state, "open", "the DEBT is opened all the same: silence about timing is not silence about outcome");
|
|
262
|
+
note("obligation opened, nothing said; session running");
|
|
236
263
|
|
|
237
|
-
// ──
|
|
238
|
-
//
|
|
239
|
-
let virtual =
|
|
264
|
+
// ── Thirty seconds in: still nothing. Below ACK_AFTER_MS the typing
|
|
265
|
+
// indicator is the acknowledgement.
|
|
266
|
+
let virtual = 30_000;
|
|
240
267
|
const clock = () => virtual;
|
|
241
|
-
const deps =
|
|
242
|
-
|
|
243
|
-
|
|
268
|
+
const deps = () => ({
|
|
269
|
+
deliverImpl: transport(clock),
|
|
270
|
+
spokeSinceImpl: () => false,
|
|
271
|
+
generateAckImpl: async () => "Taking the git fixes now — push coming.",
|
|
272
|
+
});
|
|
273
|
+
let stats = await assurance.sweepObligations({ now: Date.now() + virtual, deps: deps() });
|
|
274
|
+
assert.equal(stats.acked, 0);
|
|
275
|
+
assert.equal(transcript.length, 0, "under ninety seconds, silence is the right answer");
|
|
276
|
+
|
|
277
|
+
// ── Two minutes in: ONE line, specific to the ask.
|
|
278
|
+
virtual = 2 * 60_000;
|
|
279
|
+
stats = await assurance.sweepObligations({ now: Date.now() + virtual, deps: deps() });
|
|
280
|
+
assert.equal(stats.acked, 1, "past ACK_AFTER_MS the human gets exactly one line");
|
|
281
|
+
const ack = transcript.find((m) => m.kind === "ack");
|
|
282
|
+
assert.ok(ack, "…and it is that line");
|
|
283
|
+
assert.match(ack.text, /git fixes/i, "bespoke to the actual ask, not a rotation pick");
|
|
284
|
+
assert.doesNotMatch(ack.text, /^(on it|looking now|checking|one moment|got it|will do)\b/i, "never a generic opener — the sanitiser now enforces the prompt");
|
|
285
|
+
assert.ok(ack.text.length <= 120, "one short human line, not a templated paragraph");
|
|
286
|
+
|
|
287
|
+
// ── Six minutes in — where the progress ping used to land. NOTHING.
|
|
288
|
+
virtual = 6 * 60_000;
|
|
289
|
+
stats = await assurance.sweepObligations({ now: Date.now() + virtual, deps: deps() });
|
|
290
|
+
assert.equal(stats.acked, 0);
|
|
291
|
+
const progressish = transcript.filter((m) => /still (on this|going)/i.test(m.text));
|
|
292
|
+
assert.equal(progressish.length, 0, "a message that is a pure function of elapsed minutes cannot carry progress");
|
|
293
|
+
|
|
294
|
+
// ── Sixteen minutes: and still nothing, however long it runs.
|
|
295
|
+
virtual = 16 * 60_000;
|
|
296
|
+
await assurance.sweepObligations({ now: Date.now() + virtual, deps: deps() });
|
|
297
|
+
assert.equal(transcript.length, 1, `the whole wait costs the human ONE message, got ${transcript.length}`);
|
|
244
298
|
|
|
245
299
|
// ── 17 minutes: the session finally answers the human itself.
|
|
246
300
|
virtual = 17 * 60_000;
|
|
@@ -251,9 +305,8 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
|
|
|
251
305
|
|
|
252
306
|
const rec = assurance.readObligation(key);
|
|
253
307
|
assert.equal(rec.state, "answered", "the debt is discharged by evidence of a delivered reply");
|
|
254
|
-
const progress = transcript.filter((m) => m.kind === "progress");
|
|
255
|
-
assert.equal(progress.length, 1);
|
|
256
308
|
assert.equal(transcript.filter((m) => m.kind === "ack").length, 1, "acknowledge once, never twice");
|
|
309
|
+
assert.equal(transcript.filter((m) => m.kind === "progress").length, 0, "and never a progress ping — the branch that emitted them is deleted");
|
|
257
310
|
note("obligation discharged by receipt — the daemon added nothing on top of the session's own answer", virtual);
|
|
258
311
|
});
|
|
259
312
|
|
|
@@ -275,7 +328,10 @@ test("SCENARIO 3 — a timing-out session tells the human what happened and that
|
|
|
275
328
|
dispatch: (_p, _it, _cr, _s, opts) => { onCloseHook = opts.onClose; },
|
|
276
329
|
});
|
|
277
330
|
const key = assurance.openObligations().find((o) => o.key === item.raw_ref).key;
|
|
278
|
-
|
|
331
|
+
// Nothing has been said yet — the ask is 45 minutes old in VIRTUAL time only,
|
|
332
|
+
// and the sweep has not run. What matters for this scenario is that the debt
|
|
333
|
+
// exists, so the failure has somewhere to be reported from.
|
|
334
|
+
assert.equal(transcript.length, 0, "silence is the default; the failure notice below is the first thing this human hears");
|
|
279
335
|
|
|
280
336
|
// 45 minutes in, the dispatcher's SIGTERM lands and the session closes 143.
|
|
281
337
|
const virtual = { v: 45 * 60_000 };
|