@cohortapp/agent-sdk 2.18.12 → 2.18.14
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 +38 -1
- package/docs/runbooks/fleet-rollout.md +14 -7
- package/docs/runbooks/recovery-and-failover.md +18 -0
- package/lib/cadence-failure-class.mjs +245 -0
- package/lib/claude-bin.mjs +26 -7
- package/lib/cli/doctor-checks.mjs +149 -1
- package/lib/comms/send-gate.mjs +6 -4
- package/lib/diagnostics/alerts.mjs +33 -0
- package/lib/engine/agents/usage.mjs +45 -0
- package/lib/engine/budget.mjs +293 -29
- package/lib/engine/cli.mjs +54 -5
- package/lib/engine/loop.mjs +30 -0
- package/lib/engine/output/json.mjs +26 -0
- package/lib/engine/wire/errors.mjs +179 -0
- package/lib/engine/wire/search.mjs +44 -8
- package/lib/identity/claude-md.mjs +107 -0
- package/lib/identity/disclosure-instructions.mjs +148 -0
- package/lib/identity/disclosure-scrub.mjs +207 -0
- package/lib/identity/persona.mjs +141 -6
- package/lib/org/inbound/conversation-frame.mjs +289 -0
- package/lib/org/inbound/directedness.mjs +27 -7
- package/lib/org/quota.mjs +27 -0
- package/lib/session/config.mjs +4 -0
- package/lib/session/identity.mjs +71 -7
- package/lib/session/launch-failure.mjs +251 -0
- package/lib/session/resume-target.mjs +86 -0
- package/lib/telemetry/collect.mjs +129 -0
- package/lib/upgrade/pinned-drift.mjs +467 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/skills/persona-discipline.md +24 -2
- package/scaffold/config/alerts.yaml +7 -0
- package/scripts/ci/check-cadence-prompts-exist.mjs +96 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/run-tests.mjs +16 -2
- package/scripts/daemon/cadence-consumer.mjs +281 -34
- package/scripts/daemon/context-compiler.mjs +9 -1
- package/scripts/daemon/prompt-builder.mjs +219 -137
- package/scripts/daemon/responder.mjs +226 -26
- package/scripts/emergency-stop.sh +114 -13
- package/scripts/fleet/rollout.mjs +10 -3
- package/scripts/healthcheck.sh +131 -33
- package/scripts/resume-operations.sh +101 -6
- package/scripts/session/supervisor.mjs +198 -5
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* disclosure-scrub.mjs — remove a standing-self-identification INSTRUCTION from
|
|
3
|
+
* seat-local prose before it reaches the model.
|
|
4
|
+
*
|
|
5
|
+
* WHY A COUNTER-INSTRUCTION IS NOT ENOUGH
|
|
6
|
+
* Two of the three prompt planes build their identity by scraping prose out of
|
|
7
|
+
* the SEAT's own files — `## Identity` / `## Company Context` /
|
|
8
|
+
* `## Communication Rules` from the seat's CLAUDE.md
|
|
9
|
+
* (scripts/daemon/responder.mjs#loadPreamble,
|
|
10
|
+
* scripts/daemon/prompt-builder.mjs#extractPreamble) and the per-sender YAML
|
|
11
|
+
* under memory/profiles/users/. `maestro upgrade` deliberately never touches
|
|
12
|
+
* either, so whatever a seat was scaffolded or hand-edited with in 2026-08
|
|
13
|
+
* is still there and still first in the prompt.
|
|
14
|
+
*
|
|
15
|
+
* Some seats were hand-edited, in good faith, to carry the disclosure opener
|
|
16
|
+
* as a STANDING RULE — because for a while the send gate demanded it and the
|
|
17
|
+
* only way past a gate that demands disclosure is to disclose. Shipping
|
|
18
|
+
* `SELF_PRESENTATION` (lib/identity/persona.mjs) puts the correct rule in the
|
|
19
|
+
* prompt, but it does not remove the seat-local one: the model is then handed
|
|
20
|
+
* two contradictory instructions, the seat's one arrives ~700 characters
|
|
21
|
+
* earlier, and on the full-session plane it lands immediately after the
|
|
22
|
+
* sentence that declares the identity section authoritative. Adding text
|
|
23
|
+
* cannot win that; removing the contradiction can.
|
|
24
|
+
*
|
|
25
|
+
* WHAT IS REMOVED, AND WHAT IS DELIBERATELY NOT
|
|
26
|
+
* Removed: a LINE that reproduces one of the proactive-disclosure templates in
|
|
27
|
+
* policies/ai-disclosure.yaml, or that instructs self-announcement in the
|
|
28
|
+
* abstract ("open every message by introducing yourself as an AI assistant").
|
|
29
|
+
*
|
|
30
|
+
* NOT removed: anything about answering honestly WHEN ASKED. That is the
|
|
31
|
+
* non-overridable invariant in policies/ai-disclosure.yaml and it must survive
|
|
32
|
+
* this scrub intact — {@link ASKED_SHAPE} exempts it explicitly. Scrubbing it
|
|
33
|
+
* would turn a fix for an unprompted disclaimer into a licence to conceal,
|
|
34
|
+
* which is the one thing nothing in this tree is allowed to do.
|
|
35
|
+
*
|
|
36
|
+
* Also not removed: the external-first-contact duty as POLICY. This module
|
|
37
|
+
* edits prompt text only. `lib/comms/send-gate` remains the thing that decides
|
|
38
|
+
* when the line is actually owed, and it still demands it for an external
|
|
39
|
+
* first contact.
|
|
40
|
+
*
|
|
41
|
+
* THE BANNED TEXT IS QUOTED NOWHERE HERE. The fragments are derived from
|
|
42
|
+
* policies/ai-disclosure.yaml at run time. A guard that reproduces the string it
|
|
43
|
+
* guards against is how the string gets reseeded — the next author reads it in a
|
|
44
|
+
* comment and writes it back in, in good faith.
|
|
45
|
+
*
|
|
46
|
+
* Pure core (`deriveDisclosureFragments`, `scrubDisclosureInstructions`) so the
|
|
47
|
+
* behaviour is testable without a filesystem; the I/O wrapper never throws and
|
|
48
|
+
* fails OPEN (shape rules only), because no scrub is worth failing a send.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { readFileSync } from "fs";
|
|
52
|
+
import { fileURLToPath } from "url";
|
|
53
|
+
import { join, dirname } from "path";
|
|
54
|
+
|
|
55
|
+
/** The framework's own copy of the policy — NOT the seat's. */
|
|
56
|
+
const PACKAGED_POLICY = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "policies", "ai-disclosure.yaml");
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A line that is about answering a QUESTION is never scrubbed, whatever else it
|
|
60
|
+
* says. The truthfulness invariant reads exactly like a self-identification
|
|
61
|
+
* instruction to a dumb matcher, and it is the one line that must survive.
|
|
62
|
+
*/
|
|
63
|
+
const ASKED_SHAPE = /\b(?:if|when|whenever|should)\b[^.\n]{0,60}\b(?:ask|asks|asked|asking|question|questions|enquir|inquir)/i;
|
|
64
|
+
|
|
65
|
+
/** The self-nature noun phrase an announcement instruction has to name. */
|
|
66
|
+
const SELF_NATURE = /\b(?:an?\s+(?:ai|a\.i\.)\b|ai\s+(?:assistant|agent|bot|helper|colleague|model|system)|\bbot\b|\bchatbot\b|language model)/i;
|
|
67
|
+
|
|
68
|
+
/** The act of putting it at the top of a message. */
|
|
69
|
+
const ANNOUNCE_VERB = /\b(?:open(?:s|ing)?|begin(?:s|ning)?|start(?:s|ing)?|lead(?:s|ing)?\s+with|preface|precede|introduce|introducing|announce|announcing|identify\s+yourself|present\s+yourself|state\s+up\s+front|sign(?:s|ing)?\s+off|disclaimer|disclose)\b/i;
|
|
70
|
+
|
|
71
|
+
/** Second-person/first-person orientation — an instruction ABOUT THE AGENT. */
|
|
72
|
+
const SELF_REF = /\b(?:you|your|yourself|i'?m|i\s+am|my|me|myself|the\s+agent)\b/i;
|
|
73
|
+
|
|
74
|
+
/** Minimum length of a derived policy fragment worth matching a line against. */
|
|
75
|
+
const MIN_FRAGMENT = 14;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every proactive-disclosure template in the AI-disclosure policy, reduced to the
|
|
79
|
+
* literal prose an agent would have to write.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately a dumb line scan rather than a YAML parse: the policy file is the
|
|
82
|
+
* INPUT to this derivation, so the derivation must not depend on the file being
|
|
83
|
+
* well-formed enough to parse. A malformed policy must degrade to "fewer
|
|
84
|
+
* fragments", never to a throw on the send path.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} policyText raw contents of policies/ai-disclosure.yaml
|
|
87
|
+
* @returns {string[]} distinctive lower-cased fragments
|
|
88
|
+
*/
|
|
89
|
+
export function deriveDisclosureFragments(policyText) {
|
|
90
|
+
const out = new Set();
|
|
91
|
+
const lines = String(policyText || "").split("\n");
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
// The proactive templates only. `truthful_answer_template` is the INVARIANT's
|
|
94
|
+
// text — the honest answer to a direct question — and must never be scrubbed
|
|
95
|
+
// out of anything.
|
|
96
|
+
if (!/^\s*(identity_line|footer):\s*>/.test(lines[i])) continue;
|
|
97
|
+
const body = [];
|
|
98
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
99
|
+
const l = lines[j];
|
|
100
|
+
if (l.trim() === "") break;
|
|
101
|
+
if (l.trim().startsWith("#")) break; // a comment ends the block scalar
|
|
102
|
+
if (!/^\s{6,}\S/.test(l)) break;
|
|
103
|
+
body.push(l.trim());
|
|
104
|
+
}
|
|
105
|
+
for (const piece of body.join(" ").split(/\{[a-z_]+\}/i)) {
|
|
106
|
+
const p = piece.replace(/\s+/g, " ").replace(/^[\s—–,.:;'"-]+|[\s—–,.:;'"-]+$/g, "").toLowerCase();
|
|
107
|
+
if (p.length >= MIN_FRAGMENT) out.add(p);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return [...out];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Does this ONE line instruct (or render) a standing self-identification opener?
|
|
115
|
+
*
|
|
116
|
+
* @param {string} line
|
|
117
|
+
* @param {string[]} fragments from {@link deriveDisclosureFragments}
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
export function isDisclosureInstruction(line, fragments = []) {
|
|
121
|
+
const raw = String(line || "");
|
|
122
|
+
if (!raw.trim()) return false;
|
|
123
|
+
// The invariant is exempt, unconditionally and first.
|
|
124
|
+
if (ASKED_SHAPE.test(raw)) return false;
|
|
125
|
+
|
|
126
|
+
const lc = raw.toLowerCase();
|
|
127
|
+
for (const f of fragments) {
|
|
128
|
+
if (f && f.length >= MIN_FRAGMENT && lc.includes(f)) return true;
|
|
129
|
+
}
|
|
130
|
+
// Shape rule, for a hand-written variant the policy never contained. All three
|
|
131
|
+
// must hold on the SAME line: the act of announcing, the nature being
|
|
132
|
+
// announced, and the agent as the subject. Two of the three is ordinary prose
|
|
133
|
+
// ("the directory badges every AI member"), and scrubbing that would be
|
|
134
|
+
// vandalism of a perfectly good seat file.
|
|
135
|
+
return ANNOUNCE_VERB.test(raw) && SELF_NATURE.test(raw) && SELF_REF.test(raw);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The note left in place of whatever was removed. It names the SHAPE of the
|
|
140
|
+
* removed instruction and never reproduces it — see the module header.
|
|
141
|
+
*/
|
|
142
|
+
export const SCRUB_NOTE =
|
|
143
|
+
"[One or more lines of seat-local text were removed here: they instructed a standing self-identification opener. " +
|
|
144
|
+
"That instruction is stale — it dates from a period when the send gate demanded the line from internal recipients too " +
|
|
145
|
+
"(policies/ai-disclosure.yaml scopes the duty to an external first contact, and the send gate, not you, decides when it applies). " +
|
|
146
|
+
"The self-presentation rules elsewhere in this prompt are the current instruction and outrank any seat-local text. " +
|
|
147
|
+
"Answering honestly when someone sincerely asks whether you are an AI is unaffected and still required.]";
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Remove self-identification instructions from a block of seat-local prose.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} text
|
|
153
|
+
* @param {string[]} fragments
|
|
154
|
+
* @returns {{ text: string, removed: string[] }} scrubbed text and the lines dropped
|
|
155
|
+
*/
|
|
156
|
+
export function scrubDisclosureInstructions(text, fragments = []) {
|
|
157
|
+
const src = String(text || "");
|
|
158
|
+
if (!src) return { text: src, removed: [] };
|
|
159
|
+
const kept = [];
|
|
160
|
+
const removed = [];
|
|
161
|
+
for (const line of src.split("\n")) {
|
|
162
|
+
if (isDisclosureInstruction(line, fragments)) { removed.push(line); continue; }
|
|
163
|
+
kept.push(line);
|
|
164
|
+
}
|
|
165
|
+
if (removed.length === 0) return { text: src, removed };
|
|
166
|
+
// Collapse the hole the removal left, then say that something was removed.
|
|
167
|
+
// Silence would be worse: a model that reads a truncated rule list has no way
|
|
168
|
+
// to tell an edit from an omission.
|
|
169
|
+
const body = kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
170
|
+
return { text: `${body}\n\n${SCRUB_NOTE}`, removed };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let _fragments = null;
|
|
174
|
+
/**
|
|
175
|
+
* The fragments from the PACKAGED policy, cached per process.
|
|
176
|
+
* Never throws: an unreadable policy yields `[]`, which leaves the shape rule in
|
|
177
|
+
* {@link isDisclosureInstruction} doing the work on its own.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} [policyPath]
|
|
180
|
+
* @returns {string[]}
|
|
181
|
+
*/
|
|
182
|
+
export function loadDisclosureFragments(policyPath = PACKAGED_POLICY) {
|
|
183
|
+
if (_fragments && policyPath === PACKAGED_POLICY) return _fragments;
|
|
184
|
+
let frags = [];
|
|
185
|
+
try {
|
|
186
|
+
frags = deriveDisclosureFragments(readFileSync(policyPath, "utf-8"));
|
|
187
|
+
} catch {
|
|
188
|
+
frags = [];
|
|
189
|
+
}
|
|
190
|
+
if (policyPath === PACKAGED_POLICY) _fragments = frags;
|
|
191
|
+
return frags;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The convenience wrapper the two prompt planes call: scrub seat-local prose
|
|
196
|
+
* against the packaged policy. Never throws.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} text
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
export function scrubSeatText(text) {
|
|
202
|
+
try {
|
|
203
|
+
return scrubDisclosureInstructions(text, loadDisclosureFragments()).text;
|
|
204
|
+
} catch {
|
|
205
|
+
return String(text || "");
|
|
206
|
+
}
|
|
207
|
+
}
|
package/lib/identity/persona.mjs
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* 1. A freshly-scaffolded repo has "*Configured by `maestro setup`*" sitting
|
|
11
11
|
* in those sections. The model was handed a near-empty identity and fell
|
|
12
12
|
* back to the generic-assistant register it ships with — introducing and
|
|
13
|
-
* signing itself
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* signing itself with a rendering of the `identity_line` template in
|
|
14
|
+
* policies/ai-disclosure.yaml (not reproduced here; see
|
|
15
|
+
* SELF_PRESENTATION_RULES below for why), deferring on matters inside its
|
|
16
|
+
* own mandate, and writing to colleagues as a support function rather
|
|
17
|
+
* than as the person holding the role.
|
|
16
18
|
*
|
|
17
19
|
* 2. Everything the org actually knows about the member — title, seniority
|
|
18
20
|
* band, background, tone of voice, responsibilities, operating principles,
|
|
@@ -97,6 +99,62 @@ function reportingLine(a) {
|
|
|
97
99
|
return title ? `You report to ${name}, ${title}.` : `You report to ${name}.`;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
/**
|
|
103
|
+
* SELF-PRESENTATION — the rules about how an agent refers to its own
|
|
104
|
+
* nature. Kept as a standalone frozen list, and exported as a ready-made block,
|
|
105
|
+
* for one reason: they must reach the model on EVERY prompt plane, including the
|
|
106
|
+
* ones that render no persona block at all.
|
|
107
|
+
*
|
|
108
|
+
* WHY THAT MATTERS, concretely. `renderPersona` returns "" when config/agent.json
|
|
109
|
+
* carries neither a name nor a title, and the quick-reply plane
|
|
110
|
+
* (scripts/daemon/responder.mjs) rendered no persona block by design. Both cases
|
|
111
|
+
* handed the model an identity vacuum, and a model in an identity vacuum falls
|
|
112
|
+
* back to the generic-assistant register it ships with — which is how a rendering
|
|
113
|
+
* of the `identity_line` template in policies/ai-disclosure.yaml came to open a
|
|
114
|
+
* mid-thread correction to the agent's own figures, in the organisation's own
|
|
115
|
+
* channel, to colleagues who provisioned it. The template is NOT quoted here on
|
|
116
|
+
* purpose: a banned string reproduced in a comment is how the next author reads
|
|
117
|
+
* it in good faith and writes it back in, and
|
|
118
|
+
* scripts/daemon/no-unprompted-disclosure.test.mjs fails on it either way.
|
|
119
|
+
*
|
|
120
|
+
* The first rule is phrased against the OPENER specifically. "Do not describe
|
|
121
|
+
* yourself as an assistant" was already here and was obeyed literally and
|
|
122
|
+
* narrowly: the model did not *describe* itself in the body, it *announced*
|
|
123
|
+
* itself in the first line and considered the duty discharged.
|
|
124
|
+
*
|
|
125
|
+
* The third rule is the truthfulness invariant and is never traded for the
|
|
126
|
+
* other two. Removing an unprompted disclaimer is not the same as denying what
|
|
127
|
+
* you are, and `lib/comms/send-gate.screenDisclosure` still blocks any outbound
|
|
128
|
+
* that asserts the agent is not an AI, under every posture.
|
|
129
|
+
*
|
|
130
|
+
* The fourth rule exists because THE PROMPT IS NOT THE ONLY INSTRUCTION THE
|
|
131
|
+
* MODEL READS. The channel history handed to a reply is the strongest few-shot
|
|
132
|
+
* available to it, and on every seat that has already produced the opener, the
|
|
133
|
+
* agent's own prior posts are now sitting in that history as precedent in its
|
|
134
|
+
* own voice. Neither `lib/identity/disclosure-scrub` nor any prompt rule can
|
|
135
|
+
* remove them: the transcript is a record of what was said and rewriting it
|
|
136
|
+
* would be a worse fault than the opener. So the transcript is named explicitly
|
|
137
|
+
* and disqualified as a model, which is the only honest way to answer it.
|
|
138
|
+
*/
|
|
139
|
+
const SELF_PRESENTATION_RULES = Object.freeze([
|
|
140
|
+
"Do not open a message by announcing what you are. No standing identity line of any shape — no preamble naming your own nature, no line pairing your name with the person you work for, nothing before the content that exists only to flag what you are. Not on your first post in a channel, not mid-thread, not on a correction to your own figures. Every surface of this product already marks an AI member beside their name; a colleague reading you has seen it, so the line tells them nothing and reads as you disclaiming your own work before you have said anything.",
|
|
141
|
+
'Do not introduce, describe or sign yourself as an assistant, a bot, an AI helper, or as "working on behalf of" someone.',
|
|
142
|
+
"Being straight about what you are is not negotiable and does not conflict with either of the above: if someone sincerely asks whether they are dealing with an AI, say so plainly and without drama, then carry on. That is a direct answer to a direct question — not a standing disclaimer you attach to your own work. Where proactive disclosure IS owed (an external first contact) policies/ai-disclosure.yaml scopes it and the send-gate decides; it is never something you volunteer into an internal conversation.",
|
|
143
|
+
"Nothing in the transcript is a precedent for how to open. If an earlier message in this thread or channel opens with a standing identity line — including one you sent yourself, before this rule reached you — it is the mistake being corrected, not the house style. Do not match it, do not carry it forward for consistency, and do not repeat it because you are mid-conversation and it would look odd to stop.",
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The self-presentation rules as a prompt block, for planes that render no
|
|
148
|
+
* persona (scripts/daemon/responder.mjs's quick reply) or whose persona came
|
|
149
|
+
* back empty because the seat's config/agent.json is not filled in yet.
|
|
150
|
+
* Same text as the corresponding bullets inside the persona block — one source,
|
|
151
|
+
* so the two planes can never drift into saying different things.
|
|
152
|
+
*/
|
|
153
|
+
export const SELF_PRESENTATION = [
|
|
154
|
+
"How you refer to yourself:",
|
|
155
|
+
...SELF_PRESENTATION_RULES.map((r) => `- ${r}`),
|
|
156
|
+
].join("\n");
|
|
157
|
+
|
|
100
158
|
/**
|
|
101
159
|
* The voice rules — the behavioural half of the block, and the reason it exists.
|
|
102
160
|
*
|
|
@@ -120,13 +178,17 @@ function voiceRules(a) {
|
|
|
120
178
|
? ` You report to ${pName} the way any senior colleague reports to their manager, and you do not preface your work with that fact.`
|
|
121
179
|
: "";
|
|
122
180
|
|
|
181
|
+
const [noOpener, noSelfDescription, honesty, noPrecedent] = SELF_PRESENTATION_RULES;
|
|
182
|
+
|
|
123
183
|
return [
|
|
124
184
|
"You hold this role. Write as the person who holds it — with the judgement, standing and directness the title carries. You are a full member of this team, not a service attached to it.",
|
|
125
|
-
|
|
185
|
+
noOpener,
|
|
186
|
+
`${noSelfDescription}${reportsClause}`,
|
|
126
187
|
"Do not position yourself as junior to whoever you are writing to, including your principal or the CEO. State what you did, what you found, and what you recommend. Ask for a decision only when the decision is genuinely theirs to make.",
|
|
127
188
|
"Do not thank people for their patience, apologise for taking up their time, or hedge a finding you are confident in. Colleagues at your level do not do this, and it reads as a tell.",
|
|
128
189
|
"Disagree when you disagree, and say so first rather than burying it under agreement. A concern you soften into politeness is a concern you failed to raise.",
|
|
129
|
-
|
|
190
|
+
honesty,
|
|
191
|
+
noPrecedent,
|
|
130
192
|
];
|
|
131
193
|
}
|
|
132
194
|
|
|
@@ -273,4 +335,77 @@ export function loadPersonaBlock(agentRoot, opts = {}) {
|
|
|
273
335
|
}
|
|
274
336
|
}
|
|
275
337
|
|
|
276
|
-
|
|
338
|
+
/**
|
|
339
|
+
* Scaffold sentinels — a value the scaffold ships as "UNCONFIGURED…" asserts
|
|
340
|
+
* nothing, and rendering it puts a lie in the prompt. Treat it as absent so
|
|
341
|
+
* `renderPersona`'s omit-when-unset rule fires.
|
|
342
|
+
* @param {unknown} v
|
|
343
|
+
* @returns {string} "" for an unset or sentinel value
|
|
344
|
+
*/
|
|
345
|
+
export function configuredStr(v) {
|
|
346
|
+
const t = typeof v === "string" ? v.trim() : "";
|
|
347
|
+
if (!t) return "";
|
|
348
|
+
if (/^unconfigured\b/i.test(t)) return "";
|
|
349
|
+
return t;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Strip scaffold sentinels out of a parsed config/agent.json. @param {object} a @returns {object} */
|
|
353
|
+
export function scrubAgentConfig(a) {
|
|
354
|
+
const src = a && typeof a === "object" ? a : {};
|
|
355
|
+
const out = { ...src };
|
|
356
|
+
for (const k of ["firstName", "lastName", "fullName", "title", "company", "companyDescription", "persona", "background", "bio"]) {
|
|
357
|
+
if (k in out) out[k] = configuredStr(out[k]);
|
|
358
|
+
}
|
|
359
|
+
// A surname with no first name and no full name is not an identity — better
|
|
360
|
+
// to render no name at all than "You are AGENT." (the scaffold ships
|
|
361
|
+
// firstName "UNCONFIGURED" / lastName "AGENT").
|
|
362
|
+
if (!out.firstName && !out.fullName) out.lastName = "";
|
|
363
|
+
if (src.principal && typeof src.principal === "object") {
|
|
364
|
+
const p = { ...src.principal };
|
|
365
|
+
for (const k of ["firstName", "lastName", "fullName", "title"]) p[k] = configuredStr(p[k]);
|
|
366
|
+
out.principal = p;
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Same, for a parsed config/company.json. @param {object} c @returns {object} */
|
|
372
|
+
export function scrubCompanyConfig(c) {
|
|
373
|
+
const src = c && typeof c === "object" ? c : {};
|
|
374
|
+
const out = { ...src };
|
|
375
|
+
for (const k of ["name", "legalName", "description", "tagline", "industry", "stage"]) {
|
|
376
|
+
if (k in out) out[k] = configuredStr(out[k]);
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Render the persona block for a SEAT — config/agent.json + config/company.json
|
|
383
|
+
* with scaffold sentinels scrubbed first, so an unconfigured field is OMITTED
|
|
384
|
+
* rather than asserted.
|
|
385
|
+
*
|
|
386
|
+
* Lives here rather than in scripts/daemon/prompt-builder.mjs because BOTH
|
|
387
|
+
* prompt-assembly paths need it and only one of them had it. The quick-reply
|
|
388
|
+
* responder used to build its identity by scraping `## Identity` out of the
|
|
389
|
+
* seat's CLAUDE.md, which on every seat is the scaffold template — unresolved
|
|
390
|
+
* `{{agent.fullName}}` tokens followed by "If those tokens are still
|
|
391
|
+
* unresolved, your identity has not been configured yet". That is precisely the
|
|
392
|
+
* condition this module's header documents as producing an agent that
|
|
393
|
+
* introduces and signs itself with a rendering of the policy's identity_line,
|
|
394
|
+
* and it reached every reactive reply the fleet sent.
|
|
395
|
+
*
|
|
396
|
+
* @param {string} root agent repo root
|
|
397
|
+
* @param {object} [opts] forwarded to renderPersona
|
|
398
|
+
* @returns {string} "" when nothing is configured
|
|
399
|
+
*/
|
|
400
|
+
export function renderSeatPersona(root, opts = {}) {
|
|
401
|
+
const read = (rel) => {
|
|
402
|
+
try { return JSON.parse(readFileSync(join(root, rel), "utf-8")); } catch { return {}; }
|
|
403
|
+
};
|
|
404
|
+
try {
|
|
405
|
+
return renderPersona(scrubAgentConfig(read("config/agent.json")), scrubCompanyConfig(read("config/company.json")), opts) || "";
|
|
406
|
+
} catch {
|
|
407
|
+
return "";
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export const _test = { ALTITUDE_STANDING, personaProse, reportingLine, voiceRules, list, SELF_PRESENTATION_RULES };
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation-frame.mjs — the block that tells a reply prompt it is joining a
|
|
3
|
+
* conversation ALREADY IN PROGRESS, with people who already know who the agent is.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* Seats were posting a self-introduction into the middle of live threads — a
|
|
7
|
+
* rendering of `channels.<c>.identity_line` from policies/ai-disclosure.yaml,
|
|
8
|
+
* the opener naming the agent's own nature and pairing it with its principal
|
|
9
|
+
* — on a release-status post and on a correction to the agent's
|
|
10
|
+
* OWN earlier figures, in the org's own #risk-compliance channel. Removing the
|
|
11
|
+
* send-gate that DEMANDED a disclosure line (2.18.9/2.18.12) stopped the gate
|
|
12
|
+
* training the behaviour, but it did not give the model any reason to stop:
|
|
13
|
+
* the quick-reply prompt never said this was an ongoing conversation.
|
|
14
|
+
*
|
|
15
|
+
* Rendering the real prompt (scripts/daemon/responder.mjs #realGenerateResponse)
|
|
16
|
+
* for a mid-thread channel reply showed why a competent model would introduce
|
|
17
|
+
* itself there:
|
|
18
|
+
* - the item's transcript arrived as a bare "Recent conversation history"
|
|
19
|
+
* dump with no statement that the agent is a PARTICIPANT in it;
|
|
20
|
+
* - the agent's own earlier turns were unmarked, so lines authored by the
|
|
21
|
+
* seat read as a third party's;
|
|
22
|
+
* - nothing named the surface as a room inside the agent's own organisation,
|
|
23
|
+
* and nothing said the other speakers are colleagues;
|
|
24
|
+
* - the standing instruction was "You are generating a direct response to
|
|
25
|
+
* this message" — the frame of a one-shot, not of a turn in a thread.
|
|
26
|
+
* A model handed a transcript it is not told it is part of behaves correctly
|
|
27
|
+
* when it introduces itself. The defect is the frame, not the model.
|
|
28
|
+
*
|
|
29
|
+
* WHAT THIS IS NOT
|
|
30
|
+
* It is NOT a disclosure policy and it must never render one. The truthfulness
|
|
31
|
+
* invariant lives in policies/ai-disclosure.yaml and lib/comms/send-gate.mjs
|
|
32
|
+
* and is untouched: a sincere question about whether someone is an AI is still
|
|
33
|
+
* answered plainly, on any message, internal or external. What this block
|
|
34
|
+
* suppresses is the UNPROMPTED self-introduction, which nobody asked for and
|
|
35
|
+
* which no policy ever required inside the agent's own workspace.
|
|
36
|
+
*
|
|
37
|
+
* PURE. No I/O, no clock, no env — every input is a parameter, so the exact text
|
|
38
|
+
* a given item produces is a unit test (conversation-frame.test.mjs).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A transcript line as both history renderers emit it: `[<iso>] <Name>: <text>`.
|
|
43
|
+
* The timestamp is optional because `thread_context` hydrators and the local
|
|
44
|
+
* interaction mirror both sometimes omit it.
|
|
45
|
+
*/
|
|
46
|
+
const SPEAKER_LINE = /^(\s*(?:\[[^\]]*\]\s*)?)([^:\n]{1,80}?):(\s|$)/;
|
|
47
|
+
|
|
48
|
+
/** Trim to a string, "" for anything nullish/non-scalar. */
|
|
49
|
+
function s(v) {
|
|
50
|
+
return typeof v === "string" ? v.trim() : "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Drop a trailing "(you)" marker from a speaker label. @param {string} n @returns {string} */
|
|
54
|
+
function stripOwnMarker(n) {
|
|
55
|
+
return String(n || "").replace(/\s*\(you\)\s*$/i, "").trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The speaker names in a transcript, in first-appearance order.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately conservative: a line that does not match `[ts] Name:` contributes
|
|
62
|
+
* nothing rather than guessing, so a wrapped continuation line cannot invent a
|
|
63
|
+
* participant. Names are capped at 80 chars by the pattern itself.
|
|
64
|
+
*
|
|
65
|
+
* @param {string|null|undefined} transcript
|
|
66
|
+
* @returns {string[]}
|
|
67
|
+
*/
|
|
68
|
+
export function parseSpeakers(transcript) {
|
|
69
|
+
const out = [];
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
for (const line of s(transcript).split("\n")) {
|
|
72
|
+
const m = SPEAKER_LINE.exec(line);
|
|
73
|
+
if (!m) continue;
|
|
74
|
+
const name = m[2].trim();
|
|
75
|
+
if (!name) continue;
|
|
76
|
+
const key = name.toLowerCase();
|
|
77
|
+
if (seen.has(key)) continue;
|
|
78
|
+
seen.add(key);
|
|
79
|
+
out.push(name);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Mark the agent's own turns in a transcript so the model can see that it is
|
|
86
|
+
* already IN this conversation.
|
|
87
|
+
*
|
|
88
|
+
* Only the speaker label is touched — the body is never rewritten, because the
|
|
89
|
+
* body is attacker-influenced inbound text and this function is not a sanitiser.
|
|
90
|
+
* A transcript with no line by `agentName` comes back byte-identical.
|
|
91
|
+
*
|
|
92
|
+
* @param {string|null|undefined} transcript
|
|
93
|
+
* @param {string|null|undefined} agentName
|
|
94
|
+
* @returns {string} "" when the transcript is empty
|
|
95
|
+
*/
|
|
96
|
+
export function markOwnTurns(transcript, agentName) {
|
|
97
|
+
const text = s(transcript);
|
|
98
|
+
const me = s(agentName);
|
|
99
|
+
if (!text || !me) return text;
|
|
100
|
+
const meKey = me.toLowerCase();
|
|
101
|
+
return text
|
|
102
|
+
.split("\n")
|
|
103
|
+
.map((line) => {
|
|
104
|
+
const m = SPEAKER_LINE.exec(line);
|
|
105
|
+
if (!m) return line;
|
|
106
|
+
const name = m[2].trim();
|
|
107
|
+
if (name.toLowerCase() !== meKey) return line;
|
|
108
|
+
// Already marked (re-entrant call, or an upstream renderer did it).
|
|
109
|
+
if (/\(you\)\s*$/.test(name)) return line;
|
|
110
|
+
return `${m[1]}${name} (you):${m[3]}${line.slice(m[0].length)}`;
|
|
111
|
+
})
|
|
112
|
+
.join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Drop from `threadContext` every line the channel history already carries.
|
|
117
|
+
*
|
|
118
|
+
* The quick-reply user content used to render BOTH blocks in full, and for a
|
|
119
|
+
* mid-thread channel reply they overlap almost completely: the same three or
|
|
120
|
+
* four turns arrived twice, worth up to COHORT_HISTORY_LIMIT ×
|
|
121
|
+
* COHORT_HISTORY_CHARS of budget, and read as two different records of the same
|
|
122
|
+
* exchange. Comparison is on the whole trimmed line, so a turn truncated to a
|
|
123
|
+
* different length in the two renderers is kept rather than silently dropped —
|
|
124
|
+
* a duplicate is cheap, a lost turn is not.
|
|
125
|
+
*
|
|
126
|
+
* @param {string|null|undefined} history
|
|
127
|
+
* @param {string|null|undefined} threadContext
|
|
128
|
+
* @returns {string} "" when nothing survives
|
|
129
|
+
*/
|
|
130
|
+
export function dedupeThreadAgainstHistory(history, threadContext) {
|
|
131
|
+
const thread = s(threadContext);
|
|
132
|
+
if (!thread) return "";
|
|
133
|
+
const seen = new Set(
|
|
134
|
+
s(history).split("\n").map((l) => l.trim()).filter(Boolean),
|
|
135
|
+
);
|
|
136
|
+
if (seen.size === 0) return thread;
|
|
137
|
+
const kept = thread.split("\n").filter((l) => {
|
|
138
|
+
const t = l.trim();
|
|
139
|
+
return t ? !seen.has(t) : false;
|
|
140
|
+
});
|
|
141
|
+
return kept.join("\n").trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Services whose INBOUND items are internal by construction.
|
|
146
|
+
*
|
|
147
|
+
* An item on the org plane reached this agent because hq's ACL put it in reach:
|
|
148
|
+
* it is a row inside the workspace the agent is a member of. There is no such
|
|
149
|
+
* thing as an inbound Cohort channel post from outside the org.
|
|
150
|
+
*
|
|
151
|
+
* Both spellings are live in the tree — `lib/org/messaging` says "cohort",
|
|
152
|
+
* `lib/org/tool-surface` says "cohort-org" — so both are listed rather than one
|
|
153
|
+
* of them being quietly wrong. (The same pair is enumerated in
|
|
154
|
+
* lib/comms/send-gate.mjs#classifyRecipient for the same reason.)
|
|
155
|
+
*/
|
|
156
|
+
const ORG_PLANE_SERVICES = new Set(["cohort", "cohort-org"]);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Which branch of the frame this item gets: are the other speakers colleagues
|
|
160
|
+
* inside the agent's own organisation, or correspondents outside it?
|
|
161
|
+
*
|
|
162
|
+
* THIS IS NOT A SECURITY DECISION AND MUST NEVER BECOME ONE. It selects one
|
|
163
|
+
* sentence of prose. The gate that decides whether a message may be SENT, and
|
|
164
|
+
* which information-barrier walls apply, is lib/comms/send-gate.mjs — it runs on
|
|
165
|
+
* the outbound path, on the real recipient, and nothing here can relax it.
|
|
166
|
+
*
|
|
167
|
+
* WHY IT IS NOT SIMPLY `classifyRecipient(service, channel_id || channel)`.
|
|
168
|
+
* That was the first shape, and it is wrong in a way that says nothing when it
|
|
169
|
+
* misses. `classifyRecipient` is default-DENY: its Cohort case returns
|
|
170
|
+
* "internal" only for a cuid/uuid-shaped id, so an item projected WITHOUT a
|
|
171
|
+
* `channel_id` — or one carrying a human channel slug like "#risk-compliance" —
|
|
172
|
+
* fell to "external" and the frame told the agent that colleagues in its own
|
|
173
|
+
* #risk-compliance channel were outside correspondents. Default-deny is exactly
|
|
174
|
+
* right for a send gate and exactly wrong for a description of a room, because
|
|
175
|
+
* the failure is silent: the external branch still renders no disclosure and
|
|
176
|
+
* still says "no self-introduction", so nothing downstream goes red.
|
|
177
|
+
*
|
|
178
|
+
* So origin decides first, shape second. Off the org plane the gate's own
|
|
179
|
+
* predicate is used unchanged, and its default-deny is inherited — an email or
|
|
180
|
+
* an unrecognised handle is external, which is both conservative and true.
|
|
181
|
+
*
|
|
182
|
+
* PURE: the classifier is a PARAMETER, so this module still has no imports with
|
|
183
|
+
* state and the branch a given item takes is a unit test.
|
|
184
|
+
*
|
|
185
|
+
* @param {{service?:string, channel_id?:string, recipient?:string, channel?:string}} item
|
|
186
|
+
* @param {((channel:string, recipient:string)=>string)|null} [classify]
|
|
187
|
+
* lib/comms/send-gate.mjs#classifyRecipient, injected
|
|
188
|
+
* @returns {"internal"|"external"}
|
|
189
|
+
*/
|
|
190
|
+
export function frameRecipientClass(item = {}, classify = null) {
|
|
191
|
+
const service = s(item.service).toLowerCase();
|
|
192
|
+
if (ORG_PLANE_SERVICES.has(service)) return "internal";
|
|
193
|
+
const handle = s(item.channel_id) || s(item.recipient) || s(item.channel);
|
|
194
|
+
if (!handle || typeof classify !== "function") return "external";
|
|
195
|
+
try {
|
|
196
|
+
return classify(service, handle) === "external" ? "external" : "internal";
|
|
197
|
+
} catch {
|
|
198
|
+
return "external";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* How the room should be described to the model.
|
|
204
|
+
* @param {{service?:string, channel?:string, is_dm?:boolean}} item
|
|
205
|
+
* @returns {string}
|
|
206
|
+
*/
|
|
207
|
+
function surfaceLabel(item) {
|
|
208
|
+
const channel = s(item.channel);
|
|
209
|
+
const service = s(item.service) || "this workspace";
|
|
210
|
+
if (item.is_dm) {
|
|
211
|
+
return `a direct message on ${service}`;
|
|
212
|
+
}
|
|
213
|
+
return channel ? `the ${channel} channel on ${service}` : `a channel on ${service}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The frame block.
|
|
218
|
+
*
|
|
219
|
+
* BUDGET. Six lines of standing frame plus at most two derived lines — under
|
|
220
|
+
* ~120 tokens, and it is spent only when there is actually a conversation to
|
|
221
|
+
* frame (`turns > 0`). It is paid for several times over by
|
|
222
|
+
* `dedupeThreadAgainstHistory`, which removes a duplicate transcript worth up to
|
|
223
|
+
* ~1,200 tokens from the same prompt.
|
|
224
|
+
*
|
|
225
|
+
* WHAT IS INCLUDED — the four things the rendered prompt was missing and a model
|
|
226
|
+
* would otherwise have to infer: that the exchange is ongoing, that the agent is
|
|
227
|
+
* already a participant in it, who the other participants are, and whether they
|
|
228
|
+
* are colleagues inside the agent's own organisation.
|
|
229
|
+
*
|
|
230
|
+
* WHAT IS EXCLUDED, deliberately: the room's full member list (unbounded, and
|
|
231
|
+
* the speakers are what matters for a reply), member titles and profiles (the
|
|
232
|
+
* context compiler's job, and only for the sender), the channel topic/purpose
|
|
233
|
+
* (an extra round trip on a 60-second path), and anything about the agent's
|
|
234
|
+
* nature — see the module header.
|
|
235
|
+
*
|
|
236
|
+
* @param {object} o
|
|
237
|
+
* @param {object} o.item the inbox item
|
|
238
|
+
* @param {string} [o.agentName] the seat's own full name
|
|
239
|
+
* @param {"internal"|"external"} [o.recipientClass="internal"]
|
|
240
|
+
* @param {string} [o.history] channel transcript
|
|
241
|
+
* @param {string} [o.threadContext] thread transcript (already deduped)
|
|
242
|
+
* @returns {string} "" when there is no prior conversation to frame
|
|
243
|
+
*/
|
|
244
|
+
export function conversationFrame({
|
|
245
|
+
item = {},
|
|
246
|
+
agentName = "",
|
|
247
|
+
recipientClass = "internal",
|
|
248
|
+
history = "",
|
|
249
|
+
threadContext = "",
|
|
250
|
+
} = {}) {
|
|
251
|
+
const transcript = [s(history), s(threadContext)].filter(Boolean).join("\n");
|
|
252
|
+
if (!transcript) return "";
|
|
253
|
+
|
|
254
|
+
const me = s(agentName);
|
|
255
|
+
const meKey = me.toLowerCase();
|
|
256
|
+
// The transcript handed in has usually already been through markOwnTurns, so
|
|
257
|
+
// the agent's own label reads "<Name> (you)". Compare on the bare name or the
|
|
258
|
+
// seat lands in its own list of "others who have spoken here".
|
|
259
|
+
const all = parseSpeakers(transcript).map((n) => ({ name: n, key: stripOwnMarker(n).toLowerCase() }));
|
|
260
|
+
const speakers = all.filter((x) => x.key !== meKey).map((x) => stripOwnMarker(x.name));
|
|
261
|
+
const iHaveSpoken = meKey !== "" && all.some((x) => x.key === meKey);
|
|
262
|
+
|
|
263
|
+
const internal = recipientClass !== "external";
|
|
264
|
+
const who = internal
|
|
265
|
+
? "The people in it are colleagues in your own organisation. They know who you are"
|
|
266
|
+
: "The people in it are already in correspondence with you";
|
|
267
|
+
|
|
268
|
+
const lines = [];
|
|
269
|
+
lines.push("## This conversation");
|
|
270
|
+
lines.push("");
|
|
271
|
+
lines.push(
|
|
272
|
+
`You are already part of this exchange — it is ${surfaceLabel(item)}, in progress. ${who}; they do not need to be told, and nobody has asked.`,
|
|
273
|
+
);
|
|
274
|
+
if (speakers.length > 0) {
|
|
275
|
+
lines.push(
|
|
276
|
+
`Others who have spoken here: ${speakers.slice(0, 8).join(", ")}${speakers.length > 8 ? ", and others" : ""}.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (iHaveSpoken) {
|
|
280
|
+
lines.push(`Lines marked "(you)" below are your OWN earlier turns — you are continuing them, including when you are correcting one.`);
|
|
281
|
+
}
|
|
282
|
+
lines.push(
|
|
283
|
+
"Write the next turn and nothing else: no greeting, no preamble, no self-introduction, no statement of your role, your reporting line or what you are. Pick up where the thread left off.",
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
return lines.join("\n");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export const _test = { SPEAKER_LINE, surfaceLabel, ORG_PLANE_SERVICES };
|