@cohortapp/agent-sdk 2.16.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/poller-daemon-setup.md +4 -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/scripts/daemon/context-compiler.mjs +52 -21
- package/scripts/daemon/context-compiler.test.mjs +106 -0
- 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 +97 -12
- package/scripts/daemon/prompt-builder.test.mjs +219 -7
- package/scripts/daemon/responder-history.test.mjs +37 -1
- package/scripts/daemon/responder.mjs +71 -69
|
@@ -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
|
-
|
|
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
|
-
//
|
|
1282
|
-
//
|
|
1283
|
-
//
|
|
1284
|
-
|
|
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
|