@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.
Files changed (38) hide show
  1. package/.env.example +5 -2
  2. package/docs/guides/front-door-session.md +16 -5
  3. package/docs/guides/poller-daemon-setup.md +53 -2
  4. package/lib/assurance/plan-note.mjs +251 -0
  5. package/lib/assurance/plan-note.test.mjs +234 -0
  6. package/lib/assurance/room-budget.mjs +497 -0
  7. package/lib/assurance/room-budget.test.mjs +486 -0
  8. package/lib/assurance/tier.mjs +166 -0
  9. package/lib/assurance/tier.test.mjs +174 -0
  10. package/lib/comms/receipts.mjs +17 -1
  11. package/lib/context/budget.mjs +327 -0
  12. package/lib/context/budget.test.mjs +252 -0
  13. package/lib/context/history-scope.mjs +138 -0
  14. package/lib/context/history-scope.test.mjs +79 -0
  15. package/lib/model-router/economics.mjs +9 -0
  16. package/lib/model-router/resolve.mjs +6 -0
  17. package/lib/org/inbound/facts.mjs +4 -2
  18. package/lib/org/inbound/hydrate.mjs +555 -51
  19. package/lib/org/inbound/hydrate.test.mjs +456 -1
  20. package/package.json +3 -1
  21. package/plugins/maestro-skills/skills/inbound-triage.md +52 -24
  22. package/plugins/maestro-skills/skills/main-session.md +6 -4
  23. package/scripts/daemon/agent-daemon.mjs +35 -7
  24. package/scripts/daemon/agent-daemon.test.mjs +23 -6
  25. package/scripts/daemon/assurance-e2e.test.mjs +75 -19
  26. package/scripts/daemon/assurance.mjs +663 -159
  27. package/scripts/daemon/assurance.test.mjs +820 -140
  28. package/scripts/daemon/context-compiler.mjs +52 -21
  29. package/scripts/daemon/context-compiler.test.mjs +106 -0
  30. package/scripts/daemon/deliver.mjs +7 -4
  31. package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
  32. package/scripts/daemon/dispatcher.mjs +210 -9
  33. package/scripts/daemon/lib/session-router.mjs +310 -42
  34. package/scripts/daemon/lib/session-router.test.mjs +260 -1
  35. package/scripts/daemon/prompt-builder.mjs +160 -16
  36. package/scripts/daemon/prompt-builder.test.mjs +287 -7
  37. package/scripts/daemon/responder-history.test.mjs +37 -1
  38. package/scripts/daemon/responder.mjs +79 -72
@@ -143,6 +143,125 @@ import { budgetLadder, spawnKnobsFor } from "../../lib/model-router/economics.mj
143
143
  // AsyncLocalStorage cannot cross the proc-event boundary) + the v2 decision_id.
144
144
  // emitEvent is fail-open (never throws), so this is purely additive telemetry.
145
145
  import { emitEvent, EVENT_TYPES } from "../../lib/diagnostics/events.mjs";
146
+ import {
147
+ createRouterSync,
148
+ routingKey as deriveRoutingKey,
149
+ routerItemFromDaemonItem,
150
+ claimSession,
151
+ releaseSession,
152
+ } from "./lib/session-router.mjs";
153
+ import { replyTier } from "../../lib/assurance/tier.mjs";
154
+
155
+ /**
156
+ * The reply tier comes from `lib/assurance/tier.mjs` (design §5.1) — the ONE
157
+ * definition, imported statically now that both halves of the design have
158
+ * merged.
159
+ *
160
+ * There used to be a `localReplyTier` here carrying "the same table" for the
161
+ * window in which this package shipped before the acknowledgement package. It
162
+ * was not the same table: it predated the review fix that added
163
+ * `willSpawnSession !== true` to rule (2), so an item the quick path fell
164
+ * through on would have been tiered `answer` — total silence — while a long
165
+ * session ran. The duplicate's own test ("localReplyTier agrees with
166
+ * lib/assurance/tier.mjs wherever that module exists") is what caught it at
167
+ * the merge, which is the only reason the divergence is a footnote and not an
168
+ * outage. Two copies of one decision is the bug; there is now one.
169
+ */
170
+
171
+ /** The reply tier → the router task class that carries its effort knobs. */
172
+ const TASK_CLASS_BY_TIER = Object.freeze({
173
+ answer: "session.answer",
174
+ work: "session.responder",
175
+ plan: "session.plan",
176
+ });
177
+
178
+ /** Rungs, as `lib/execution/route.RUNGS` numbers them. */
179
+ const ANSWER_MAX_RUNG = 1;
180
+ const PLAN_MIN_RUNG = 3;
181
+
182
+ /**
183
+ * A rung worth reasoning from, or null. An out-of-range value, a string "3" or
184
+ * a NaN is a stale field, and reading one as a rung would let a typo pick the
185
+ * turn's effort. Same coercion as `lib/assurance/tier.mjs#routedRung`.
186
+ */
187
+ function routedRung(rung) {
188
+ if (typeof rung !== "number" || !Number.isInteger(rung)) return null;
189
+ return rung < 0 || rung > 5 ? null : rung;
190
+ }
191
+
192
+
193
+ /**
194
+ * The routed execution rung for an item, or null.
195
+ *
196
+ * `agent-daemon.mjs:617` stamps the ladder's decision onto `item.execution`
197
+ * precisely so everything downstream can see which rung the turn is serving.
198
+ * The classifier emits no `rung` field of its own, so reading `classResult.rung`
199
+ * finds `undefined` forever — which would make `session.answer`'s knobs dead
200
+ * code and the tier table a decoration.
201
+ */
202
+ export function rungForItem(item) {
203
+ const ex = item && typeof item === "object" ? item.execution : null;
204
+ if (ex && typeof ex === "object") {
205
+ const r = routedRung(ex.rung);
206
+ if (r != null) return r;
207
+ }
208
+ return routedRung(item && item.rung);
209
+ }
210
+
211
+ /**
212
+ * The task class for this spawn. Was hardcoded `"session.responder"`, so every
213
+ * session took the same 40-turn, null-effort knobs no matter what was asked
214
+ * (design §3 R13).
215
+ *
216
+ * @param {object} classResult
217
+ * @param {object} [item] the inbox item, which carries the ladder's decision
218
+ */
219
+ export function taskClassFor(classResult, item = null) {
220
+ const c = classResult || {};
221
+ const args = {
222
+ answerable: c.answerable,
223
+ action: c.action,
224
+ priority: c.priority,
225
+ rung: rungForItem(item) ?? routedRung(c.rung),
226
+ // DELIBERATELY LEFT UNKNOWN, even though this function is only ever called
227
+ // on the spawn path and `true` is the literally accurate value.
228
+ //
229
+ // `replyTier` answers two different questions and the spawn flag matters to
230
+ // only one of them:
231
+ //
232
+ // "will the reply arrive in this turn?" — the ACKNOWLEDGEMENT question.
233
+ // A spawning item must never be tiered `answer`, because `answer`
234
+ // means total silence and the premise (an in-turn reply) is false.
235
+ // `assurance.mjs` asserts `willSpawnSession: true` for exactly that.
236
+ //
237
+ // "how hard is this ask?" — the EFFORT question, ours.
238
+ // Whether a session is spawning says nothing about it. A cheap,
239
+ // answerable, rung-0 ask deserves `session.answer`'s knobs whether it
240
+ // is answered in-turn or in a session.
241
+ //
242
+ // Asserting `true` here collapsed the two: rule (2) requires
243
+ // `willSpawnSession !== true`, so on the spawn path — the only path this
244
+ // function has — NOTHING could ever reach the `answer` tier and the
245
+ // `session.answer` effort class became unreachable. Caught at the merge by
246
+ // "taskClassFor maps each tier onto the class that carries its knobs".
247
+ // Leaving it unknown asks the tier the question this caller actually has.
248
+ willSpawnSession: undefined,
249
+ };
250
+ // A tier module that throws must not stop a dispatch: `work` is the tier the
251
+ // flood was made of and the one that speaks at most once, so it is the right
252
+ // thing to fall back to.
253
+ let tier = "work";
254
+ try { tier = replyTier(args) || "work"; } catch { tier = "work"; }
255
+ return TASK_CLASS_BY_TIER[tier] || "session.responder";
256
+ }
257
+
258
+ /**
259
+ * The session-continuity registry, shared with `responder.mjs` — same file,
260
+ * same key function, same decision. Synchronous because `spawnSession` builds
261
+ * its argv and spawns in one pass; see `createRouterSync`'s note.
262
+ */
263
+ const SESSION_REGISTRY_PATH = join(AGENT_REPO_DIR, "state", "daemon", "session-router-registry.json");
264
+ const sessionRouter = createRouterSync({ registryPath: SESSION_REGISTRY_PATH });
146
265
  // Lazy + cached so a misconfigured YAML doesn't break agents that didn't
147
266
  // opt in. The cache is invalidated only on daemon restart.
148
267
  let _routingConfigCache;
@@ -1167,17 +1286,21 @@ function currentBudgetBand() {
1167
1286
  * @param {object} classResult
1168
1287
  * @param {string} source
1169
1288
  * @param {string} fallbackModel the coarse sonnet/opus class for the no-config path
1289
+ * @param {object} [item] the inbox item, which carries the routed rung
1170
1290
  */
1171
- function resolveSpawnTarget(routingConfig, routingRequest, classResult, source, fallbackModel) {
1291
+ function resolveSpawnTarget(routingConfig, routingRequest, classResult, source, fallbackModel, item = null) {
1172
1292
  // No config at all → stock Claude-CLI-on-Max behaviour (unchanged).
1173
1293
  if (!routingConfig) {
1174
- return { modelFlag: fallbackModel, envForSpawn: {}, decisionId: null, backend: null, model: null, transport: "anthropic-cli", maxTurns: null, effort: null, agentsJson: null, explain: null, decision: null };
1294
+ return { taskClass: taskClassFor(classResult, item), modelFlag: fallbackModel, envForSpawn: {}, decisionId: null, backend: null, model: null, transport: "anthropic-cli", maxTurns: null, effort: null, agentsJson: null, explain: null, decision: null };
1175
1295
  }
1176
1296
 
1177
1297
  // v2 path — resolveChain produces a full RouteDecision.
1178
1298
  if (routingConfig.schema_version === 2) {
1179
1299
  try {
1180
- const taskClass = "session.responder";
1300
+ // The task class carries the spawn knobs (`spawnKnobsFor`), so hardcoding
1301
+ // one made `effort` always null and `maxTurns` always 40. It is now the
1302
+ // reply tier — see `taskClassFor`.
1303
+ const taskClass = taskClassFor(classResult, item);
1181
1304
  const req = {
1182
1305
  ...routingRequest,
1183
1306
  task_class: taskClass,
@@ -1194,6 +1317,7 @@ function resolveSpawnTarget(routingConfig, routingRequest, classResult, source,
1194
1317
  const knobs = spawnKnobsFor(taskClass);
1195
1318
  const sa = decision.spawnArgs || {};
1196
1319
  return {
1320
+ taskClass,
1197
1321
  modelFlag: sa.modelFlag || decision.chosen.model || fallbackModel,
1198
1322
  envForSpawn: decision.envForSpawn || {},
1199
1323
  decisionId: decision.decision_id || null,
@@ -1216,9 +1340,10 @@ function resolveSpawnTarget(routingConfig, routingRequest, classResult, source,
1216
1340
  // v1 path — resolveBackend → modelFlagFor (preserved verbatim).
1217
1341
  const resolved = resolveBackend(routingRequest, { config: routingConfig });
1218
1342
  if (!resolved) {
1219
- return { modelFlag: fallbackModel, envForSpawn: {}, decisionId: null, backend: null, model: null, transport: "anthropic-cli", maxTurns: null, effort: null, agentsJson: null, explain: null, decision: null };
1343
+ return { taskClass: taskClassFor(classResult, item), modelFlag: fallbackModel, envForSpawn: {}, decisionId: null, backend: null, model: null, transport: "anthropic-cli", maxTurns: null, effort: null, agentsJson: null, explain: null, decision: null };
1220
1344
  }
1221
1345
  return {
1346
+ taskClass: taskClassFor(classResult, item),
1222
1347
  modelFlag: modelFlagFor(resolved, routingRequest),
1223
1348
  envForSpawn: resolved.envForSpawn || {},
1224
1349
  decisionId: null,
@@ -1275,13 +1400,57 @@ function spawnSession(entry) {
1275
1400
  // default exactly. NEVER throws (resolveSpawnTarget degrades internally).
1276
1401
  const routingConfig = getRoutingConfig();
1277
1402
  const routingRequest = requestFromClassifierResult(classResult, { source, role: "responder" });
1278
- const target = resolveSpawnTarget(routingConfig, routingRequest, classResult, source, model);
1403
+ const target = resolveSpawnTarget(routingConfig, routingRequest, classResult, source, model, item);
1279
1404
  const effectiveModelFlag = target.modelFlag || model;
1280
1405
 
1281
- // WS4: pre-mint a stable Claude session id so a crash/reboot mid-flight can
1282
- // be resumed deterministically with `claude --print --resume <id>`. (Same
1283
- // mechanism the responder already uses; safe in --print text mode.)
1284
- const claudeSessionId = randomUUID();
1406
+ // Session continuity. A fresh `randomUUID()` per dispatch meant every full
1407
+ // session in a live thread started COLD — the agent re-read the room, re-did
1408
+ // the orientation work, and answered a follow-up as if it were an opening
1409
+ // (design §3 R10). The router the responder already consults keys the
1410
+ // conversation; a follow-up inside the TTL resumes the same session id, which
1411
+ // in this daemon IS continuation (`--session-id <id> <prompt>`, never
1412
+ // `--resume` — see the resume-pending notes above).
1413
+ //
1414
+ // Inbox only. Backlog work is not a conversation, and keying it would put
1415
+ // unrelated items in one session.
1416
+ //
1417
+ // Everything here is fail-open: no key, an unreadable registry, or a throw
1418
+ // from `routingKey` all fall back to the fresh UUID, which is exactly the
1419
+ // behaviour being replaced. Crash-recovery resume is UNCHANGED — it still
1420
+ // rides `writeResumePending(..., claudeSessionId, ...)` below, with whichever
1421
+ // id this resolved to.
1422
+ //
1423
+ // AND ONE PROCESS PER KEY. `route()` refuses to resume a key this daemon
1424
+ // already has a child on, and the claim below is what tells it so. A burst in
1425
+ // a busy top-level channel — the measured shape: ~38 messages an hour in one
1426
+ // room, where the thread lock does not apply because there is no thread id —
1427
+ // would otherwise put three or four `claude --print --session-id <same id>`
1428
+ // processes on ONE transcript. The later turns spawn cold instead, which is
1429
+ // exactly the pre-router behaviour, and continuity returns on the next turn.
1430
+ let sessionRoutingKey = null;
1431
+ let resumedSessionId = null;
1432
+ if (source === "inbox") {
1433
+ const routerItem = routerItemFromDaemonItem(item);
1434
+ if (routerItem) {
1435
+ try {
1436
+ const candidateKey = deriveRoutingKey(routerItem);
1437
+ const decision = sessionRouter.route(candidateKey);
1438
+ if (claimSession(candidateKey)) {
1439
+ sessionRoutingKey = candidateKey;
1440
+ if (decision.decision === "RESUME" && decision.resumeId) resumedSessionId = decision.resumeId;
1441
+ } else {
1442
+ // Something is already running on this room. Spawn cold, and do NOT
1443
+ // hold the key — the running session owns the registry row, and the
1444
+ // second turn must not overwrite it on close.
1445
+ console.log(`[dispatcher] ${candidateKey} already in flight — spawning cold (no session reuse)`);
1446
+ }
1447
+ } catch (err) {
1448
+ console.warn(`[dispatcher] session routing key failed: ${err.message} — spawning cold`);
1449
+ sessionRoutingKey = null;
1450
+ }
1451
+ }
1452
+ }
1453
+ const claudeSessionId = resumedSessionId || randomUUID();
1285
1454
 
1286
1455
  // The v2 router supplies spawn knobs (SPEC §4.4 / §6.5): --max-turns bounds
1287
1456
  // the session, --effort tunes reasoning where supported, --agents attaches the
@@ -1416,6 +1585,9 @@ function spawnSession(entry) {
1416
1585
  priority: classResult.priority,
1417
1586
  summary: classResult.summary,
1418
1587
  active_count: activeSessions.size,
1588
+ task_class: target.taskClass || null,
1589
+ session_key: sessionRoutingKey,
1590
+ continued: Boolean(resumedSessionId),
1419
1591
  });
1420
1592
 
1421
1593
  // Observability: the interaction's trace_id rode in on item.trace_id (set by
@@ -1448,6 +1620,7 @@ function spawnSession(entry) {
1448
1620
  // happened. Skip to avoid double-count and double-release.
1449
1621
  if (spawnErrorHandled.has(sessionId)) {
1450
1622
  spawnErrorHandled.delete(sessionId);
1623
+ if (sessionRoutingKey) releaseSession(sessionRoutingKey);
1451
1624
  clearResumePending(sessionId); // error path already terminal — no resume
1452
1625
  // The error handler already fired onClose(ok:false) AND emitted
1453
1626
  // session_closed; the single-fire guard makes the onClose a no-op, and we
@@ -1461,6 +1634,31 @@ function spawnSession(entry) {
1461
1634
  recordSession(true, code === 0);
1462
1635
  const duration = ((Date.now() - startTime) / 1000).toFixed(1);
1463
1636
 
1637
+ // Session continuity: a clean exit keeps the key live for the next turn in
1638
+ // this conversation; a non-zero one marks it killed so the next route
1639
+ // returns EPHEMERAL_REPLACE rather than resuming into a broken session.
1640
+ // Best-effort — a registry we cannot write costs continuity, never work.
1641
+ if (sessionRoutingKey) {
1642
+ try {
1643
+ if (code === 0) {
1644
+ sessionRouter.touch(sessionRoutingKey, {
1645
+ claudeSessionId,
1646
+ daemonSessionId: sessionId,
1647
+ model: effectiveModelFlag,
1648
+ });
1649
+ } else {
1650
+ sessionRouter.recordExit(sessionRoutingKey, code);
1651
+ }
1652
+ } catch (err) {
1653
+ console.warn(`[dispatcher] session router update failed for ${sessionRoutingKey}: ${err.message}`);
1654
+ } finally {
1655
+ // The key is free the moment the child is gone — released in a `finally`
1656
+ // so a registry write that throws cannot leave the room permanently
1657
+ // unable to continue a session.
1658
+ releaseSession(sessionRoutingKey);
1659
+ }
1660
+ }
1661
+
1464
1662
  // WS4: a clean exit retires the resume marker (work finished — nothing to
1465
1663
  // resume) and closes the shared 429 breaker. A non-zero exit whose stderr
1466
1664
  // looks like a rate limit opens the breaker so ALL spawn sources back off
@@ -1622,6 +1820,9 @@ function spawnSession(entry) {
1622
1820
  if (source === "inbox") { try { stopTyping(item); } catch { /* */ } }
1623
1821
  // Mark so the trailing proc.on("close") doesn't double-process.
1624
1822
  spawnErrorHandled.add(sessionId);
1823
+ // A spawn that never ran still holds the room's key. Release it here — the
1824
+ // close handler's release is behind an early return on this path.
1825
+ if (sessionRoutingKey) releaseSession(sessionRoutingKey);
1625
1826
  activeSessions.delete(sessionId);
1626
1827
  removeActiveSession(sessionId);
1627
1828
  // Spawn never ran — there is nothing to resume; retire the marker so the