@cohortapp/agent-sdk 2.4.0 → 2.5.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/bin/maestro.mjs +9 -0
- package/lib/backlog.mjs +35 -0
- package/lib/backlog.test.mjs +36 -0
- package/lib/channels/contract.mjs +1 -0
- package/lib/channels/contract.test.mjs +2 -1
- package/lib/channels/inbox-item.mjs +54 -0
- package/lib/comms/send-gate.mjs +56 -1
- package/lib/comms/send-gate.test.mjs +56 -0
- package/lib/execution/disposition.mjs +62 -2
- package/lib/execution/disposition.test.mjs +54 -0
- package/lib/execution/drive.mjs +1 -1
- package/lib/execution/effects.mjs +282 -24
- package/lib/execution/effects.test.mjs +112 -0
- package/lib/execution/index.mjs +1 -0
- package/lib/execution/intake.mjs +43 -9
- package/lib/execution/intake.test.mjs +46 -0
- package/lib/execution/pipeline.mjs +5 -0
- package/lib/execution/surface-policy.mjs +80 -30
- package/lib/goals/classify.mjs +49 -5
- package/lib/goals/classify.test.mjs +58 -0
- package/lib/goals/collaborate.mjs +131 -17
- package/lib/goals/collaborate.test.mjs +16 -4
- package/lib/goals/loop.mjs +160 -9
- package/lib/goals/loop.test.mjs +129 -3
- package/lib/kpi-sensors.mjs +666 -0
- package/lib/kpi-sensors.test.mjs +275 -0
- package/lib/kpi.mjs +23 -0
- package/lib/mandate/audit.mjs +3 -0
- package/lib/mandate/contract.mjs +277 -0
- package/lib/mandate/contract.test.mjs +185 -0
- package/lib/mandate/derive.mjs +49 -5
- package/lib/mandate/derive.test.mjs +7 -1
- package/lib/mandate/model.mjs +10 -1
- package/lib/mandate/model.test.mjs +22 -3
- package/lib/mandate/refresh.mjs +53 -5
- package/lib/mandate/refresh.test.mjs +83 -1
- package/lib/org/doctor.mjs +66 -0
- package/lib/org/doctor.test.mjs +73 -1
- package/lib/org/inbound/directedness.mjs +119 -1
- package/lib/org/inbound/directedness.test.mjs +67 -0
- package/lib/org/inbound/facts.mjs +132 -9
- package/lib/org/inbound/facts.test.mjs +96 -0
- package/lib/org/inbound/hydrate.mjs +40 -0
- package/lib/org/inbound/index.test.mjs +83 -0
- package/lib/org/inbound/project.mjs +8 -0
- package/lib/org/inbound/surfaces.mjs +20 -0
- package/lib/org/param-contract.mjs +16 -2
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +214 -2
- package/lib/org/protocol.test.mjs +11 -2
- package/lib/org/push.mjs +213 -49
- package/lib/org/push.test.mjs +112 -10
- package/lib/plan/compile.mjs +85 -8
- package/lib/plan/compile.test.mjs +82 -0
- package/lib/plan/emit.test.mjs +6 -1
- package/lib/setup/enroll-from-cohort.mjs +22 -2
- package/lib/setup/enroll-from-cohort.test.mjs +25 -0
- package/lib/setup/sections/mandate.mjs +43 -1
- package/lib/subagents/schema.mjs +14 -2
- package/lib/subagents/schema.test.mjs +22 -0
- package/package.json +1 -1
- package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
- package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/conformance-org-api.mjs +16 -0
- package/scripts/ci/journey-approval-escalation.mjs +341 -0
- package/scripts/daemon/agent-daemon.mjs +582 -28
- package/scripts/daemon/cadence-handlers.mjs +273 -17
- package/scripts/daemon/cadence-handlers.test.mjs +101 -0
- package/scripts/daemon/execution-ladder.test.mjs +430 -0
- package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
- package/scripts/daemon/maestro-daemon.mjs +53 -0
- package/scripts/daemon/prompt-builder.mjs +47 -0
- package/scripts/daemon/responder.mjs +70 -3
- package/scripts/poller/imap-client.mjs +20 -1
- package/scripts/poller/inbox-scan-poller.mjs +15 -0
- package/scripts/poller/utils.mjs +51 -0
- package/scripts/setup/generate-capability.mjs +120 -11
- package/scripts/setup/generate-capability.test.mjs +134 -0
- package/scripts/setup/generate-plan.mjs +6 -1
- package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
|
@@ -20,8 +20,12 @@
|
|
|
20
20
|
* the daemon's 2-minute backlog sweep already dispatches. No
|
|
21
21
|
* dispatcher edit, no new transport.
|
|
22
22
|
* delegate → `lib/org/handoff.mjs sendHandoff` (the A2A envelope path).
|
|
23
|
-
* escalate → `escalation.
|
|
24
|
-
*
|
|
23
|
+
* escalate → `escalation.ask` when the decision carries ≥2 concrete
|
|
24
|
+
* options (an OpenQuestion a human can settle in one click),
|
|
25
|
+
* else `escalation.create` against the task/channel the
|
|
26
|
+
* event names (a blockage flag). Both via `lib/org/client.mjs
|
|
27
|
+
* call`, whose `{base, token}` this module now resolves from
|
|
28
|
+
* the agent root — see `orgConn` for why it had to.
|
|
25
29
|
* requestApproval → `lib/org/approvals.mjs requestApproval` + `awaitDecision`,
|
|
26
30
|
* including the `payloadHash` single-use binding.
|
|
27
31
|
* arbitrate → `lib/org/leases.mjs preSendArbitration` (3s fail-open).
|
|
@@ -42,6 +46,68 @@ import { dirname, join } from "node:path";
|
|
|
42
46
|
import { resolveAgentRoot } from "../agent-root.mjs";
|
|
43
47
|
import { appendJsonl } from "../fs-atomic.mjs";
|
|
44
48
|
import { yamlScalar } from "../channels/inbox-item.mjs";
|
|
49
|
+
import { policyFor } from "./surface-policy.mjs";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve `{base, token, orgId}` for an agent root.
|
|
53
|
+
*
|
|
54
|
+
* ── WHY THIS EXISTS ──
|
|
55
|
+
* `lib/org/client.mjs call()` resolves NOTHING from an agent root: its first
|
|
56
|
+
* line is `const {base, token, orgId} = o; if (!base) return errFrame(...)`.
|
|
57
|
+
* Every effect below was calling it as `call(m, p, {agentRoot, client, base,
|
|
58
|
+
* token})` with `base`/`token` undefined, because `defaultEffects` is built by
|
|
59
|
+
* `scripts/daemon/agent-daemon.mjs#runExecutionLadder` as
|
|
60
|
+
* `defaultEffects({agentRoot, me, react})` — the daemon has an agent root and
|
|
61
|
+
* nothing else to give.
|
|
62
|
+
*
|
|
63
|
+
* The result, live: EVERY network effect the execution ladder owns returned
|
|
64
|
+
*
|
|
65
|
+
* {ok:false, error:{code:"BAD_REQUEST", message:"missing base"}}
|
|
66
|
+
*
|
|
67
|
+
* before a single byte left the machine. The ladder could decide to escalate and
|
|
68
|
+
* could never actually escalate. `agentRoot` was accepted, forwarded, and
|
|
69
|
+
* silently ignored one layer down.
|
|
70
|
+
*
|
|
71
|
+
* An agent root IS the org binding (`config/org.yaml` + the env token fallbacks
|
|
72
|
+
* in `client.configFromAgent`), so resolving it here is not a new mechanism —
|
|
73
|
+
* it is calling the loader every other org module already calls.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} o {agentRoot, base, token, orgId}
|
|
76
|
+
* @returns {Promise<{base:string, token:string, orgId:string}>}
|
|
77
|
+
*/
|
|
78
|
+
async function orgConn(o = {}) {
|
|
79
|
+
if (o.base) return { base: o.base, token: o.token, orgId: o.orgId };
|
|
80
|
+
const { loadOrgConfig, configFromAgent } = await import("../org/client.mjs");
|
|
81
|
+
const c = configFromAgent(loadOrgConfig(resolveAgentRoot(o.agentRoot)));
|
|
82
|
+
return {
|
|
83
|
+
base: c.base,
|
|
84
|
+
token: o.token || c.token,
|
|
85
|
+
orgId: o.orgId || c.orgId,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render an RPC error frame as a LINE, not as `[object Object]`.
|
|
91
|
+
*
|
|
92
|
+
* `String({code, message})` is `"[object Object]"`, and that is exactly what
|
|
93
|
+
* every effect below used to hand back — so the daemon's fall-through logged
|
|
94
|
+
* `escalate effect failed for … ([object Object])` and the operator learned
|
|
95
|
+
* nothing at all about why. Errors are the one thing that must never be lossy.
|
|
96
|
+
*
|
|
97
|
+
* @param {any} err
|
|
98
|
+
* @returns {string|null}
|
|
99
|
+
*/
|
|
100
|
+
export function errText(err) {
|
|
101
|
+
if (err == null) return null;
|
|
102
|
+
if (typeof err === "string") return err;
|
|
103
|
+
if (typeof err === "object") {
|
|
104
|
+
const code = err.code ? String(err.code) : "";
|
|
105
|
+
const msg = err.message ? String(err.message) : "";
|
|
106
|
+
if (code || msg) return code && msg ? `${code}: ${msg}` : code || msg;
|
|
107
|
+
try { return JSON.stringify(err); } catch { return String(err); }
|
|
108
|
+
}
|
|
109
|
+
return String(err);
|
|
110
|
+
}
|
|
45
111
|
|
|
46
112
|
/** Where scheduled work lands — a queue file the daemon's sweep already reads. */
|
|
47
113
|
export const INBOUND_QUEUE_REL = "state/queues/inbound.yaml";
|
|
@@ -67,13 +133,68 @@ function queueItemId(decision) {
|
|
|
67
133
|
return `inb-${raw.replace(/[^A-Za-z0-9._#:@-]/g, "-").slice(0, 80)}`;
|
|
68
134
|
}
|
|
69
135
|
|
|
136
|
+
/**
|
|
137
|
+
* One line saying what the queued work actually IS, for the session that will
|
|
138
|
+
* drain the row. Collapsed to a single line because `parseQueueItems` splits
|
|
139
|
+
* items on a `- ` at line start — a body line beginning with a dash would tear
|
|
140
|
+
* the item in half — and truncated because a queue row is an instruction, not
|
|
141
|
+
* an archive.
|
|
142
|
+
*/
|
|
143
|
+
function contextLine(item, candidate) {
|
|
144
|
+
const bits = [];
|
|
145
|
+
const subject = item && typeof item.subject === "string" ? item.subject.trim() : "";
|
|
146
|
+
const content = item && typeof item.content === "string" ? item.content.trim() : "";
|
|
147
|
+
// WHERE it happened. A call invite hydrates to the two words "Call invite";
|
|
148
|
+
// the room it was called in is the only thing that makes it answerable.
|
|
149
|
+
const where = item && typeof item.channel === "string" ? item.channel.trim() : "";
|
|
150
|
+
const who = item && typeof item.sender === "string" ? item.sender.trim() : "";
|
|
151
|
+
if (subject) bits.push(subject);
|
|
152
|
+
if (where) bits.push(`in ${where}`);
|
|
153
|
+
if (who) bits.push(`from ${who}`);
|
|
154
|
+
if (content && content !== subject) bits.push(content);
|
|
155
|
+
if (!bits.length && candidate && candidate.ids) {
|
|
156
|
+
// No inbox body (a raw-event caller). The candidate's own ids are still a
|
|
157
|
+
// better instruction than nothing at all.
|
|
158
|
+
for (const [k, v] of Object.entries(candidate.ids)) {
|
|
159
|
+
if (typeof v === "string" && v) bits.push(`${k}=${v}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return bits.join(" — ").replace(/\s*\n+\s*/g, " · ").slice(0, 600);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The concrete protocol method the queued work would call, in priority order:
|
|
167
|
+
* what the router already decided, then the bound obligation's `uses[]`, then
|
|
168
|
+
* the surface policy's `queueUses` fallback. Returns [] when nothing knows —
|
|
169
|
+
* and an empty list is written as an empty field rather than a guess.
|
|
170
|
+
*/
|
|
171
|
+
function usesFor(decision) {
|
|
172
|
+
const fromAction = decision && decision.action && decision.action.method;
|
|
173
|
+
if (fromAction) return [String(fromAction)];
|
|
174
|
+
const fromObligation = decision && Array.isArray(decision.uses) ? decision.uses.filter(Boolean) : [];
|
|
175
|
+
if (fromObligation.length) return fromObligation.map(String);
|
|
176
|
+
const policy = policyFor(decision && decision.policySurface ? decision.policySurface : decision && decision.surface);
|
|
177
|
+
const fallback = policy && Array.isArray(policy.queueUses) ? policy.queueUses : [];
|
|
178
|
+
return fallback.map(String);
|
|
179
|
+
}
|
|
180
|
+
|
|
70
181
|
/**
|
|
71
182
|
* Append one item to `state/queues/inbound.yaml`, creating the file with its
|
|
72
183
|
* header when absent. Idempotent on the item id: an id already present in the
|
|
73
184
|
* file is a no-op, so a retry of the same decision cannot double-queue.
|
|
74
185
|
*
|
|
186
|
+
* THE ROW MUST BE ACTIONABLE. It used to carry the decision and nothing else:
|
|
187
|
+
* `event_key`, `thread`, `rung`, `why`. A live calendar invite queued through
|
|
188
|
+
* it produced `next_action: "Handle the calendar event calendar.calendar#8261
|
|
189
|
+
* at rung 1"` — an event key built from the LEDGER SEQ, with the meeting id
|
|
190
|
+
* buried inside `thread` and the title, the time and the method absent
|
|
191
|
+
* entirely. `buildBacklogContext` then rendered that row into a prompt, and the
|
|
192
|
+
* session it dispatched had nothing to RSVP. The decision reached the queue;
|
|
193
|
+
* the WORK did not. So the subject travels with it now: `entity_id`,
|
|
194
|
+
* `source_ref`, `context`, `uses`.
|
|
195
|
+
*
|
|
75
196
|
* @param {object} decision
|
|
76
|
-
* @param {object} [o] { agentRoot, nowMs }
|
|
197
|
+
* @param {object} [o] { agentRoot, nowMs, candidate, item }
|
|
77
198
|
* @returns {{ok:boolean, ref:object|null, error:string|null}}
|
|
78
199
|
*/
|
|
79
200
|
export function scheduleToQueue(decision, o = {}) {
|
|
@@ -107,11 +228,36 @@ export function scheduleToQueue(decision, o = {}) {
|
|
|
107
228
|
}
|
|
108
229
|
}
|
|
109
230
|
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
231
|
+
const candidate = o.candidate || null;
|
|
232
|
+
const item = o.item || null;
|
|
233
|
+
const entityId =
|
|
234
|
+
(candidate && (candidate.entityId || (candidate.ids && candidate.ids.entityId))) ||
|
|
235
|
+
(item && (item.thread_id || item.id)) ||
|
|
236
|
+
"";
|
|
237
|
+
const sourceRef = (item && item.raw_ref) || (candidate && candidate.rawRef) || "";
|
|
238
|
+
const context = contextLine(item, candidate);
|
|
239
|
+
const uses = usesFor(decision);
|
|
240
|
+
|
|
241
|
+
// The title is what an operator scanning the queue reads, so it names the
|
|
242
|
+
// thing, not the verdict. "calendar: not_respondable" told nobody anything.
|
|
243
|
+
const subject = (item && typeof item.subject === "string" && item.subject.trim()) || "";
|
|
244
|
+
const title = subject
|
|
245
|
+
? `${decision.surface || "inbound"}: ${subject}`.slice(0, 160)
|
|
246
|
+
: `${decision.surface || "inbound"}: ${decision.reason || "queued"}`;
|
|
247
|
+
|
|
248
|
+
let nextAction;
|
|
249
|
+
if (decision.disposition === "schedule" && decision.surface) {
|
|
250
|
+
const target = entityId ? ` ${entityId}` : "";
|
|
251
|
+
const method = uses.length ? ` using ${uses.join(" / ")}` : "";
|
|
252
|
+
nextAction = `Handle the ${decision.surface}${target}${method} (rung ${decision.rung ?? "?"}, ${decision.reason || "queued"})`;
|
|
253
|
+
if (!entityId) {
|
|
254
|
+
// FAIL-OPEN IS FINE; SILENT IS NOT. A row with no subject is the exact
|
|
255
|
+
// dead end this field exists to close, so say so on the row itself.
|
|
256
|
+
nextAction += " — WARNING: no entity id reached the queue; the drain cannot address this";
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
nextAction = `Handle ${decision.key}`;
|
|
260
|
+
}
|
|
115
261
|
|
|
116
262
|
const lines = [
|
|
117
263
|
` - id: ${yamlScalar(id)}`,
|
|
@@ -122,9 +268,32 @@ export function scheduleToQueue(decision, o = {}) {
|
|
|
122
268
|
` created: ${yamlScalar(new Date(nowMs).toISOString())}`,
|
|
123
269
|
` surface: ${yamlScalar(decision.surface || "")}`,
|
|
124
270
|
` event_key: ${yamlScalar(decision.key || "")}`,
|
|
271
|
+
// The three fields that make the row addressable rather than merely
|
|
272
|
+
// traceable. `entity_id` is the thing to act ON, `uses` is the method to
|
|
273
|
+
// act WITH, `context` is what actually arrived.
|
|
274
|
+
` entity_id: ${yamlScalar(entityId)}`,
|
|
275
|
+
` source_ref: ${yamlScalar(sourceRef)}`,
|
|
276
|
+
` uses: ${yamlScalar(uses.join(","))}`,
|
|
277
|
+
` context: ${yamlScalar(context)}`,
|
|
125
278
|
` thread: ${yamlScalar(decision.thread || "")}`,
|
|
126
279
|
` rung: ${Number.isFinite(decision.rung) ? decision.rung : "null"}`,
|
|
127
280
|
` obligation_key: ${yamlScalar(decision.obligationKey || "")}`,
|
|
281
|
+
// THE LEARNING LINK. `lib/goals/loop.openItemsFor` selects the work that is
|
|
282
|
+
// in flight against an objective by reading `advances: [...]`, and
|
|
283
|
+
// `journalRealizedDeltas` scores each of those against the next KPI sample
|
|
284
|
+
// via `lib/kpi.recordIntervention`. Without these two lines a board item the
|
|
285
|
+
// ladder queued was structurally invisible to that loop: the row named its
|
|
286
|
+
// obligation but never the OBJECTIVE that obligation serves, so reactive
|
|
287
|
+
// work could never move a number, and no intervention was ever scored for
|
|
288
|
+
// it. Traced end-to-end on a live board assignment — the journey reached
|
|
289
|
+
// the queue and stopped dead there.
|
|
290
|
+
//
|
|
291
|
+
// `decision.objectiveId` comes from the matched obligation
|
|
292
|
+
// (lib/execution/disposition.mjs). An UNCOVERED event has none; the row then
|
|
293
|
+
// says `advances: []`, which reads correctly as "this advances nothing
|
|
294
|
+
// anyone has adopted" rather than silently omitting the field.
|
|
295
|
+
` advances: [${decision.objectiveId ? String(decision.objectiveId) : ""}]`,
|
|
296
|
+
` expected_delta: ${Number.isFinite(decision.expectedDelta) ? decision.expectedDelta : "null"}`,
|
|
128
297
|
// The reasoning travels WITH the work. A queue item whose provenance is a
|
|
129
298
|
// mystery is a queue item nobody trusts enough to action.
|
|
130
299
|
` why: ${yamlScalar((decision.why || []).join(" | "))}`,
|
|
@@ -227,7 +396,12 @@ export function defaultEffects(o = {}) {
|
|
|
227
396
|
return {
|
|
228
397
|
react: typeof o.react === "function" ? o.react : undefined,
|
|
229
398
|
|
|
230
|
-
|
|
399
|
+
// `candidate` was already on the wire from `drive` and was being dropped on
|
|
400
|
+
// the floor here — it carries the entity id the queued row needs to be
|
|
401
|
+
// addressable at all. `item` is the originating inbox item when the caller
|
|
402
|
+
// has one (the daemon's ladder passes it); absent for raw-event callers.
|
|
403
|
+
schedule: async ({ decision, candidate, item }) =>
|
|
404
|
+
scheduleToQueue(decision, { ...common, candidate, item: item || o.item || null }),
|
|
231
405
|
|
|
232
406
|
delegate: async ({ decision, candidate }) => {
|
|
233
407
|
const { sendHandoff } = await import("../org/handoff.mjs");
|
|
@@ -248,27 +422,111 @@ export function defaultEffects(o = {}) {
|
|
|
248
422
|
return { ok: r && r.ok !== false, ref: r, error: r && r.error ? String(r.error) : null };
|
|
249
423
|
},
|
|
250
424
|
|
|
251
|
-
|
|
425
|
+
/**
|
|
426
|
+
* Raise the decision up the line.
|
|
427
|
+
*
|
|
428
|
+
* ── TWO METHODS, CHOSEN BY SHAPE ──
|
|
429
|
+
* hq's `escalation` family carries two different entities and this effect
|
|
430
|
+
* has to pick the right one, because neither accepts the other's params:
|
|
431
|
+
*
|
|
432
|
+
* `escalation.ask` → an **OpenQuestion**. Requires ≥2 distinct options
|
|
433
|
+
* (hq `methods/escalation/ask.ts` 400s on fewer:
|
|
434
|
+
* "a question with fewer is a message, not a
|
|
435
|
+
* decision"). This is the shape a human can act on
|
|
436
|
+
* in one click, and it is what `disposition.mjs`
|
|
437
|
+
* rules 6/7/8 are documented to produce.
|
|
438
|
+
* `escalation.create` → an **Escalation**: a blockage flag. Requires
|
|
439
|
+
* `title` AND at least one of `taskId`/`channelId`
|
|
440
|
+
* (hq `methods/escalation/create.ts`).
|
|
441
|
+
*
|
|
442
|
+
* ── WHAT WAS BROKEN ──
|
|
443
|
+
* This effect sent `escalation.create({summary, waitingOnMemberId, options,
|
|
444
|
+
* context:{…}})`. Against the live handler that is four faults at once:
|
|
445
|
+
* `summary` is not `title` (the param contract aliases `subject→title`, not
|
|
446
|
+
* `summary`), no `taskId`/`channelId` was ever supplied so the `oneOf`
|
|
447
|
+
* refine could not pass, `waitingOnMemberId` is not a field hq reads on this
|
|
448
|
+
* method, and `context` is a STRING there, not an object. So the escalation
|
|
449
|
+
* lane 400'd on every call — and `escalation.ask`, the method the ladder's
|
|
450
|
+
* own comments name, had no caller anywhere in the repo.
|
|
451
|
+
*
|
|
452
|
+
* ── REDACTION ──
|
|
453
|
+
* Unchanged and load-bearing: ids, surfaces and machine reasons only. No
|
|
454
|
+
* message body, no room name, no address ever reaches either method. The
|
|
455
|
+
* question below is assembled from the decision, never from the event text.
|
|
456
|
+
*/
|
|
457
|
+
escalate: async ({ decision, candidate }) => {
|
|
252
458
|
const { call } = await import("../org/client.mjs");
|
|
459
|
+
const conn = await orgConn({ agentRoot, base: o.base, token: o.token, orgId: o.orgId });
|
|
460
|
+
if (!conn.base) {
|
|
461
|
+
return { ok: false, ref: null, error: "org not configured (no base) — cannot escalate" };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const surface = decision.surface || "inbound";
|
|
465
|
+
const ids = (candidate && candidate.ids) || {};
|
|
466
|
+
const options = (decision.options || [])
|
|
467
|
+
.map((l) => String(l || "").trim())
|
|
468
|
+
.filter(Boolean);
|
|
469
|
+
|
|
470
|
+
// ≥2 concrete options ⇒ a DECISION. Ask it.
|
|
471
|
+
if (options.length >= 2) {
|
|
472
|
+
const r = await call(
|
|
473
|
+
"escalation.ask",
|
|
474
|
+
{
|
|
475
|
+
question:
|
|
476
|
+
`${surface} ${decision.key || ""}: I stopped at \`${decision.reason}\` and may not ` +
|
|
477
|
+
"decide this myself. Which way should it go?",
|
|
478
|
+
options: options.map((label) => ({ label })),
|
|
479
|
+
// hq's `context` is a STRING (≤4000). Ids + reasoning only.
|
|
480
|
+
context: [
|
|
481
|
+
`event_key: ${decision.key || "?"}`,
|
|
482
|
+
`surface: ${surface}`,
|
|
483
|
+
`obligation: ${decision.obligationKey || "(uncovered)"}`,
|
|
484
|
+
`rung: ${decision.rung ?? "?"} (${decision.mechanism || "?"})`,
|
|
485
|
+
...(decision.why || []).map((w) => `why: ${w}`),
|
|
486
|
+
].join("\n").slice(0, 4000),
|
|
487
|
+
trigger: String(decision.reason || "escalate").slice(0, 100),
|
|
488
|
+
},
|
|
489
|
+
{ ...conn, agentRoot },
|
|
490
|
+
);
|
|
491
|
+
return {
|
|
492
|
+
ok: !!r && r.ok !== false,
|
|
493
|
+
ref: { method: "escalation.ask", frame: r },
|
|
494
|
+
error: r && r.error ? errText(r.error) : null,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Fewer than two options ⇒ a BLOCKAGE. Flag it — but only if there is
|
|
499
|
+
// something to flag it against, because hq requires a target and a made-up
|
|
500
|
+
// one is worse than an honest refusal.
|
|
501
|
+
const taskId = ids.taskId || ids.itemId || null;
|
|
502
|
+
const channelId = ids.channelId || null;
|
|
503
|
+
if (!taskId && !channelId) {
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
ref: null,
|
|
507
|
+
error:
|
|
508
|
+
`escalation.create needs a taskId or channelId and ${surface} event ` +
|
|
509
|
+
`${decision.key} carries neither; escalation.ask needs ≥2 options and ` +
|
|
510
|
+
`the decision offered ${options.length}. Nothing was raised.`,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
253
513
|
const r = await call(
|
|
254
514
|
"escalation.create",
|
|
255
515
|
{
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
event_key: decision.key,
|
|
263
|
-
surface: decision.surface,
|
|
264
|
-
thread: decision.thread,
|
|
265
|
-
obligation_key: decision.obligationKey || null,
|
|
266
|
-
why: decision.why,
|
|
267
|
-
},
|
|
516
|
+
title: `${surface}: ${decision.reason}`.slice(0, 500),
|
|
517
|
+
detail: (decision.why || []).join("; ").slice(0, 2000),
|
|
518
|
+
waitingOn: decision.escalateTo || o.escalationTarget || undefined,
|
|
519
|
+
resolvable: true,
|
|
520
|
+
...(taskId ? { taskId: String(taskId) } : {}),
|
|
521
|
+
...(channelId ? { channelId: String(channelId) } : {}),
|
|
268
522
|
},
|
|
269
|
-
{
|
|
523
|
+
{ ...conn, agentRoot },
|
|
270
524
|
);
|
|
271
|
-
return {
|
|
525
|
+
return {
|
|
526
|
+
ok: !!r && r.ok !== false,
|
|
527
|
+
ref: { method: "escalation.create", frame: r },
|
|
528
|
+
error: r && r.error ? errText(r.error) : null,
|
|
529
|
+
};
|
|
272
530
|
},
|
|
273
531
|
|
|
274
532
|
requestApproval: async ({ decision, classes }) => {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
writeDrift,
|
|
18
18
|
writeProposal,
|
|
19
19
|
defaultEffects,
|
|
20
|
+
errText,
|
|
20
21
|
INBOUND_QUEUE_REL,
|
|
21
22
|
DRIFT_REL,
|
|
22
23
|
PROPOSED_REL,
|
|
@@ -191,3 +192,114 @@ test("the default propose + reportDrift effects write through", async () => {
|
|
|
191
192
|
assert.ok(existsSync(join(root, DRIFT_REL)));
|
|
192
193
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
193
194
|
});
|
|
195
|
+
|
|
196
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
197
|
+
// The joint: does the escalate effect actually reach the org?
|
|
198
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
import { writeFileSync as _wf, mkdirSync as _mk } from "node:fs";
|
|
201
|
+
|
|
202
|
+
/** An agent root with a real config/org.yaml, as `maestro setup` writes it. */
|
|
203
|
+
function enrolledRoot() {
|
|
204
|
+
const root = mkdtempSync(join(tmpdir(), "effects-org-"));
|
|
205
|
+
_mk(join(root, "config"), { recursive: true });
|
|
206
|
+
_wf(
|
|
207
|
+
join(root, "config", "org.yaml"),
|
|
208
|
+
"org:\n cohort:\n enabled: true\n base: https://example.invalid\n orgId: org_t\n token: tok_t\n",
|
|
209
|
+
);
|
|
210
|
+
return root;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
test("escalate resolves the org binding from the agent root — `agentRoot` alone used to be ignored", async () => {
|
|
214
|
+
// `client.call()` reads `o.base` and nothing else; `defaultEffects` is built by
|
|
215
|
+
// the daemon as `{agentRoot, me, react}`. So every network effect returned
|
|
216
|
+
// BAD_REQUEST "missing base" before a byte left the machine: the ladder could
|
|
217
|
+
// decide to escalate and could never escalate.
|
|
218
|
+
const root = enrolledRoot();
|
|
219
|
+
const seen = [];
|
|
220
|
+
const orig = globalThis.fetch;
|
|
221
|
+
globalThis.fetch = async (url, init) => {
|
|
222
|
+
seen.push({ url: String(url), body: JSON.parse(init.body) });
|
|
223
|
+
return new Response(JSON.stringify({ ok: true, result: { id: "oq_1" } }), {
|
|
224
|
+
status: 200, headers: { "content-type": "application/json" },
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
try {
|
|
228
|
+
const eff = defaultEffects({ agentRoot: root, me: "M-1" });
|
|
229
|
+
const r = await eff
|
|
230
|
+
.escalate({
|
|
231
|
+
decision: {
|
|
232
|
+
key: "approval.requested#1", surface: "approval", reason: "cannot_self_decide",
|
|
233
|
+
rung: 1, mechanism: "claude-skill", why: ["a", "b"],
|
|
234
|
+
options: ["approve it", "reject it"],
|
|
235
|
+
},
|
|
236
|
+
candidate: { ids: { taskId: "T-1" } },
|
|
237
|
+
});
|
|
238
|
+
{
|
|
239
|
+
assert.equal(r.ok, true, r.error || "escalate failed");
|
|
240
|
+
assert.equal(seen.length, 1, "no request was made");
|
|
241
|
+
assert.match(seen[0].url, /escalation\.ask$/, "≥2 options must go to ask, not create");
|
|
242
|
+
assert.equal(seen[0].body.options.length, 2);
|
|
243
|
+
assert.equal(typeof seen[0].body.context, "string", "hq's `context` is a STRING");
|
|
244
|
+
assert.equal(seen[0].body.trigger, "cannot_self_decide");
|
|
245
|
+
assert.ok(seen[0].body.question, "hq requires a question");
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
globalThis.fetch = orig;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("fewer than two options falls back to escalation.create WITH a target", async () => {
|
|
253
|
+
const root = enrolledRoot();
|
|
254
|
+
const seen = [];
|
|
255
|
+
const orig = globalThis.fetch;
|
|
256
|
+
globalThis.fetch = async (url, init) => {
|
|
257
|
+
seen.push({ url: String(url), body: JSON.parse(init.body) });
|
|
258
|
+
return new Response(JSON.stringify({ ok: true, result: { id: "esc_1" } }), {
|
|
259
|
+
status: 200, headers: { "content-type": "application/json" },
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
try {
|
|
263
|
+
const eff = defaultEffects({ agentRoot: root, me: "M-1" });
|
|
264
|
+
const r = await eff
|
|
265
|
+
.escalate({
|
|
266
|
+
decision: { key: "k", surface: "task_comment", reason: "stuck", why: ["x"], options: [] },
|
|
267
|
+
candidate: { ids: { taskId: "T-7" } },
|
|
268
|
+
});
|
|
269
|
+
{
|
|
270
|
+
assert.equal(r.ok, true);
|
|
271
|
+
assert.match(seen[0].url, /escalation\.create$/);
|
|
272
|
+
assert.ok(seen[0].body.title, "hq requires a title, not a `summary`");
|
|
273
|
+
assert.equal(seen[0].body.taskId, "T-7", "hq requires a taskId or channelId");
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
globalThis.fetch = orig;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("no options AND no target REFUSES loudly rather than 400ing on the wire", async () => {
|
|
281
|
+
const root = enrolledRoot();
|
|
282
|
+
const orig = globalThis.fetch;
|
|
283
|
+
let called = 0;
|
|
284
|
+
globalThis.fetch = async () => { called += 1; return new Response("{}", { status: 200 }); };
|
|
285
|
+
try {
|
|
286
|
+
const eff = defaultEffects({ agentRoot: root, me: "M-1" });
|
|
287
|
+
const r = await eff
|
|
288
|
+
.escalate({ decision: { key: "k", surface: "approval", reason: "r", why: [], options: [] }, candidate: { ids: {} } });
|
|
289
|
+
{
|
|
290
|
+
assert.equal(r.ok, false);
|
|
291
|
+
assert.match(r.error, /needs a taskId or channelId/);
|
|
292
|
+
assert.match(r.error, /Nothing was raised/);
|
|
293
|
+
assert.equal(called, 0, "a call that cannot succeed must not be made");
|
|
294
|
+
}
|
|
295
|
+
} finally {
|
|
296
|
+
globalThis.fetch = orig;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("errText renders an RPC error frame as a line, never `[object Object]`", () => {
|
|
301
|
+
assert.equal(errText({ code: "BAD_REQUEST", message: "title is required" }), "BAD_REQUEST: title is required");
|
|
302
|
+
assert.equal(errText("plain"), "plain");
|
|
303
|
+
assert.equal(errText(null), null);
|
|
304
|
+
assert.notEqual(String(errText({ code: "X" })), "[object Object]");
|
|
305
|
+
});
|
package/lib/execution/index.mjs
CHANGED
package/lib/execution/intake.mjs
CHANGED
|
@@ -319,26 +319,60 @@ export function fromInboxItem(item, o = {}) {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
const mentions = !!(item.priority_signals && item.priority_signals.mentions_agent);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
322
|
+
|
|
323
|
+
// PREFER THE REAL REASON. `lib/org/inbound/directedness.mjs` already worked
|
|
324
|
+
// out WHY this event is mine and `lib/channels/inbox-item.mjs` now carries the
|
|
325
|
+
// answer across the flattening as `direct_reason`. Guessing it from a
|
|
326
|
+
// per-surface default is a last resort, not the normal path: the guess feeds
|
|
327
|
+
// `disposition.tierOf`, so being wrong about the reason can be being wrong
|
|
328
|
+
// about the tier, and therefore about the decision. Observed: an approval
|
|
329
|
+
// blocking a task the seat REVIEWS was journalled "directed: approver on
|
|
330
|
+
// approval" — a false statement about the agent's own reasoning.
|
|
331
|
+
const carried = s(item.direct_reason);
|
|
332
|
+
let reason;
|
|
333
|
+
if (carried) {
|
|
334
|
+
reason = carried;
|
|
335
|
+
why.push(`org inbox item carries its directedness reason: ${reason}`);
|
|
336
|
+
} else {
|
|
337
|
+
reason = RECONSTRUCTED_REASON[surface] || "direct";
|
|
338
|
+
if (mentions && (reason === "thread" || reason === "shared" || reason === "owner")) {
|
|
339
|
+
// The one signal the flattening preserves, and it upgrades a lane-tier
|
|
340
|
+
// reason to a structural one — being named IS the strongest reason there is.
|
|
341
|
+
reason = "named";
|
|
342
|
+
why.push("priority_signals.mentions_agent → the item names me outright (T0)");
|
|
343
|
+
}
|
|
344
|
+
degraded.push(`verdict_reconstructed:${surface}`);
|
|
345
|
+
why.push(`org inbox item: reconstructing directed:true (it was written because the join said so), reason ${reason}`);
|
|
328
346
|
}
|
|
329
|
-
degraded.push(`verdict_reconstructed:${surface}`);
|
|
330
|
-
why.push(`org inbox item: reconstructing directed:true (it was written because the join said so), reason ${reason}`);
|
|
331
347
|
|
|
332
348
|
const ids = {};
|
|
333
349
|
if (item.channel_id) ids.channelId = s(item.channel_id);
|
|
334
350
|
if (item.thread_id) ids.threadId = s(item.thread_id);
|
|
335
351
|
if (ref && ref.entityId) ids.entityId = ref.entityId;
|
|
336
352
|
if (item.id) ids.messageId = s(item.id);
|
|
353
|
+
// The surface's own subject id (the blocked task, the decision, the file).
|
|
354
|
+
// Named per family so the consumers that ask for `ids.taskId` find it where
|
|
355
|
+
// they already look.
|
|
356
|
+
if (item.scope_id) {
|
|
357
|
+
const scopeId = s(item.scope_id);
|
|
358
|
+
const family = SURFACE_FAMILY[surface];
|
|
359
|
+
if (family === "decision") ids.decisionId = scopeId;
|
|
360
|
+
else if (family === "files") ids.fileId = scopeId;
|
|
361
|
+
else ids.taskId = scopeId;
|
|
362
|
+
}
|
|
337
363
|
|
|
338
364
|
const candidate = candidateFrom({
|
|
339
365
|
seq: ref ? ref.seq : null,
|
|
340
366
|
family: SURFACE_FAMILY[surface],
|
|
341
|
-
|
|
367
|
+
// PREFER THE CHAIN KIND. `item.event_kind` is the hq event kind the wide
|
|
368
|
+
// projection carried through the flattening (`item.blocked`,
|
|
369
|
+
// `item.commented`, …). `item.kind` is only the SURFACE, and ten board kinds
|
|
370
|
+
// share the surface `task_comment` — so without this an obligation written
|
|
371
|
+
// `{topic:task, kind:blocked}` binds on the push lane (where the candidate
|
|
372
|
+
// still has the chain kind) and reads UNCOVERED on the poll lane.
|
|
373
|
+
// `kindAliases` still derives the surface tokens from the verdict, so both
|
|
374
|
+
// vocabularies stay bindable on both lanes.
|
|
375
|
+
kind: s(item.event_kind) || s(item.kind) || surface,
|
|
342
376
|
entityId: ref ? ref.entityId : s(item.id) || null,
|
|
343
377
|
actor: s(item.sender_id) || s(item.sender),
|
|
344
378
|
at: s(item.timestamp) || s(o.now),
|
|
@@ -341,3 +341,49 @@ test("KIND_SURFACE covers every event kind the channel contract ships", async ()
|
|
|
341
341
|
const missing = EVENT_KINDS.filter((k) => !["reaction", "channel_cc"].includes(k) && !KIND_SURFACE[k]);
|
|
342
342
|
assert.deepEqual(missing, [], "a shipped event kind with no surface would fall through to the default policy");
|
|
343
343
|
});
|
|
344
|
+
|
|
345
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
346
|
+
// The poll lane must agree with the push lane about WHY an event is mine.
|
|
347
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
test("a carried `direct_reason` beats the per-surface guess — the guess sets the TIER", () => {
|
|
350
|
+
// Before this, `fromInboxItem` re-derived the reason from a default table, so
|
|
351
|
+
// an approval blocking a task the seat REVIEWS was journalled as "directed:
|
|
352
|
+
// approver on approval". Since `disposition.tierOf` maps the reason onto the
|
|
353
|
+
// T0/T1 tier, guessing wrong is deciding wrong.
|
|
354
|
+
const guessed = fromInboxItem({
|
|
355
|
+
raw_ref: "cohort:approval:aprv_1:99",
|
|
356
|
+
kind: "approval",
|
|
357
|
+
id: "cohort-approval-99",
|
|
358
|
+
timestamp: NOW,
|
|
359
|
+
}, { me: ME });
|
|
360
|
+
assert.equal(guessed.verdict.reason, "approver", "the fallback guess is unchanged");
|
|
361
|
+
assert.ok(guessed.degraded.some((d) => /verdict_reconstructed/.test(d)));
|
|
362
|
+
|
|
363
|
+
const carried = fromInboxItem({
|
|
364
|
+
raw_ref: "cohort:approval:aprv_1:99",
|
|
365
|
+
kind: "approval",
|
|
366
|
+
id: "cohort-approval-99",
|
|
367
|
+
timestamp: NOW,
|
|
368
|
+
direct_reason: "reviewer",
|
|
369
|
+
}, { me: ME });
|
|
370
|
+
assert.equal(carried.verdict.reason, "reviewer");
|
|
371
|
+
assert.ok(
|
|
372
|
+
!carried.degraded.some((d) => /verdict_reconstructed/.test(d)),
|
|
373
|
+
"a carried reason is not a reconstruction and must not be reported as one",
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("`scope_id` lands on the id its family is looked up by", () => {
|
|
378
|
+
const approval = fromInboxItem(
|
|
379
|
+
{ raw_ref: "cohort:approval:aprv_1:99", kind: "approval", id: "i", timestamp: NOW, scope_id: "T-9" },
|
|
380
|
+
{ me: ME },
|
|
381
|
+
);
|
|
382
|
+
assert.equal(approval.candidate.ids.taskId, "T-9", "effects.escalate reads ids.taskId");
|
|
383
|
+
|
|
384
|
+
const decision = fromInboxItem(
|
|
385
|
+
{ raw_ref: "cohort:decision:dec_1:99", kind: "decision", id: "i", timestamp: NOW, scope_id: "D-1" },
|
|
386
|
+
{ me: ME },
|
|
387
|
+
);
|
|
388
|
+
assert.equal(decision.candidate.ids.decisionId, "D-1");
|
|
389
|
+
});
|
|
@@ -162,6 +162,11 @@ export async function processOne(o = {}) {
|
|
|
162
162
|
const res = await handleInboundEvent({
|
|
163
163
|
candidate: norm.candidate,
|
|
164
164
|
verdict: norm.verdict,
|
|
165
|
+
// The ORIGINATING item travels with the decision. `intake` distils it down
|
|
166
|
+
// to a candidate + verdict — which is right for DECIDING — but an effect
|
|
167
|
+
// that has to hand the work to something else (the schedule queue, and any
|
|
168
|
+
// later drain) needs the subject back: the title, the body, the raw_ref.
|
|
169
|
+
item: o.item,
|
|
165
170
|
me: o.me,
|
|
166
171
|
history,
|
|
167
172
|
runtime: o.runtime,
|