@cohortapp/agent-sdk 2.11.1 → 2.11.2
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/package.json +1 -1
- package/scripts/daemon/assurance-e2e.test.mjs +4 -3
- package/scripts/daemon/assurance.mjs +34 -74
- package/scripts/daemon/assurance.test.mjs +26 -26
- package/scripts/daemon/classifier.mjs +27 -14
- package/scripts/daemon/execution-ladder.test.mjs +23 -0
- package/scripts/daemon/responder.mjs +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.11.
|
|
3
|
+
"version": "2.11.2",
|
|
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": {
|
|
@@ -190,7 +190,8 @@ test("SCENARIO 2 — a slow ask is acknowledged at once, updated while it runs,
|
|
|
190
190
|
const ack = transcript.find((m) => m.kind === "ack");
|
|
191
191
|
assert.ok(ack, "the acknowledgement must go out");
|
|
192
192
|
assert.ok(ack.ms < 1000, `acknowledged in ${ack.ms}ms — the old path budgeted 60,000ms and missed 2 times in 3`);
|
|
193
|
-
assert.match(ack.text, /
|
|
193
|
+
assert.match(ack.text, /on it|looking now|digging in/i);
|
|
194
|
+
assert.ok(ack.text.length <= 40, "the ack is one short human line, not a templated paragraph");
|
|
194
195
|
|
|
195
196
|
const key = assurance.openObligations()[0].key;
|
|
196
197
|
note("obligation opened and acknowledged; session running");
|
|
@@ -248,7 +249,7 @@ test("SCENARIO 3 — a timing-out session tells the human what happened and that
|
|
|
248
249
|
const failure = transcript.filter((m) => m.kind === "failure").at(-1);
|
|
249
250
|
assert.ok(failure, "the human MUST be told; the old code emitted telemetry and nothing else");
|
|
250
251
|
assert.match(failure.text, /ran past its time limit/i);
|
|
251
|
-
assert.match(failure.text, /retrying
|
|
252
|
+
assert.match(failure.text, /retrying now/i);
|
|
252
253
|
assert.equal(assurance.readObligation(key).state, "open", "still owed until the retry resolves");
|
|
253
254
|
note("item left un-processed for redelivery; the debt stays open so the retry is covered too", virtual.v);
|
|
254
255
|
});
|
|
@@ -278,7 +279,7 @@ test("SCENARIO 4 — a crashing session tells the human it failed, stops, and es
|
|
|
278
279
|
|
|
279
280
|
assert.equal(r.willRetry, false, "a spawn failure is permanent — retrying burns another 45 minutes for the same outcome");
|
|
280
281
|
const failure = transcript.filter((m) => m.kind === "failure").at(-1);
|
|
281
|
-
assert.match(failure.text, /couldn't
|
|
282
|
+
assert.match(failure.text, /couldn't finish this/i);
|
|
282
283
|
assert.match(failure.text, /stopped retrying/i);
|
|
283
284
|
assert.match(failure.text, /flagged it so it isn't lost/i);
|
|
284
285
|
assert.match(failure.text, /\?\s*$/, "a dead end ends in a question, so the human has a move");
|
|
@@ -311,16 +311,13 @@ function variant(seed, n) {
|
|
|
311
311
|
/**
|
|
312
312
|
* Trim the classifier's summary into a short topic phrase — casing preserved.
|
|
313
313
|
*
|
|
314
|
-
* This
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* where original casing reads correctly. The stored `rec.summary` flows into
|
|
322
|
-
* progress/failure/interrupted messages and the board-row title too, so the
|
|
323
|
-
* no-recasing rule protects all of them at once.
|
|
314
|
+
* This is NO LONGER interpolated into any message a human reads (the "(CEO
|
|
315
|
+
* asking what machine…)" bleed is gone — see composeAck). It survives only to
|
|
316
|
+
* populate `rec.summary`, which feeds the board-row title and the
|
|
317
|
+
* needs-attention escalation record. Casing is preserved because a summary that
|
|
318
|
+
* starts with an acronym or a name ("CEO", "Q3") must not be mangled in those
|
|
319
|
+
* records either; no local rule can tell "Fix" (safe to downcase) from "CEO"
|
|
320
|
+
* (not), so the phrase is stored exactly as the classifier wrote it.
|
|
324
321
|
*/
|
|
325
322
|
function topicClause(classResult, item) {
|
|
326
323
|
const raw = (classResult && classResult.summary) || (item && item.subject) || "";
|
|
@@ -329,84 +326,49 @@ function topicClause(classResult, item) {
|
|
|
329
326
|
return t;
|
|
330
327
|
}
|
|
331
328
|
|
|
332
|
-
/** Rough, honest expectation. Derived from the measured distribution of real
|
|
333
|
-
* sessions, not invented: sonnet work clusters near a minute, opus work near
|
|
334
|
-
* fifteen. Phrased as a range because it IS a range. */
|
|
335
|
-
export function expectedWindow(classResult) {
|
|
336
|
-
const model = classResult && classResult.model;
|
|
337
|
-
const priority = classResult && classResult.priority;
|
|
338
|
-
if (model === "opus" || priority === "critical") return "usually within 10-20 minutes";
|
|
339
|
-
return "usually within a few minutes";
|
|
340
|
-
}
|
|
341
|
-
|
|
342
329
|
/**
|
|
343
|
-
* The acknowledgement
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
330
|
+
* The acknowledgement — sent ONLY when genuinely long, multi-step work is about
|
|
331
|
+
* to run (a directly-answerable question is answered in the turn on the quick
|
|
332
|
+
* path, never acked). So it is one plain line, the way a colleague types it
|
|
333
|
+
* before they get to work: no aside, no filler, no promise-of-followup
|
|
334
|
+
* boilerplate. The failure notices, interim updates and stale sweep are what
|
|
335
|
+
* actually keep the human informed — the ack does not need to narrate them.
|
|
336
|
+
*
|
|
337
|
+
* The classifier's summary is DELIBERATELY not shown here. It used to ride in a
|
|
338
|
+
* parenthetical aside, which leaked the daemon's internal framing to the
|
|
339
|
+
* requester verbatim — "(CEO asking what machine/infrastructure is being used)"
|
|
340
|
+
* reached a real human. The summary is still stored on the obligation for the
|
|
341
|
+
* board row and escalations; it just never appears in a message a person reads.
|
|
355
342
|
*/
|
|
356
|
-
export function composeAck(item
|
|
357
|
-
const topic = topicClause(classResult, item);
|
|
358
|
-
const aside = topic ? ` (${topic})` : "";
|
|
343
|
+
export function composeAck(item) {
|
|
359
344
|
const v = variant(obligationKey(item) || (item && item.sender), 3);
|
|
360
|
-
|
|
361
|
-
`No problem — I'm looking into this now${aside}.`,
|
|
362
|
-
`Got it — I'm on it now${aside}.`,
|
|
363
|
-
`Understood — I'm digging into this now${aside}.`,
|
|
364
|
-
];
|
|
365
|
-
return `${openers[v]} I want to get this right, so I'll come back to you here with a full answer — ${expectedWindow(classResult)} — and you'll hear from me either way.`;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* The topic, set off in parentheses.
|
|
370
|
-
*
|
|
371
|
-
* It is NOT interpolated as the object of a preposition, because the classifier's
|
|
372
|
-
* summary is as often a verb phrase ("fix the issues and push them") as a noun
|
|
373
|
-
* phrase ("July spend reconciliation") — and "Still working on fix the issues"
|
|
374
|
-
* is the kind of sentence that tells a reader they are talking to a machine.
|
|
375
|
-
* Parentheses read correctly for both.
|
|
376
|
-
*/
|
|
377
|
-
function topicAside(rec) {
|
|
378
|
-
return rec && rec.summary ? ` (${rec.summary})` : "";
|
|
345
|
+
return ["On it.", "Looking now.", "On it — digging in now."][v];
|
|
379
346
|
}
|
|
380
347
|
|
|
381
|
-
/** An interim update. Says how long it has been and that the work is live
|
|
382
|
-
*
|
|
383
|
-
* fires at PROGRESS_AFTER_MS (5 min), squarely inside the 10-20 minute window
|
|
384
|
-
* the acknowledgement promised for opus work minutes earlier — a claim the
|
|
385
|
-
* same human could see was false. */
|
|
348
|
+
/** An interim update. Says how long it has been and that the work is live —
|
|
349
|
+
* short, no internal topic aside, no filler. */
|
|
386
350
|
export function composeProgress(rec, o = {}) {
|
|
387
351
|
const now = Number.isFinite(o.now) ? o.now : Date.now();
|
|
388
352
|
const mins = Math.max(1, Math.round((now - (rec.openedAt || now)) / 60_000));
|
|
389
|
-
const topic = topicAside(rec);
|
|
390
353
|
if ((rec.progressSent || 0) === 0) {
|
|
391
|
-
return `Still
|
|
354
|
+
return `Still on this — ${mins} minutes in. I'll come back as soon as I've got something.`;
|
|
392
355
|
}
|
|
393
|
-
return `
|
|
356
|
+
return `Still going — ${mins} minutes in. I'll follow up the moment it's done.`;
|
|
394
357
|
}
|
|
395
358
|
|
|
396
359
|
/**
|
|
397
360
|
* The failure notice. Says WHAT HAPPENED and WHAT HAPPENS NEXT, in that order,
|
|
398
361
|
* because those are the two things the human is missing when a session dies.
|
|
399
|
-
*
|
|
400
|
-
* ends without a next step.
|
|
362
|
+
* Terse and human: never blames the human, never hides behind "an error
|
|
363
|
+
* occurred", never ends without a next step, and never shows internal framing.
|
|
401
364
|
*/
|
|
402
365
|
export function composeFailure(rec, o = {}) {
|
|
403
366
|
const f = o.failure || {};
|
|
404
|
-
const topic = topicAside(rec);
|
|
405
367
|
const cause = f.human || "the working session ended unexpectedly";
|
|
406
368
|
if (o.willRetry) {
|
|
407
|
-
return `
|
|
369
|
+
return `Hit a problem — ${cause}. Retrying now; if it fails again I'll come straight back rather than leave you waiting.`;
|
|
408
370
|
}
|
|
409
|
-
return `
|
|
371
|
+
return `Couldn't finish this — ${cause}. I've stopped retrying and flagged it so it isn't lost. Want me to try a narrower version, hand it off, or leave it with you?`;
|
|
410
372
|
}
|
|
411
373
|
|
|
412
374
|
/**
|
|
@@ -416,20 +378,18 @@ export function composeFailure(rec, o = {}) {
|
|
|
416
378
|
* `sent`. This message is what the human gets instead of that silence.
|
|
417
379
|
*/
|
|
418
380
|
export function composeSilentSuccess(rec, o = {}) {
|
|
419
|
-
const topic = topicAside(rec);
|
|
420
381
|
const tail = (o.finalText || "").trim();
|
|
421
|
-
const head = `
|
|
382
|
+
const head = `Finished this but didn't get a reply out to you — my fault.`;
|
|
422
383
|
if (tail) {
|
|
423
384
|
const excerpt = tail.length > 900 ? `${tail.slice(0, 900).trimEnd()}…` : tail;
|
|
424
|
-
return `${head} Here's where I got to:\n\n${excerpt}
|
|
385
|
+
return `${head} Here's where I got to:\n\n${excerpt}`;
|
|
425
386
|
}
|
|
426
|
-
return `${head} I don't have a clean result to show you,
|
|
387
|
+
return `${head} I don't have a clean result to show you, though. Want me to run it again?`;
|
|
427
388
|
}
|
|
428
389
|
|
|
429
390
|
/** A session interrupted by the daemon itself dying/restarting. */
|
|
430
|
-
export function composeInterrupted(
|
|
431
|
-
|
|
432
|
-
return `Heads up — my working session on this${topic} was interrupted before it finished (my end restarted). I've put it back in the queue and I'm picking it up again now; I'll come back to you with the answer.`;
|
|
391
|
+
export function composeInterrupted() {
|
|
392
|
+
return `Heads up — my session on this was interrupted before it finished (my end restarted). I've picked it back up; I'll come back with the answer.`;
|
|
433
393
|
}
|
|
434
394
|
|
|
435
395
|
// ---------------------------------------------------------------------------
|
|
@@ -135,28 +135,28 @@ describe("composeAck", () => {
|
|
|
135
135
|
assert.equal(a, composeAck(ITEM, CLASS_COMPLEX), "same input must give the same words");
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
-
test("
|
|
138
|
+
test("is one short human line — no filler, no promise-of-followup boilerplate", () => {
|
|
139
139
|
const a = composeAck(ITEM, CLASS_COMPLEX);
|
|
140
|
-
assert.
|
|
141
|
-
|
|
142
|
-
assert.
|
|
140
|
+
assert.ok(a.length <= 40, `an ack is one short line, got ${a.length} chars: ${a}`);
|
|
141
|
+
// The templated filler communication-style.md bans.
|
|
142
|
+
assert.doesNotMatch(a, /either way|full answer|within a few minutes|within 10-20|I want to get this right|hear from me|come back to you/i);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
test("degrades gracefully when the classifier gave nothing to go on", () => {
|
|
146
146
|
const a = composeAck(ITEM, {});
|
|
147
|
-
assert.ok(a.length >
|
|
148
|
-
assert.
|
|
147
|
+
assert.ok(a.length > 0);
|
|
148
|
+
assert.doesNotMatch(a, /\(.*\)/, "no parenthetical aside");
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
test("REGRESSION:
|
|
152
|
-
// Owner screenshot, verbatim:
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
// the
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
assert.
|
|
159
|
-
assert.doesNotMatch(a,
|
|
151
|
+
test("REGRESSION: the classifier summary NEVER reaches the human — no '(CEO …)' bleed", () => {
|
|
152
|
+
// Owner screenshot, verbatim: the ack carried the classifier's internal
|
|
153
|
+
// summary in a parenthetical aside — "(CEO asking what machine/infrastructure
|
|
154
|
+
// is being used)" reached a real human. The summary is now stored on the
|
|
155
|
+
// obligation for the board row but never shown in a message anyone reads.
|
|
156
|
+
const a = composeAck(ITEM, { ...CLASS_COMPLEX, summary: "CEO asking what machine/infrastructure is being used" });
|
|
157
|
+
assert.doesNotMatch(a, /CEO asking/i);
|
|
158
|
+
assert.doesNotMatch(a, /machine\/infrastructure/i);
|
|
159
|
+
assert.doesNotMatch(a, /\(.*\)/, "no parenthetical aside at all");
|
|
160
160
|
});
|
|
161
161
|
|
|
162
162
|
test("reads like a colleague, not a template — no internal framing shown to the human", () => {
|
|
@@ -223,10 +223,10 @@ describe("openAndAcknowledge", () => {
|
|
|
223
223
|
assert.equal(openObligations().length, 1, "the debt must be visible to the sweep");
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
-
test("the stored summary keeps the classifier's casing —
|
|
227
|
-
// `rec.summary` feeds
|
|
228
|
-
//
|
|
229
|
-
//
|
|
226
|
+
test("the stored summary keeps the classifier's casing — the board row and escalations read from it", async () => {
|
|
227
|
+
// `rec.summary` feeds board-mirror's row title and the needs-attention
|
|
228
|
+
// escalation record. A summary mangled at open time would resurface there.
|
|
229
|
+
// It must NOT, however, appear in any message the human reads.
|
|
230
230
|
const t = fakeTransport();
|
|
231
231
|
const r = await openAndAcknowledge({
|
|
232
232
|
item: ITEM,
|
|
@@ -235,7 +235,7 @@ describe("openAndAcknowledge", () => {
|
|
|
235
235
|
});
|
|
236
236
|
const rec = readObligation(r.key);
|
|
237
237
|
assert.equal(rec.summary, "CEO checking in on work progress");
|
|
238
|
-
assert.
|
|
238
|
+
assert.doesNotMatch(composeProgress(rec, { now: Date.now() + 6 * 60_000 }), /CEO checking in on work progress/, "the summary is never leaked into a progress update");
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
test("acknowledges ONCE — a re-delivered item does not re-announce itself", async () => {
|
|
@@ -339,7 +339,7 @@ describe("settleSession", () => {
|
|
|
339
339
|
assert.match(r.verdict, /^retrying:timeout$/);
|
|
340
340
|
const msg = t.sent.at(-1).text;
|
|
341
341
|
assert.match(msg, /ran past its time limit/i);
|
|
342
|
-
assert.match(msg, /retrying
|
|
342
|
+
assert.match(msg, /retrying now/i);
|
|
343
343
|
assert.equal(readObligation(key).state, "open", "still owed — the retry has to be covered too");
|
|
344
344
|
});
|
|
345
345
|
|
|
@@ -350,7 +350,7 @@ describe("settleSession", () => {
|
|
|
350
350
|
assert.equal(r.willRetry, false);
|
|
351
351
|
assert.match(r.verdict, /^failed:spawn_failed$/);
|
|
352
352
|
const msg = t.sent.at(-1).text;
|
|
353
|
-
assert.match(msg, /couldn't
|
|
353
|
+
assert.match(msg, /couldn't finish this/i);
|
|
354
354
|
assert.match(msg, /stopped retrying/i);
|
|
355
355
|
assert.match(msg, /\?$/, "a dead end must end in a question, not a shrug");
|
|
356
356
|
assert.equal(readObligation(key).state, "failed");
|
|
@@ -384,7 +384,7 @@ describe("sweepObligations", () => {
|
|
|
384
384
|
const now = Date.now() + ACK_GRACE_MS + 1000;
|
|
385
385
|
const stats = await sweepObligations({ now, deps: { deliverImpl: working.impl, spokeSinceImpl: () => false } });
|
|
386
386
|
assert.equal(stats.acked, 1);
|
|
387
|
-
assert.match(working.sent[0].text, /
|
|
387
|
+
assert.match(working.sent[0].text, /on it|looking now|digging in/i);
|
|
388
388
|
assert.equal(readObligation(r.key).acknowledged, true);
|
|
389
389
|
});
|
|
390
390
|
|
|
@@ -395,7 +395,7 @@ describe("sweepObligations", () => {
|
|
|
395
395
|
const stats = await sweepObligations({ now, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
396
396
|
assert.equal(stats.progressed, 1);
|
|
397
397
|
const msg = t.sent.at(-1).text;
|
|
398
|
-
assert.match(msg, /still
|
|
398
|
+
assert.match(msg, /still on this/i);
|
|
399
399
|
assert.match(msg, /minutes in/i, "an interim update that omits how long it has been is not much of an update");
|
|
400
400
|
// Six minutes in is INSIDE the 10-20 minute window the ack just promised
|
|
401
401
|
// for opus work; claiming it here contradicts a message the same human read.
|
|
@@ -642,7 +642,7 @@ describe("attribution: which debt did that message actually discharge", () => {
|
|
|
642
642
|
const stats = await sweepObligations({ now: Date.now() + ACK_GRACE_MS + 1000, deps: { deliverImpl: t.impl } });
|
|
643
643
|
assert.equal(stats.closed, 0, "no session of ours had run — that message was not our answer");
|
|
644
644
|
assert.equal(stats.acked, 1, "instead the missing acknowledgement is finally sent");
|
|
645
|
-
assert.match(t.sent.at(-1).text, /
|
|
645
|
+
assert.match(t.sent.at(-1).text, /on it|looking now|digging in/i);
|
|
646
646
|
});
|
|
647
647
|
});
|
|
648
648
|
|
|
@@ -719,7 +719,7 @@ describe("a running retry is not declared dead", () => {
|
|
|
719
719
|
now: t0 + 45 * 60_000, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false },
|
|
720
720
|
});
|
|
721
721
|
assert.equal(settled.willRetry, true);
|
|
722
|
-
assert.match(t.sent.at(-1).text, /retrying
|
|
722
|
+
assert.match(t.sent.at(-1).text, /retrying now/i);
|
|
723
723
|
|
|
724
724
|
const stats = await sweepObligations({ now: t0 + 51 * 60_000, deps: { deliverImpl: t.impl, spokeSinceImpl: () => false } });
|
|
725
725
|
assert.equal(stats.staled, 0, "the retry is minutes old, not fifty");
|
|
@@ -243,7 +243,8 @@ Your job: classify each incoming message and return a JSON object with exactly t
|
|
|
243
243
|
"model": "opus" | "sonnet",
|
|
244
244
|
"summary": "<one-line summary of the message>",
|
|
245
245
|
"category": "action_required" | "fyi" | "ignore",
|
|
246
|
-
"directed_at_agent": true | false
|
|
246
|
+
"directed_at_agent": true | false,
|
|
247
|
+
"answerable": true | false
|
|
247
248
|
}
|
|
248
249
|
${peerContext}
|
|
249
250
|
Classification rules:
|
|
@@ -271,6 +272,10 @@ CATEGORY:
|
|
|
271
272
|
- "fyi": Informational, no action needed
|
|
272
273
|
- "ignore": No value
|
|
273
274
|
|
|
275
|
+
ANSWERABLE (can ${agentName} answer this DIRECTLY, in one reply, right now?):
|
|
276
|
+
- true: A question that can be answered from what ${agentName} already knows or can see, needing NO tools, NO external lookups, NO drafting, and NO multi-step work — e.g. "what machine are you running on?", "which model are you?", "are you online?", "what's your role?", a simple factual or conversational question. These get answered immediately instead of a "I'll look into it" holding message, even from the ${principalTitle}.
|
|
277
|
+
- false: Anything that needs an action performed (send, schedule, file, push, draft, research, look something up), anything multi-step, or anything that is not a direct question. When unsure, use false — a wrong direct answer is worse than a brief wait.
|
|
278
|
+
|
|
274
279
|
DIRECTED_AT_AGENT (CRITICAL — determines whether ${agentName} should respond. Default to false in channels/group chats.):
|
|
275
280
|
- true: The message is a DM to ${agentName} (1:1)
|
|
276
281
|
- true: The message explicitly mentions ${agentName} by name or @mention
|
|
@@ -300,43 +305,46 @@ Context:
|
|
|
300
305
|
Examples:
|
|
301
306
|
|
|
302
307
|
Input: CEO DM on Slack: "Can you send the board resolution to Jordan?"
|
|
303
|
-
Output: {"priority":"critical","action":"respond","model":"opus","summary":"CEO requesting board resolution sent to GC Jordan","category":"action_required","directed_at_agent":true}
|
|
308
|
+
Output: {"priority":"critical","action":"respond","model":"opus","summary":"CEO requesting board resolution sent to GC Jordan","category":"action_required","directed_at_agent":true,"answerable":false}
|
|
309
|
+
|
|
310
|
+
Input: CEO DM on Slack: "what kind of machine are you running on?"
|
|
311
|
+
Output: {"priority":"critical","action":"respond","model":"opus","summary":"CEO asking what machine/infrastructure the agent runs on","category":"action_required","directed_at_agent":true,"answerable":true}
|
|
304
312
|
|
|
305
313
|
Input: GitHub notification email about a merged PR
|
|
306
|
-
Output: {"priority":"ignore","action":"ignore","model":"sonnet","summary":"GitHub PR merge notification","category":"ignore","directed_at_agent":false}
|
|
314
|
+
Output: {"priority":"ignore","action":"ignore","model":"sonnet","summary":"GitHub PR merge notification","category":"ignore","directed_at_agent":false,"answerable":false}
|
|
307
315
|
|
|
308
316
|
Input: Leadership Slack: "We need the the regulator gap analysis updated by Thursday"
|
|
309
|
-
Output: {"priority":"high","action":"draft","model":"opus","summary":"Leadership requesting the regulator gap analysis update by Thursday deadline","category":"action_required","directed_at_agent":true}
|
|
317
|
+
Output: {"priority":"high","action":"draft","model":"opus","summary":"Leadership requesting the regulator gap analysis update by Thursday deadline","category":"action_required","directed_at_agent":true,"answerable":false}
|
|
310
318
|
|
|
311
319
|
Input: Team member: "Standup notes from today's sync"
|
|
312
|
-
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team standup notes shared","category":"fyi","directed_at_agent":false}
|
|
320
|
+
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team standup notes shared","category":"fyi","directed_at_agent":false,"answerable":false}
|
|
313
321
|
|
|
314
322
|
Input: External email: "Following up on our conversation about the Singapore entity"
|
|
315
|
-
Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"External follow-up on Singapore entity discussion","category":"action_required","directed_at_agent":true}
|
|
323
|
+
Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"External follow-up on Singapore entity discussion","category":"action_required","directed_at_agent":true,"answerable":false}
|
|
316
324
|
|
|
317
325
|
Input: Channel message from Jacob: "@${agentName} can you check the engine deployment?"
|
|
318
|
-
Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"Jacob asking ${agentName} to check engine deployment","category":"action_required","directed_at_agent":true}
|
|
326
|
+
Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"Jacob asking ${agentName} to check engine deployment","category":"action_required","directed_at_agent":true,"answerable":false}
|
|
319
327
|
|
|
320
328
|
Input: Channel message from CEO: "@AnotherAgent can you review the agentic libraries in this channel?"
|
|
321
|
-
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"CEO asking another agent to review agentic libraries — not directed at ${agentName}","category":"fyi","directed_at_agent":false}
|
|
329
|
+
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"CEO asking another agent to review agentic libraries — not directed at ${agentName}","category":"fyi","directed_at_agent":false,"answerable":false}
|
|
322
330
|
|
|
323
331
|
Input: Channel message from Sam to Jordan: "Jordan, did you file the compliance response yet?"
|
|
324
|
-
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Sam asking Jordan about compliance response filing","category":"fyi","directed_at_agent":false}
|
|
332
|
+
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Sam asking Jordan about compliance response filing","category":"fyi","directed_at_agent":false,"answerable":false}
|
|
325
333
|
|
|
326
334
|
Input: Channel thread — Jacob: "pushed the fix" / Sam: "nice, looks good"
|
|
327
|
-
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team exchange about code fix","category":"fyi","directed_at_agent":false}
|
|
335
|
+
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team exchange about code fix","category":"fyi","directed_at_agent":false,"answerable":false}
|
|
328
336
|
|
|
329
337
|
Input: Thread where ${agentName} previously replied — Jacob: "yeah that makes sense, I'll handle it" (responding to Sam, not ${agentName})
|
|
330
|
-
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jacob acknowledging Sam in thread","category":"fyi","directed_at_agent":false}
|
|
338
|
+
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jacob acknowledging Sam in thread","category":"fyi","directed_at_agent":false,"answerable":false}
|
|
331
339
|
|
|
332
340
|
Input: Thread where ${agentName} previously replied — Sam: "${agentName}, can you send the updated doc?"
|
|
333
|
-
Output: {"priority":"high","action":"respond","model":"sonnet","summary":"Sam asking ${agentName} for updated document","category":"action_required","directed_at_agent":true}
|
|
341
|
+
Output: {"priority":"high","action":"respond","model":"sonnet","summary":"Sam asking ${agentName} for updated document","category":"action_required","directed_at_agent":true,"answerable":false}
|
|
334
342
|
|
|
335
343
|
Input: Channel message: "FYI — the Cayman entity docs are signed and filed"
|
|
336
|
-
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"FYI: Cayman entity docs signed and filed","category":"fyi","directed_at_agent":false}
|
|
344
|
+
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"FYI: Cayman entity docs signed and filed","category":"fyi","directed_at_agent":false,"answerable":false}
|
|
337
345
|
|
|
338
346
|
Input: Group chat — Jordan: "Sam, can we sync on the the regulator gaps tomorrow?"
|
|
339
|
-
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jordan asking Sam to sync on the regulator gaps","category":"fyi","directed_at_agent":false}
|
|
347
|
+
Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jordan asking Sam to sync on the regulator gaps","category":"fyi","directed_at_agent":false,"answerable":false}
|
|
340
348
|
|
|
341
349
|
Return ONLY the JSON object. No explanation, no markdown fencing, no extra text.`;
|
|
342
350
|
}
|
|
@@ -407,6 +415,10 @@ function parseClassification(text) {
|
|
|
407
415
|
summary: typeof parsed.summary === "string" ? parsed.summary : "Unclassified message",
|
|
408
416
|
category: validCategories.includes(parsed.category) ? parsed.category : "fyi",
|
|
409
417
|
directed_at_agent: typeof parsed.directed_at_agent === "boolean" ? parsed.directed_at_agent : false, // default false — don't respond unless we're confident the message is for this agent
|
|
418
|
+
// A directly-answerable question gets a quick reply instead of a session +
|
|
419
|
+
// holding ack (see responder.isQuickReply). Default false — when the field
|
|
420
|
+
// is absent or non-boolean, take the session path, never guess "answerable".
|
|
421
|
+
answerable: parsed.answerable === true,
|
|
410
422
|
};
|
|
411
423
|
}
|
|
412
424
|
|
|
@@ -781,5 +793,6 @@ function classifiedAttrs(r) {
|
|
|
781
793
|
model: r.model,
|
|
782
794
|
category: r.category,
|
|
783
795
|
directed_at_agent: r.directed_at_agent,
|
|
796
|
+
answerable: r.answerable,
|
|
784
797
|
};
|
|
785
798
|
}
|
|
@@ -384,6 +384,29 @@ test("RUNG: the quick-reply responder accepts rungs 0-1 and refuses 2+", async (
|
|
|
384
384
|
);
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
+
test("ANSWERABLE: a directly-answerable question is quick-replied, not sessioned + acked", () => {
|
|
388
|
+
// "what machine are you running on?" from the CEO classifies critical/opus,
|
|
389
|
+
// which would otherwise force a session and a robotic holding ack. Flagged
|
|
390
|
+
// answerable, it is answered in the turn instead — the defect being fixed.
|
|
391
|
+
assert.equal(
|
|
392
|
+
responder.isQuickReply({ action: "respond", priority: "critical", model: "opus", answerable: true }),
|
|
393
|
+
true,
|
|
394
|
+
"an answerable question skips the session even at critical/opus",
|
|
395
|
+
);
|
|
396
|
+
// The flag only fast-paths a `respond` — a draft/research item stays on the
|
|
397
|
+
// session path even if the classifier also (contradictorily) flags it.
|
|
398
|
+
assert.equal(responder.isQuickReply({ action: "draft", priority: "high", model: "opus", answerable: true }), false);
|
|
399
|
+
assert.equal(responder.isQuickReply({ action: "research", priority: "normal", model: "sonnet", answerable: true }), false);
|
|
400
|
+
// And the rung veto still wins over answerable.
|
|
401
|
+
assert.equal(
|
|
402
|
+
responder.isQuickReply({ action: "respond", priority: "critical", model: "opus", answerable: true }, { rung: 3 }),
|
|
403
|
+
false,
|
|
404
|
+
"a rung-3 answer needs a session even when the classifier called it answerable",
|
|
405
|
+
);
|
|
406
|
+
// Without the flag, a critical/opus item still takes the session path.
|
|
407
|
+
assert.equal(responder.isQuickReply({ action: "respond", priority: "critical", model: "opus" }), false);
|
|
408
|
+
});
|
|
409
|
+
|
|
387
410
|
// ---------------------------------------------------------------------------
|
|
388
411
|
// 8) REGRESSION (live run): with no need estimate every DM routed to rung 3,
|
|
389
412
|
// which vetoed the quick reply and spawned a session for a two-line message.
|
|
@@ -1075,6 +1075,17 @@ export function isQuickReply(classResult, routed = null) {
|
|
|
1075
1075
|
// place, or a caller that has no ladder) leaves the original rules untouched.
|
|
1076
1076
|
if (!rungPermitsQuickReply(routed)) return false;
|
|
1077
1077
|
|
|
1078
|
+
// ── directly-answerable question: answer it, don't ack it ────────────────
|
|
1079
|
+
// A factual question the classifier judged answerable in one reply — no
|
|
1080
|
+
// tools, no drafting, no multi-step work ("what machine are you running on?")
|
|
1081
|
+
// — belongs on the quick path even from the CEO and even when the model or
|
|
1082
|
+
// priority would otherwise route it to a session. Spawning a 15-minute
|
|
1083
|
+
// session and posting a robotic "I'm looking into this now (CEO asking…)" to
|
|
1084
|
+
// a question that could be answered in one line IS the defect being fixed.
|
|
1085
|
+
// Gated to `respond` so a `draft`/`research` item that also gets flagged
|
|
1086
|
+
// answerable (a classifier contradiction) still takes the session path.
|
|
1087
|
+
if (classResult.answerable === true && classResult.action === "respond") return true;
|
|
1088
|
+
|
|
1078
1089
|
// These always need full sessions — never quick reply
|
|
1079
1090
|
if (classResult.action === "research") return false;
|
|
1080
1091
|
if (classResult.action === "draft") return false;
|