@cohortapp/agent-sdk 2.9.1 → 2.10.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/lib/channels/inbox-item.mjs +4 -0
- package/lib/comms/send-gate.mjs +23 -1
- package/lib/comms/send-gate.test.mjs +24 -0
- package/lib/model-router/economics.mjs +53 -1
- package/lib/model-router/economics.test.mjs +76 -0
- package/lib/model-router/resolve.mjs +57 -4
- package/lib/model-router.mjs +95 -8
- package/lib/model-router.test.mjs +305 -5
- package/lib/org/inbound/project.mjs +9 -5
- package/lib/org/messaging.mjs +6 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +78 -3
- package/lib/org/protocol.test.mjs +14 -2
- package/package.json +1 -1
- package/scripts/daemon/assurance.mjs +12 -1
- package/scripts/daemon/deliver.mjs +457 -33
- package/scripts/daemon/deliver.test.mjs +564 -0
- package/scripts/daemon/responder-history.test.mjs +18 -2
- package/scripts/daemon/responder.mjs +7 -1
|
@@ -31,6 +31,10 @@ import { join } from "path";
|
|
|
31
31
|
import { screenOutbound } from "../../lib/comms/send-gate.mjs";
|
|
32
32
|
import { getHookBus } from "../../lib/hooks/bus.mjs";
|
|
33
33
|
import { recordOutbound } from "../../lib/comms/receipts.mjs";
|
|
34
|
+
// Pure data (a frozen table, no imports of its own) — the surface vocabulary the
|
|
35
|
+
// inbound projection stamps into `raw_ref`. Imported rather than re-listed so a
|
|
36
|
+
// new surface cannot be added upstream without this file's switch noticing.
|
|
37
|
+
import { SURFACE_NAMES } from "../../lib/org/inbound/surfaces.mjs";
|
|
34
38
|
|
|
35
39
|
const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
|
|
36
40
|
|
|
@@ -63,18 +67,288 @@ export function resolveSlackChannel(item) {
|
|
|
63
67
|
return null;
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Cohort surface routing
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* WHY THIS EXISTS — the `task/<title>` incident.
|
|
76
|
+
*
|
|
77
|
+
* Cohort inbound is not one surface, it is fourteen. Four of them are ROOMS (a
|
|
78
|
+
* DM, an @mention, a thread reply, a call invite) and a reply to those is a
|
|
79
|
+
* `messaging.send` into a channel id. The other ten are ENTITIES — a board
|
|
80
|
+
* item's comment thread, a doc's comment thread, a decision's thread, an
|
|
81
|
+
* approval, a handoff — and their reply surface is the ENTITY, not a room.
|
|
82
|
+
* `lib/org/inbound/project.mjs` names this exact failure mode in a comment.
|
|
83
|
+
*
|
|
84
|
+
* SOME OF THEM DO CARRY A CHANNEL, and this module routes them to the entity
|
|
85
|
+
* anyway. `lib/org/inbound/hydrate.mjs` sets
|
|
86
|
+
* `channelId: c.ids.channelId || (task && task.channelId) || ""`, so a task
|
|
87
|
+
* that lives in a Space has a real, sendable channel id, and the old code did
|
|
88
|
+
* deliver those — as a loose chat message in the Space, detached from the task
|
|
89
|
+
* thread the human was reading. THIS IS A DELIBERATE, VISIBLE BEHAVIOUR CHANGE:
|
|
90
|
+
* a board comment now gets a board comment, always, not "a board comment when
|
|
91
|
+
* we happen to have nowhere else to put it". The reply lands where the question
|
|
92
|
+
* was asked. If that is ever wrong, the fix is a `room()` fallback on the board
|
|
93
|
+
* case — not restoring `channel_id ||` as a silent first choice.
|
|
94
|
+
*
|
|
95
|
+
* Downstream never enforced it. `sendCohortReply` read
|
|
96
|
+
* `item.channel_id || item.channel`, and `item.channel` is the HUMAN LABEL
|
|
97
|
+
* (`lib/channels/inbox-item.mjs` carries `channel_label` there:
|
|
98
|
+
* `task/Roll out tier-3 guardrails…`, `doc/AI-BORN-CHARTER…pdf`,
|
|
99
|
+
* `approval/…`). So a board comment was answered by posting the reply to a
|
|
100
|
+
* channel whose id was a task's TITLE. hq's messaging.send resolves that to
|
|
101
|
+
* nothing and throws NOT_FOUND; the daemon caught it, logged a warning, and
|
|
102
|
+
* `deliverWithRetry` — whose permanent-failure test only matched send-gate
|
|
103
|
+
* blocks — retried the structurally impossible send three times, every sweep,
|
|
104
|
+
* forever. Six live items on one agent's disk were in that state.
|
|
105
|
+
*
|
|
106
|
+
* The rule this module now enforces, at the ONE chokepoint every outbound
|
|
107
|
+
* crosses (the answer, the acknowledgement and the assurance sweep all land in
|
|
108
|
+
* `deliver`):
|
|
109
|
+
*
|
|
110
|
+
* A LABEL IS NEVER AN ID. An item from a surface with no room is answered ON
|
|
111
|
+
* THAT SURFACE — a board comment gets a board comment — and an item that
|
|
112
|
+
* cannot be addressed at all fails LOUDLY, permanently, with the reason,
|
|
113
|
+
* rather than being guessed into a channel that does not exist.
|
|
114
|
+
*
|
|
115
|
+
* `item.channel` is not consulted anywhere on the send path any more, on any
|
|
116
|
+
* service: telegram's is `dm/<name>`, Cohort's is `task/<title>`. There is no
|
|
117
|
+
* correct case for it.
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/** Surfaces that genuinely live in a Cohort room — these reply with a message. */
|
|
121
|
+
const CHANNEL_SURFACES = Object.freeze(["dm", "mention", "thread_reply", "call"]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Error frame codes a retry cannot change.
|
|
125
|
+
*
|
|
126
|
+
* `UNAUTHORIZED` IS DELIBERATELY NOT ON THIS LIST. The others describe the
|
|
127
|
+
* REQUEST — a task that does not exist, a body hq will never accept, a scope
|
|
128
|
+
* this key does not hold — and re-sending the identical bytes cannot change the
|
|
129
|
+
* answer. A 401 describes the CREDENTIAL at one instant: a token mid-rotation,
|
|
130
|
+
* an hq restart between issuing and honouring, a clock skew on a signature.
|
|
131
|
+
* Treating it as permanent throws the reply away on the first blip of an
|
|
132
|
+
* otherwise healthy deployment, which is a strictly worse failure than one
|
|
133
|
+
* extra attempt.
|
|
134
|
+
*/
|
|
135
|
+
const PERMANENT_RPC_CODES = Object.freeze([
|
|
136
|
+
"NOT_FOUND",
|
|
137
|
+
"BAD_REQUEST",
|
|
138
|
+
"FORBIDDEN",
|
|
139
|
+
"FORBIDDEN_SCOPE",
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The one BAD_REQUEST a board comment is allowed to answer by re-asking with a
|
|
144
|
+
* different author kind. hq's `board/addTaskComment.ts` throws
|
|
145
|
+
* "this task has no responsible agent to reply" / "responsible agent not found"
|
|
146
|
+
* when `kind:"agent"` finds no assignee and no reviewer. Matched on the phrase
|
|
147
|
+
* both messages share, so a reworded message keeps working and an unrelated
|
|
148
|
+
* BAD_REQUEST (a malformed body, a bad id) stays permanent.
|
|
149
|
+
*/
|
|
150
|
+
const NO_RESPONSIBLE_AGENT = /responsible agent/i;
|
|
151
|
+
|
|
152
|
+
function str(v) {
|
|
153
|
+
return v == null ? "" : String(v).trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The entity id `project.mjs` stamped into `raw_ref` (`cohort:<surface>:<id>:<seq>`). */
|
|
157
|
+
function entityFromRawRef(item) {
|
|
158
|
+
const m = /^cohort:[a-z_]+:(.+):[^:]*$/.exec(str(item && item.raw_ref));
|
|
159
|
+
return m ? str(m[1]) : "";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The inbound SURFACE this Cohort item came from.
|
|
164
|
+
*
|
|
165
|
+
* `raw_ref` is authoritative — `project.mjs` writes the surface name into it
|
|
166
|
+
* verbatim. `item.kind` is the fallback, but it is LOSSY: `doc_comment` (a
|
|
167
|
+
* workspace doc, family `files`) and `file_comment` (a chat attachment, family
|
|
168
|
+
* `file`) share the MessageEvent kind `file_comment` and take different reply
|
|
169
|
+
* methods with different id shapes, so they are told apart by the one thing
|
|
170
|
+
* that distinguishes them — the chat attachment always names its room.
|
|
171
|
+
*/
|
|
172
|
+
export function cohortSurfaceOf(item) {
|
|
173
|
+
if (!item) return "";
|
|
174
|
+
// Guarded against the LEGACY raw_ref shape, which is `cohort:<channelId>:
|
|
175
|
+
// <messageId>` — still on disk today. An all-lowercase channel id would
|
|
176
|
+
// otherwise parse as a surface name and route a working DM to nowhere.
|
|
177
|
+
const m = /^cohort:([a-z_]+):/.exec(str(item.raw_ref));
|
|
178
|
+
if (m && SURFACE_NAMES.includes(m[1])) return m[1];
|
|
179
|
+
const kind = str(item.kind);
|
|
180
|
+
if (kind === "file_comment") return str(item.channel_id) ? "file_comment" : "doc_comment";
|
|
181
|
+
if (!kind || kind === "message") return "dm";
|
|
182
|
+
return kind;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Where a reply to this Cohort item goes — a pure function of the item, so it
|
|
187
|
+
* can be asked BEFORE anything is promised (see `canDeliverTo`).
|
|
188
|
+
*
|
|
189
|
+
* @param {object} item daemon inbox item (service === "cohort")
|
|
190
|
+
* @returns {{transport:"channel", surface:string, channelId:string, target:string}
|
|
191
|
+
* |{transport:"rpc", surface:string, method:string, params:object, bodyKey:string, target:string}
|
|
192
|
+
* |{transport:null, surface:string, error:string}}
|
|
193
|
+
*/
|
|
194
|
+
export function cohortReplyRoute(item) {
|
|
195
|
+
if (!item) return { transport: null, surface: "", error: "no item" };
|
|
196
|
+
const surface = cohortSurfaceOf(item);
|
|
197
|
+
const channelId = str(item.channel_id);
|
|
198
|
+
const threadId = str(item.thread_id);
|
|
199
|
+
const scopeId = str(item.scope_id);
|
|
200
|
+
const entity = entityFromRawRef(item);
|
|
201
|
+
|
|
202
|
+
// A surface whose reply IS a room message. `channel_id` only ever carries a
|
|
203
|
+
// real Cohort channel id (project.mjs guarantees it); blank means no room.
|
|
204
|
+
const room = (why) =>
|
|
205
|
+
channelId
|
|
206
|
+
? { transport: "channel", surface, channelId, target: channelId }
|
|
207
|
+
: { transport: null, surface, error: why };
|
|
208
|
+
|
|
209
|
+
if (CHANNEL_SURFACES.includes(surface)) {
|
|
210
|
+
return room(`cohort ${surface} item has no channel_id — nothing to reply into`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
switch (surface) {
|
|
214
|
+
// ── board. The task id comes from `scope_id`, else `thread_id`, else
|
|
215
|
+
// `raw_ref`. hydrate.mjs sets `threadId: taskId`; `project.mjs`'s
|
|
216
|
+
// `scope_id` is `taskId || decisionId || fileId`, i.e. NOT surface-typed
|
|
217
|
+
// — which is safe here only because a board item is the one surface whose
|
|
218
|
+
// `scope_id` is a task (the live approval item on disk proves the general
|
|
219
|
+
// case: its `scope_id` is a task id while its `thread_id` is the approval,
|
|
220
|
+
// and approvals have no RPC route at all).
|
|
221
|
+
//
|
|
222
|
+
// `kind` DECIDES WHOSE NAME IS ON THE COMMENT, and hq resolves it
|
|
223
|
+
// server-side (`server/methods/board/addTaskComment.ts`):
|
|
224
|
+
// "agent" → the task's assignee, else its reviewer;
|
|
225
|
+
// "human" → the ACTING member, i.e. this paired agent.
|
|
226
|
+
// "human" is the safer call for authorship and the wrong one for
|
|
227
|
+
// LABELLING: `components/board/task-conversation.tsx` renders
|
|
228
|
+
// `kind === "human"` as the viewer's own "me" bubble WITHOUT an
|
|
229
|
+
// `<Avatar kind>` — so an AI reply arrived looking like something the
|
|
230
|
+
// reading human had written themselves, on the one lane whose whole
|
|
231
|
+
// argument is AI disclosure. So: ask for "agent", which is both the
|
|
232
|
+
// honest label and, for a directed board item, the correct author (the
|
|
233
|
+
// inbound is directed here BECAUSE this agent is the assignee/reviewer);
|
|
234
|
+
// and fall back to "human" for the one case hq refuses it — an
|
|
235
|
+
// unassigned, unreviewed task, where "agent" is a BAD_REQUEST and the
|
|
236
|
+
// acting member is the only author there is. See `fallbackParams`.
|
|
237
|
+
case "task_assigned":
|
|
238
|
+
case "task_comment": {
|
|
239
|
+
const taskId = scopeId || threadId || entity;
|
|
240
|
+
if (!taskId) {
|
|
241
|
+
return { transport: null, surface, error: "board item carries no task id (scope_id, thread_id and raw_ref all blank)" };
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
transport: "rpc", surface,
|
|
245
|
+
method: "board.addTaskComment",
|
|
246
|
+
params: { taskId, kind: "agent" },
|
|
247
|
+
fallbackParams: { taskId, kind: "human" },
|
|
248
|
+
fallbackWhen: NO_RESPONSIBLE_AGENT,
|
|
249
|
+
bodyKey: "body",
|
|
250
|
+
target: `task:${taskId}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── workspace doc (family `files`). Both handles are the File id.
|
|
255
|
+
case "doc_comment": {
|
|
256
|
+
const fileId = scopeId || threadId || entity;
|
|
257
|
+
if (!fileId) {
|
|
258
|
+
return { transport: null, surface, error: "doc comment carries no file id (scope_id, thread_id and raw_ref all blank)" };
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
transport: "rpc", surface,
|
|
262
|
+
method: "files.commentAdd",
|
|
263
|
+
params: { fileId },
|
|
264
|
+
bodyKey: "body",
|
|
265
|
+
target: `file:${fileId}`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── chat attachment (family `file`). The handle is the opaque `fileKey`,
|
|
270
|
+
// which hydrate.mjs puts in `threadId` — `raw_ref` here holds the
|
|
271
|
+
// COMMENT id, not the file, so it is deliberately not a fallback. hq's
|
|
272
|
+
// `file.addComment` also requires the room the attachment lives in.
|
|
273
|
+
case "file_comment": {
|
|
274
|
+
const fileKey = threadId;
|
|
275
|
+
if (!fileKey) return { transport: null, surface, error: "chat file comment carries no fileKey (thread_id blank)" };
|
|
276
|
+
if (!channelId) return { transport: null, surface, error: `chat file comment on ${fileKey} has no channel_id — file.addComment requires one` };
|
|
277
|
+
return {
|
|
278
|
+
transport: "rpc", surface,
|
|
279
|
+
method: "file.addComment",
|
|
280
|
+
params: { fileKey, channelId },
|
|
281
|
+
bodyKey: "body",
|
|
282
|
+
target: `file:${fileKey}`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── decision. NOTE the param is `text`, not `body` (decision/comment.ts).
|
|
287
|
+
case "decision": {
|
|
288
|
+
const decisionId = scopeId || threadId || entity;
|
|
289
|
+
if (!decisionId) {
|
|
290
|
+
return { transport: null, surface, error: "decision carries no decision id (scope_id, thread_id and raw_ref all blank)" };
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
transport: "rpc", surface,
|
|
294
|
+
method: "decision.comment",
|
|
295
|
+
params: { decisionId },
|
|
296
|
+
bodyKey: "text",
|
|
297
|
+
target: `decision:${decisionId}`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── escalation / calendar. hq attaches a real channel to these when the
|
|
302
|
+
// entity is room-bound, and that channel IS the surface. When it is not
|
|
303
|
+
// (an escalation hanging off a task, an event with no room) there is no
|
|
304
|
+
// prose surface to write into: neither family has a comment method.
|
|
305
|
+
case "escalation":
|
|
306
|
+
return room("escalation is not room-bound and has no comment surface (escalation.resolve/answer are verdicts, not messages)");
|
|
307
|
+
case "calendar":
|
|
308
|
+
return room("calendar event is not room-bound and has no comment surface");
|
|
309
|
+
|
|
310
|
+
// ── verdict surfaces. Answering these is an ACT, not a sentence, and
|
|
311
|
+
// guessing an act from generated prose is not something this transport
|
|
312
|
+
// is allowed to do. Undeliverable is the honest answer — assurance then
|
|
313
|
+
// escalates to an operator instead of promising a reply it cannot send.
|
|
314
|
+
case "approval":
|
|
315
|
+
return { transport: null, surface, error: "approval has no comment surface — a response is a verdict (approval.resolve), not a message" };
|
|
316
|
+
case "handoff":
|
|
317
|
+
return { transport: null, surface, error: "handoff has no comment surface — a response is handoff.accept/decline, not a message" };
|
|
318
|
+
|
|
319
|
+
// ── email. `channel_id` on this surface is a MAILBOX id, which
|
|
320
|
+
// messaging.send would reject exactly like a task title did. Replying
|
|
321
|
+
// needs `email.send` with a recipient address, and the inbox item does
|
|
322
|
+
// not carry one (only the sender's display name survives projection).
|
|
323
|
+
case "email":
|
|
324
|
+
return { transport: null, surface, error: "cohort email reply needs email.send with a recipient address; this item carries none (channel_id is a mailbox id, not a channel)" };
|
|
325
|
+
|
|
326
|
+
default:
|
|
327
|
+
return { transport: null, surface, error: `unknown cohort surface ${surface || "(none)"} — no reply route` };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
66
331
|
/**
|
|
67
|
-
* The
|
|
68
|
-
*
|
|
332
|
+
* The place a reply to this item belongs, whatever the service — the key both
|
|
333
|
+
* the receipt ledger and the obligation ledger are keyed on.
|
|
334
|
+
*
|
|
335
|
+
* Routing is by SURFACE, not by person: the surface the inbound arrived on is
|
|
336
|
+
* the surface the answer belongs on. For a room that is the channel id; for an
|
|
337
|
+
* entity thread it is `task:<id>` / `file:<id>` / `decision:<id>`.
|
|
69
338
|
*
|
|
70
|
-
*
|
|
71
|
-
* room the answer belongs in.
|
|
339
|
+
* Returns null — never a label — when nothing addressable exists.
|
|
72
340
|
*/
|
|
73
341
|
export function replyTargetOf(item) {
|
|
74
342
|
if (!item) return null;
|
|
75
343
|
if (item.service === "slack") return resolveSlackChannel(item);
|
|
76
344
|
if (item.service === "gmail") return item.sender_email || item.sender || null;
|
|
77
|
-
|
|
345
|
+
if (item.service === "cohort") {
|
|
346
|
+
const route = cohortReplyRoute(item);
|
|
347
|
+
return route.transport ? route.target : null;
|
|
348
|
+
}
|
|
349
|
+
// Every other producer addresses by id. `item.channel` is the display LABEL
|
|
350
|
+
// (`dm/<name>`) and was the fallback here until it got handed to a send.
|
|
351
|
+
return item.channel_id || null;
|
|
78
352
|
}
|
|
79
353
|
|
|
80
354
|
/**
|
|
@@ -93,7 +367,13 @@ export const DELIVERABLE_SERVICES = Object.freeze(["slack", "gmail", "cohort"]);
|
|
|
93
367
|
|
|
94
368
|
/**
|
|
95
369
|
* Can `deliver` reach the human behind this item at all? Requires both a
|
|
96
|
-
* transport for the service AND a resolvable
|
|
370
|
+
* transport for the service AND a resolvable surface to write onto.
|
|
371
|
+
*
|
|
372
|
+
* This used to answer TRUE for every Cohort board/doc/decision item, because
|
|
373
|
+
* `replyTargetOf` fell through to the human label and a label is truthy. The
|
|
374
|
+
* assurance ledger asks this BEFORE it promises an acknowledgement, so a "yes"
|
|
375
|
+
* here on an unsendable item is how the daemon came to promise a reply it had
|
|
376
|
+
* no way to deliver.
|
|
97
377
|
*/
|
|
98
378
|
export function canDeliverTo(item) {
|
|
99
379
|
if (!item || !item.service) return false;
|
|
@@ -172,9 +452,13 @@ async function sendGmailResponse(item, text) {
|
|
|
172
452
|
// ---------------------------------------------------------------------------
|
|
173
453
|
|
|
174
454
|
/**
|
|
175
|
-
* Send onto Cohort
|
|
176
|
-
*
|
|
177
|
-
*
|
|
455
|
+
* Send onto Cohort — ONTO THE SURFACE THE ITEM CAME FROM.
|
|
456
|
+
*
|
|
457
|
+
* `cohortReplyRoute` decides. A room surface goes through
|
|
458
|
+
* lib/org/messaging.sendMessage (so the outbound send-gate, the afterSend hook
|
|
459
|
+
* and cost attribution all still run); an entity thread goes through its own
|
|
460
|
+
* comment method with the send-gate applied here. Nothing guesses a channel,
|
|
461
|
+
* and `item.channel` — a human label — is never read.
|
|
178
462
|
*
|
|
179
463
|
* `idempotencySuffix` distinguishes the several distinct things we may say
|
|
180
464
|
* about ONE item — the acknowledgement, the FIRST progress update, the SECOND
|
|
@@ -189,29 +473,151 @@ async function sendGmailResponse(item, text) {
|
|
|
189
473
|
* dedup doing its job.
|
|
190
474
|
*/
|
|
191
475
|
async function sendCohortReply(item, text, o = {}) {
|
|
476
|
+
const route = cohortReplyRoute(item);
|
|
477
|
+
|
|
478
|
+
// LOUD. A reply with nowhere to go is a defect in the item or in the routing
|
|
479
|
+
// table above, and the whole reason this bug survived for months is that its
|
|
480
|
+
// symptom was a caught NOT_FOUND folded into a warning nobody read. Say the
|
|
481
|
+
// surface, say why, and mark it PERMANENT so the sweep stops re-sending it.
|
|
482
|
+
if (!route.transport) {
|
|
483
|
+
const why = `cannot address a reply to ${route.surface || "unknown"} item ${str(item && (item.raw_ref || item.id)) || "(no ref)"}: ${route.error}`;
|
|
484
|
+
console.error(`[deliver] ${why}`);
|
|
485
|
+
return { sent: false, via: null, permanent: true, surface: route.surface, error: why };
|
|
486
|
+
}
|
|
487
|
+
|
|
192
488
|
try {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
const { sendMessage } = await import("../../lib/org/messaging.mjs");
|
|
196
|
-
const { loadOrgConfig } = await import("../../lib/org/client.mjs");
|
|
197
|
-
const agentRoot = process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
|
|
489
|
+
const agentRoot = o.agentRoot || process.env.AGENT_ROOT || process.env.AGENT_DIR || process.cwd();
|
|
490
|
+
const cfg = o.cfg || (await import("../../lib/org/client.mjs")).loadOrgConfig(agentRoot);
|
|
198
491
|
const base = item.message_id || item.id || item.raw_ref || Date.now();
|
|
199
492
|
const suffix = o.idempotencySuffix ? `-${o.idempotencySuffix}` : "";
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
493
|
+
const idempotencyId = `reply-${base}${suffix}`;
|
|
494
|
+
|
|
495
|
+
let frame;
|
|
496
|
+
if (route.transport === "channel") {
|
|
497
|
+
const sendMessageImpl = o.sendMessageImpl || (await import("../../lib/org/messaging.mjs")).sendMessage;
|
|
498
|
+
frame = await sendMessageImpl(
|
|
499
|
+
{
|
|
500
|
+
channel: route.channelId,
|
|
501
|
+
body: text,
|
|
502
|
+
idempotencyId,
|
|
503
|
+
...(item.thread_id ? { threadId: item.thread_id } : {}),
|
|
504
|
+
...(Array.isArray(o.mentions) && o.mentions.length ? { mentions: o.mentions } : {}),
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
cfg, agentRoot,
|
|
508
|
+
...(o.gateImpl ? { gateImpl: o.gateImpl } : {}),
|
|
509
|
+
...(o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}),
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
} else {
|
|
513
|
+
frame = await sendCohortEntityComment(route, text, { ...o, cfg, agentRoot, idempotencyId });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (frame && frame.ok) return { sent: true, via: route.transport === "channel" ? "cohort" : route.method, channel: route.target, surface: route.surface };
|
|
517
|
+
const code = str(frame && frame.error && frame.error.code);
|
|
518
|
+
const message = str(frame && frame.error && frame.error.message) || "send failed";
|
|
519
|
+
if (code && PERMANENT_RPC_CODES.includes(code)) {
|
|
520
|
+
// A NOT_FOUND/BAD_REQUEST is the server saying "this can never work".
|
|
521
|
+
// Retrying it is what produced `"attempts":3` on every failed row.
|
|
522
|
+
console.error(`[deliver] ${route.method || "messaging.send"} → ${route.target}: ${code} ${message} (permanent, not retrying)`);
|
|
523
|
+
return { sent: false, via: null, permanent: true, channel: route.target, surface: route.surface, code, error: `${code}: ${message}` };
|
|
524
|
+
}
|
|
525
|
+
return { sent: false, via: null, channel: route.target, surface: route.surface, ...(code ? { code } : {}), error: code ? `${code}: ${message}` : message };
|
|
212
526
|
} catch (err) {
|
|
213
|
-
return { sent: false, via: null, error: err && err.message };
|
|
527
|
+
return { sent: false, via: null, channel: route.target, surface: route.surface, error: err && err.message };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Post onto a Cohort ENTITY thread (a task, a doc, a chat attachment, a
|
|
533
|
+
* decision) rather than into a room.
|
|
534
|
+
*
|
|
535
|
+
* The send-gate runs here too, and FAIL-CLOSED. `lib/org/messaging.sendMessage`
|
|
536
|
+
* is the chokepoint for room messages; these routes bypass it, so without this
|
|
537
|
+
* a board reply would be the one outbound in the system that skipped
|
|
538
|
+
* banned-phrase, AI-disclosure and information-barrier screening. Fail-closed
|
|
539
|
+
* matches messaging.mjs's own Cohort posture: never send what we could not
|
|
540
|
+
* screen.
|
|
541
|
+
*
|
|
542
|
+
* THE GATE IS CALLED WITH THE SAME ARGUMENTS THE ROOM LANE USES, all of them.
|
|
543
|
+
* `screenOutbound` defaults `firstContact` to TRUE (send-gate.mjs), and under
|
|
544
|
+
* the shipped `disclose-always` posture that blocks any message which does not
|
|
545
|
+
* proactively disclose AI. `lib/org/messaging.sendMessage` passes `false` on
|
|
546
|
+
* purpose — an org room is a continuing conversation with colleagues, not a
|
|
547
|
+
* cold approach — and so must this. Omitting it made the gate refuse 100% of
|
|
548
|
+
* board/doc/decision replies with FORBIDDEN_SCOPE, which is in
|
|
549
|
+
* {@link PERMANENT_RPC_CODES}: dropped on the first attempt, never retried.
|
|
550
|
+
* A routing table that reaches the wrong wire and a routing table that reaches
|
|
551
|
+
* no wire at all are the same bug wearing different error strings, so the test
|
|
552
|
+
* for this lane runs the REAL gate against the REAL policies rather than a stub
|
|
553
|
+
* (`deliver.test.mjs`, "the real send-gate").
|
|
554
|
+
*/
|
|
555
|
+
async function sendCohortEntityComment(route, text, o = {}) {
|
|
556
|
+
const { errFrame } = await import("../../lib/org/protocol.mjs");
|
|
557
|
+
const gate = o.gateImpl || screenOutbound;
|
|
558
|
+
let screened;
|
|
559
|
+
try {
|
|
560
|
+
screened = await gate({
|
|
561
|
+
channel: "cohort",
|
|
562
|
+
recipient: route.target,
|
|
563
|
+
text,
|
|
564
|
+
agentRoot: o.agentRoot || AGENT_REPO_DIR,
|
|
565
|
+
jurisdiction: o.jurisdiction,
|
|
566
|
+
firstContact: o.firstContact !== undefined ? o.firstContact : false,
|
|
567
|
+
internalDomains: o.internalDomains || [],
|
|
568
|
+
allowlist: o.allowlist,
|
|
569
|
+
});
|
|
570
|
+
} catch (err) {
|
|
571
|
+
return errFrame("INTERNAL", `send-gate error: ${err && err.message ? err.message : String(err)}`);
|
|
572
|
+
}
|
|
573
|
+
if (!screened || !screened.allow) {
|
|
574
|
+
return errFrame("FORBIDDEN_SCOPE", (screened && screened.reason) || "blocked by send-gate");
|
|
575
|
+
}
|
|
576
|
+
const body = screened.redactedText != null ? screened.redactedText : text;
|
|
577
|
+
|
|
578
|
+
// Same enrollment resolution messaging.mjs uses — one place decides whether
|
|
579
|
+
// this agent is paired, and to which base.
|
|
580
|
+
const { isEnabled, configFromAgent, call } = await import("../../lib/org/client.mjs");
|
|
581
|
+
if (!isEnabled(o.cfg)) return errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
|
|
582
|
+
const c = configFromAgent(o.cfg);
|
|
583
|
+
if (!c.base) return errFrame("BAD_REQUEST", "org messaging disabled or unconfigured");
|
|
584
|
+
|
|
585
|
+
const callImpl = o.callImpl || call;
|
|
586
|
+
const wire = (params) =>
|
|
587
|
+
callImpl(
|
|
588
|
+
route.method,
|
|
589
|
+
{ ...params, [route.bodyKey]: body },
|
|
590
|
+
{ base: c.base, token: c.token, orgId: c.orgId, idempotencyKey: o.idempotencyId || undefined, fetchImpl: o.fetchImpl },
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
let frame = await wire(route.params);
|
|
594
|
+
|
|
595
|
+
// ONE re-ask, on ONE named refusal, with a DIFFERENT request — not a retry.
|
|
596
|
+
// `deliverWithRetry` re-sends the same bytes and cannot help here: hq has
|
|
597
|
+
// already told us this task has nobody to attribute an agent comment to, and
|
|
598
|
+
// it will say so again. See the `kind` note on the board route.
|
|
599
|
+
if (
|
|
600
|
+
frame && !frame.ok && route.fallbackParams &&
|
|
601
|
+
str(frame.error && frame.error.code) === "BAD_REQUEST" &&
|
|
602
|
+
(!route.fallbackWhen || route.fallbackWhen.test(str(frame.error && frame.error.message)))
|
|
603
|
+
) {
|
|
604
|
+
console.warn(`[deliver] ${route.method} → ${route.target}: ${str(frame.error.message)} — re-asking as the acting member`);
|
|
605
|
+
frame = await wire(route.fallbackParams);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (frame && frame.ok) {
|
|
609
|
+
try {
|
|
610
|
+
await getHookBus().emit("afterSend", { channel: "cohort", recipient: route.target, text: body, result: frame.result, source: route.method });
|
|
611
|
+
} catch { /* observe-only: a broken subscriber must never break a send */ }
|
|
612
|
+
// Cost/usage attribution, exactly as `lib/org/messaging.sendMessage` does it
|
|
613
|
+
// for a room send (its invariant #3). Without this the board/doc/decision
|
|
614
|
+
// lane was the one outbound family missing from the messaging ledger.
|
|
615
|
+
try {
|
|
616
|
+
const { attributeMessaging } = await import("../../lib/org/messaging.mjs");
|
|
617
|
+
await attributeMessaging({ cfg: o.cfg, agentRoot: o.agentRoot, action: route.method, channel: route.target }, o);
|
|
618
|
+
} catch { /* attribution is observational — never fail a delivered send on it */ }
|
|
214
619
|
}
|
|
620
|
+
return frame;
|
|
215
621
|
}
|
|
216
622
|
|
|
217
623
|
// ---------------------------------------------------------------------------
|
|
@@ -231,11 +637,17 @@ async function sendCohortReply(item, text, o = {}) {
|
|
|
231
637
|
* @param {string} text exactly what the human will read
|
|
232
638
|
* @param {object} [o]
|
|
233
639
|
* @param {string} [o.kind] receipt kind: "ack" | "progress" | "failure" | "reply"
|
|
234
|
-
* @param {string[]} [o.mentions] org member ids to @-tag (cohort only)
|
|
640
|
+
* @param {string[]} [o.mentions] org member ids to @-tag (cohort room sends only)
|
|
235
641
|
* @param {function} [o.fetchImpl] test seam
|
|
236
|
-
* @
|
|
642
|
+
* @param {function} [o.callImpl] test seam — the org RPC (entity-thread routes)
|
|
643
|
+
* @param {function} [o.sendMessageImpl] test seam — messaging.send (room routes)
|
|
644
|
+
* @param {function} [o.gateImpl] test seam — the outbound send-gate
|
|
645
|
+
* @param {object} [o.cfg] test seam — org config, else loaded from disk
|
|
646
|
+
* @returns {Promise<{sent:boolean, via:string|null, channel?:string, surface?:string,
|
|
647
|
+
* permanent?:boolean, code?:string, error?:string}>}
|
|
237
648
|
* NEVER throws — a transport failure is returned, so the caller can
|
|
238
|
-
* escalate rather than lose the message to an exception.
|
|
649
|
+
* escalate rather than lose the message to an exception. `permanent`
|
|
650
|
+
* means retrying cannot help (no route, or a NOT_FOUND/BAD_REQUEST).
|
|
239
651
|
*/
|
|
240
652
|
export async function deliver(item, text, o = {}) {
|
|
241
653
|
const kind = o.kind || "reply";
|
|
@@ -302,8 +714,11 @@ export async function deliverWithRetry(item, text, o = {}) {
|
|
|
302
714
|
last = await deliver(item, text, o);
|
|
303
715
|
if (last.sent) return { ...last, attempts: i + 1 };
|
|
304
716
|
// A policy block is a decision, not a fault — retrying re-runs the same
|
|
305
|
-
// deterministic screen and gets the same answer.
|
|
306
|
-
|
|
717
|
+
// deterministic screen and gets the same answer. So is a NOT_FOUND: the
|
|
718
|
+
// server has said the thing we are writing to does not exist, and a second
|
|
719
|
+
// and third attempt only widen the hole. Every `"attempts":3` row in the
|
|
720
|
+
// response log was one impossible send tried three times. Stop, surface it.
|
|
721
|
+
if (last.permanent || (last.error && /send-gate blocked|blocked by send-gate|FORBIDDEN|NOT_FOUND/i.test(last.error))) {
|
|
307
722
|
return { ...last, attempts: i + 1, permanent: true };
|
|
308
723
|
}
|
|
309
724
|
if (i < attempts - 1) await sleep(base * Math.pow(2, i));
|
|
@@ -311,4 +726,13 @@ export async function deliverWithRetry(item, text, o = {}) {
|
|
|
311
726
|
return { ...last, attempts };
|
|
312
727
|
}
|
|
313
728
|
|
|
314
|
-
export default {
|
|
729
|
+
export default {
|
|
730
|
+
deliver,
|
|
731
|
+
deliverWithRetry,
|
|
732
|
+
resolveSlackChannel,
|
|
733
|
+
replyTargetOf,
|
|
734
|
+
canDeliverTo,
|
|
735
|
+
cohortReplyRoute,
|
|
736
|
+
cohortSurfaceOf,
|
|
737
|
+
DELIVERABLE_SERVICES,
|
|
738
|
+
};
|