@cohortapp/agent-sdk 2.16.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -2
- package/docs/guides/poller-daemon-setup.md +4 -1
- package/lib/context/budget.mjs +327 -0
- package/lib/context/budget.test.mjs +252 -0
- package/lib/context/history-scope.mjs +138 -0
- package/lib/context/history-scope.test.mjs +79 -0
- package/lib/model-router/economics.mjs +9 -0
- package/lib/model-router/resolve.mjs +6 -0
- package/lib/org/inbound/facts.mjs +4 -2
- package/lib/org/inbound/hydrate.mjs +555 -51
- package/lib/org/inbound/hydrate.test.mjs +456 -1
- package/package.json +3 -1
- package/scripts/daemon/context-compiler.mjs +52 -21
- package/scripts/daemon/context-compiler.test.mjs +106 -0
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +365 -0
- package/scripts/daemon/dispatcher.mjs +210 -9
- package/scripts/daemon/lib/session-router.mjs +310 -42
- package/scripts/daemon/lib/session-router.test.mjs +260 -1
- package/scripts/daemon/prompt-builder.mjs +97 -12
- package/scripts/daemon/prompt-builder.test.mjs +219 -7
- package/scripts/daemon/responder-history.test.mjs +37 -1
- package/scripts/daemon/responder.mjs +71 -69
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/context/history-scope.mjs — which interaction logs may be read for THIS
|
|
3
|
+
* reply.
|
|
4
|
+
*
|
|
5
|
+
* THE DEFECT THIS EXISTS TO CLOSE
|
|
6
|
+
*
|
|
7
|
+
* `scripts/daemon/responder.mjs#logInteraction` files every exchange under
|
|
8
|
+
* `memory/interactions/<service>/…`, and it files each one TWICE: once under the
|
|
9
|
+
* room (`<channelId>`) and once under the person (`dm-<sender-slug>`). The two
|
|
10
|
+
* readers — `prompt-builder.mjs` and `context-compiler.mjs` — then merged every
|
|
11
|
+
* candidate directory into one timestamp-sorted block headed "Recent
|
|
12
|
+
* conversation history with this sender".
|
|
13
|
+
*
|
|
14
|
+
* While that read was pinned to `…/slack/…` the merge was inert for every other
|
|
15
|
+
* service. Reading the item's own service (design §3 R12) makes it live, and
|
|
16
|
+
* Cohort is the one service where a person's DMs and the public rooms they post
|
|
17
|
+
* in share a namespace. So: Dana DMs the agent something private, later
|
|
18
|
+
* @-mentions the agent in a public channel, and the public room's prompt
|
|
19
|
+
* carries the DM sentence verbatim. That is design §5.5's resource-scope leak —
|
|
20
|
+
* "per-person facts may inform a reply but are NEVER quoted into a shared room"
|
|
21
|
+
* — arriving through the back door of a directory list.
|
|
22
|
+
*
|
|
23
|
+
* THE RULE, stated once so both readers and the writer cannot drift:
|
|
24
|
+
*
|
|
25
|
+
* A per-person directory is readable only when the reply itself is going to
|
|
26
|
+
* that person privately. In a shared room only the room's own log is read.
|
|
27
|
+
* Unknown is not private: an item that does not positively say it is a DM is
|
|
28
|
+
* treated as a room.
|
|
29
|
+
*
|
|
30
|
+
* PURE. Everything arrives on the argument; nothing is read from disk, the
|
|
31
|
+
* clock or the environment.
|
|
32
|
+
*
|
|
33
|
+
* @module lib/context/history-scope
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
"use strict";
|
|
37
|
+
|
|
38
|
+
/** A blank-safe string. */
|
|
39
|
+
function s(v) {
|
|
40
|
+
return v == null ? "" : String(v);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Is this inbound item a private 1:1 (or small group DM) conversation?
|
|
45
|
+
*
|
|
46
|
+
* The signals, in the order they are trusted:
|
|
47
|
+
* 1. `is_dm` — set from Cohort's `channel_kind` (`lib/org/inbound/project.mjs`)
|
|
48
|
+
* and from Slack's `channel_type` (`scripts/poller/utils.mjs`). A literal
|
|
49
|
+
* `false` is a positive assertion of "room" and ends the question.
|
|
50
|
+
* 2. `channel_type` — Slack's own envelope field: `im` / `mpim`.
|
|
51
|
+
* 3. A Slack channel id beginning `D` — the convention the raw_ref parser in
|
|
52
|
+
* `prompt-builder.mjs` already relies on.
|
|
53
|
+
* 4. A `dm/<name>` channel label, which is what the Telegram and Slack
|
|
54
|
+
* adapters write for a private chat.
|
|
55
|
+
*
|
|
56
|
+
* Anything else is a room. That direction is deliberate and is the opposite of
|
|
57
|
+
* this module's neighbours: everywhere else in hydration an unknown degrades
|
|
58
|
+
* towards MORE context, because the cost is a thinner prompt. Here the cost of
|
|
59
|
+
* guessing wrong is one person's private sentence in a public channel, so
|
|
60
|
+
* unknown buys nothing.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} item inbound item (`{service, is_dm, channel_type, channel_id, channel}`)
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function isPrivateConversation(item) {
|
|
66
|
+
if (!item || typeof item !== "object") return false;
|
|
67
|
+
if (item.is_dm === true) return true;
|
|
68
|
+
if (item.is_dm === false) return false;
|
|
69
|
+
|
|
70
|
+
const type = s(item.channel_type).toLowerCase();
|
|
71
|
+
if (type === "im" || type === "mpim") return true;
|
|
72
|
+
if (type === "channel" || type === "group") return false;
|
|
73
|
+
|
|
74
|
+
// Slack's own id convention. `service` defaults to slack here for the same
|
|
75
|
+
// reason both readers write `String(item.service || "slack")` — an item that
|
|
76
|
+
// predates the per-service split carries no service field, and a `D…` id on
|
|
77
|
+
// such an item is a Slack DM.
|
|
78
|
+
const service = s(item.service) || "slack";
|
|
79
|
+
const channelId = s(item.channel_id || item.channelId);
|
|
80
|
+
if (service === "slack" && /^D[A-Z0-9]+$/.test(channelId)) return true;
|
|
81
|
+
|
|
82
|
+
if (/^dm\//i.test(s(item.channel))) return true;
|
|
83
|
+
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The interaction-log directory NAMES this reply may read, relative to
|
|
89
|
+
* `memory/interactions/<service>/`. Callers join them onto the service root.
|
|
90
|
+
*
|
|
91
|
+
* Ordered room-first so a merged read still prefers the room's own record.
|
|
92
|
+
* Slack keeps its legacy bare `<sender-slug>` directory alongside the `dm-`
|
|
93
|
+
* one — historical logs live there and must not go dark — and it is subject to
|
|
94
|
+
* exactly the same gate.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} o
|
|
97
|
+
* @param {string|null} [o.channelId] the room/DM channel id, when known
|
|
98
|
+
* @param {string|null} [o.senderSlug] the sender, slugified
|
|
99
|
+
* @param {boolean} [o.isPrivate] is the reply going to that person privately
|
|
100
|
+
* @param {string} [o.service]
|
|
101
|
+
* @returns {string[]} directory names, possibly empty
|
|
102
|
+
*/
|
|
103
|
+
export function historyDirNames({ channelId, senderSlug, isPrivate, service } = {}) {
|
|
104
|
+
const dirs = [];
|
|
105
|
+
const cid = s(channelId);
|
|
106
|
+
const slug = s(senderSlug);
|
|
107
|
+
if (cid) dirs.push(cid);
|
|
108
|
+
if (isPrivate === true && slug) {
|
|
109
|
+
dirs.push(`dm-${slug}`);
|
|
110
|
+
if ((s(service) || "slack") === "slack") dirs.push(slug);
|
|
111
|
+
}
|
|
112
|
+
return dirs;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The directories `logInteraction` may WRITE this exchange to.
|
|
117
|
+
*
|
|
118
|
+
* The read gate above is the one that stops the leak, but leaving the writer
|
|
119
|
+
* unconditional keeps every per-person directory permanently contaminated with
|
|
120
|
+
* public-room content — so a future reader, or an operator grepping the tree,
|
|
121
|
+
* meets the same defect again. One rule, both ends.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} o
|
|
124
|
+
* @param {string|null} [o.channelId]
|
|
125
|
+
* @param {string|null} [o.senderSlug]
|
|
126
|
+
* @param {boolean} [o.isPrivate]
|
|
127
|
+
* @returns {string[]}
|
|
128
|
+
*/
|
|
129
|
+
export function interactionWriteDirNames({ channelId, senderSlug, isPrivate } = {}) {
|
|
130
|
+
const dirs = [];
|
|
131
|
+
const cid = s(channelId);
|
|
132
|
+
const slug = s(senderSlug);
|
|
133
|
+
if (cid) dirs.push(cid);
|
|
134
|
+
if (isPrivate === true && slug) dirs.push(`dm-${slug}`);
|
|
135
|
+
return dirs;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default { isPrivateConversation, historyDirNames, interactionWriteDirNames };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
isPrivateConversation,
|
|
6
|
+
historyDirNames,
|
|
7
|
+
interactionWriteDirNames,
|
|
8
|
+
} from "./history-scope.mjs";
|
|
9
|
+
|
|
10
|
+
test("is_dm is the authoritative signal, in both directions", () => {
|
|
11
|
+
assert.equal(isPrivateConversation({ is_dm: true }), true);
|
|
12
|
+
assert.equal(isPrivateConversation({ is_dm: false, channel_type: "im" }), false,
|
|
13
|
+
"an explicit is_dm:false is an assertion of 'room' and outranks a stale channel_type");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("Slack's channel_type and the D-prefixed channel id both read as private", () => {
|
|
17
|
+
assert.equal(isPrivateConversation({ service: "slack", channel_type: "im" }), true);
|
|
18
|
+
assert.equal(isPrivateConversation({ service: "slack", channel_type: "mpim" }), true);
|
|
19
|
+
assert.equal(isPrivateConversation({ service: "slack", channel_id: "D099N1JGKRQ" }), true);
|
|
20
|
+
assert.equal(isPrivateConversation({ service: "slack", channel_id: "C099N1JGKRQ" }), false);
|
|
21
|
+
assert.equal(isPrivateConversation({ service: "slack", channel_type: "channel", channel_id: "D1" }), false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a missing service reads as Slack — the historical default both readers use", () => {
|
|
25
|
+
assert.equal(isPrivateConversation({ channel_id: "D001TESTCH" }), true);
|
|
26
|
+
assert.equal(isPrivateConversation({ channel_id: "C001TESTCH" }), false);
|
|
27
|
+
assert.deepEqual(
|
|
28
|
+
historyDirNames({ channelId: "D001TESTCH", senderSlug: "alice-smith", isPrivate: true }),
|
|
29
|
+
["D001TESTCH", "dm-alice-smith", "alice-smith"],
|
|
30
|
+
"…including its legacy bare-slug directory",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a dm/<name> label counts, and everything unknown is a ROOM", () => {
|
|
35
|
+
assert.equal(isPrivateConversation({ service: "telegram", channel: "dm/dana" }), true);
|
|
36
|
+
assert.equal(isPrivateConversation({ service: "cohort", channel_id: "c_capital_formation" }), false,
|
|
37
|
+
"unknown is not private — guessing wrong here puts a private sentence in a public room");
|
|
38
|
+
assert.equal(isPrivateConversation(null), false);
|
|
39
|
+
assert.equal(isPrivateConversation("nope"), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a shared room reads ONLY the room's own log — never a per-person directory", () => {
|
|
43
|
+
const dirs = historyDirNames({
|
|
44
|
+
channelId: "c_capital_formation", senderSlug: "dana", isPrivate: false, service: "cohort",
|
|
45
|
+
});
|
|
46
|
+
assert.deepEqual(dirs, ["c_capital_formation"]);
|
|
47
|
+
assert.equal(dirs.some((d) => d.includes("dana")), false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("a DM reads the room and the person; Slack also keeps its legacy bare slug", () => {
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
historyDirNames({ channelId: "c_dm", senderSlug: "dana", isPrivate: true, service: "cohort" }),
|
|
53
|
+
["c_dm", "dm-dana"],
|
|
54
|
+
);
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
historyDirNames({ channelId: "D1", senderSlug: "dana", isPrivate: true, service: "slack" }),
|
|
57
|
+
["D1", "dm-dana", "dana"],
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the WRITER is gated by the same rule, so per-person logs stay per-person", () => {
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
interactionWriteDirNames({ channelId: "c_public", senderSlug: "dana", isPrivate: false }),
|
|
64
|
+
["c_public"],
|
|
65
|
+
);
|
|
66
|
+
assert.deepEqual(
|
|
67
|
+
interactionWriteDirNames({ channelId: "c_dm", senderSlug: "dana", isPrivate: true }),
|
|
68
|
+
["c_dm", "dm-dana"],
|
|
69
|
+
);
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
interactionWriteDirNames({ channelId: null, senderSlug: "dana", isPrivate: true }),
|
|
72
|
+
["dm-dana"],
|
|
73
|
+
);
|
|
74
|
+
assert.deepEqual(
|
|
75
|
+
interactionWriteDirNames({ channelId: null, senderSlug: "dana", isPrivate: false }),
|
|
76
|
+
[],
|
|
77
|
+
"a room exchange with no room id is not filed under the person as a consolation prize",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
@@ -310,6 +310,8 @@ const DEFERRABLE_PREFIXES = Object.freeze([
|
|
|
310
310
|
// the live conversational / inbox path.
|
|
311
311
|
const REALTIME_TASK_CLASSES = Object.freeze(new Set([
|
|
312
312
|
"session.responder",
|
|
313
|
+
"session.answer",
|
|
314
|
+
"session.plan",
|
|
313
315
|
"classify.inbox",
|
|
314
316
|
"huddle",
|
|
315
317
|
"voice.brief",
|
|
@@ -556,6 +558,13 @@ const HAIKU_EXPLORE = Object.freeze({
|
|
|
556
558
|
// cheap classes where a subagent would only add overhead.
|
|
557
559
|
const SPAWN_KNOBS = Object.freeze({
|
|
558
560
|
"session.responder": { maxTurns: 40, effort: null, agentsJson: HAIKU_EXPLORE },
|
|
561
|
+
// The reply tiers (design §5.6 / §5.1). Until now `dispatcher.mjs` hardcoded
|
|
562
|
+
// `session.responder` for every spawn, so `effort` was ALWAYS null and
|
|
563
|
+
// `maxTurns` always 40 — complexity moved the model and the timeout and
|
|
564
|
+
// nothing else. An answer gets low effort and a short leash; a plan gets high
|
|
565
|
+
// effort, a longer one, and the cheap-subagent map it will actually use.
|
|
566
|
+
"session.answer": { maxTurns: 6, effort: "low", agentsJson: null },
|
|
567
|
+
"session.plan": { maxTurns: 60, effort: "high", agentsJson: HAIKU_EXPLORE },
|
|
559
568
|
"classify.inbox": { maxTurns: 1, effort: null, agentsJson: null },
|
|
560
569
|
lookup: { maxTurns: 2, effort: null, agentsJson: null },
|
|
561
570
|
enrich: { maxTurns: 3, effort: null, agentsJson: null },
|
|
@@ -93,6 +93,12 @@ const TASK_CLASS_MAX_TURNS = Object.freeze({
|
|
|
93
93
|
"voice.brief": 4,
|
|
94
94
|
"learning.reflect": 6,
|
|
95
95
|
"learning.curate": 6,
|
|
96
|
+
// The reply tiers (design §5.6). `session.responder` keeps the historical
|
|
97
|
+
// default; an ANSWER is a question the classifier already judged answerable,
|
|
98
|
+
// so a 40-turn ceiling is 39 turns of rope it does not need, and a PLAN is
|
|
99
|
+
// the rung that genuinely has multi-step work to do.
|
|
100
|
+
"session.answer": 6,
|
|
101
|
+
"session.plan": 60,
|
|
96
102
|
});
|
|
97
103
|
|
|
98
104
|
// ---------------------------------------------------------------------------
|
|
@@ -76,7 +76,7 @@ export const DEFAULT_LIMITS = Object.freeze({
|
|
|
76
76
|
* @property {Map<string,Set<string>>} decisionVoices
|
|
77
77
|
* @property {Map<string,object>|null} escalations
|
|
78
78
|
* @property {Map<string,object>|null} approvals
|
|
79
|
-
* @property {Map<string,object>|null} fileAcl fileId → {ownerId, shared, mentionsMe}
|
|
79
|
+
* @property {Map<string,object>|null} fileAcl fileId → {ownerId, shared, mentionsMe, kind}
|
|
80
80
|
* @property {Set<string>|null} myEventIds calendar events I own or attend (null == unreadable)
|
|
81
81
|
* @property {Map<string,object>|null} myEvents eventId → the calendar event DTO
|
|
82
82
|
* @property {string[]} degraded names of facts that could not be read
|
|
@@ -602,7 +602,9 @@ async function resolveFileFacts({ candidates, facts, io, limits, log, degraded }
|
|
|
602
602
|
const permission = String(res.permission || "").toLowerCase();
|
|
603
603
|
const ownerId = String(file.ownerId || file.owner || "") || null;
|
|
604
604
|
const shared = Boolean(permission && permission !== "none");
|
|
605
|
-
|
|
605
|
+
// `kind` (DOC | SHEET | DECK | …) rides along so the doc hydrator can skip a
|
|
606
|
+
// `files.docRead` that hq would answer BAD_REQUEST for. One field, no read.
|
|
607
|
+
const entry = { ownerId, shared, permission, mentionsMe: false, name: file.name || "", kind: String(file.kind || "") };
|
|
606
608
|
|
|
607
609
|
if (!shared && !(ownerId && ownerId === facts.me) && facts.myNames.length > 0) {
|
|
608
610
|
const cf = await io.call("files.comments", { fileId, limit: 20 });
|