@cohortapp/agent-sdk 2.18.12 → 2.18.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/comms/send-gate.mjs +6 -4
- 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/package.json +1 -1
- package/plugins/maestro-skills/skills/persona-discipline.md +24 -2
- package/scripts/ci/run-tests.mjs +16 -2
- package/scripts/daemon/context-compiler.mjs +9 -1
- package/scripts/daemon/prompt-builder.mjs +219 -137
- package/scripts/daemon/responder.mjs +226 -26
|
@@ -74,9 +74,53 @@ import { generateAck } from "./assurance.mjs";
|
|
|
74
74
|
// must be large; attach genuinely verbose content rather than dumping it). The
|
|
75
75
|
// SAME constant leads the full-session prompt in prompt-builder.mjs, so a reactive
|
|
76
76
|
// quick reply and a full inbox/backlog session shape outbound prose identically.
|
|
77
|
-
// This path
|
|
78
|
-
//
|
|
79
|
-
|
|
77
|
+
// This path renders the seat persona through renderSeatPersona() rather than
|
|
78
|
+
// the full voiceRules() block, which is why the doctrine is a separately-
|
|
79
|
+
// exported constant rather than part of voiceRules().
|
|
80
|
+
//
|
|
81
|
+
// SELF_PRESENTATION travels with it, and for a sharper reason. The preamble this
|
|
82
|
+
// path loads is scraped out of the SEAT's CLAUDE.md — a file `maestro upgrade`
|
|
83
|
+
// deliberately never touches, so a seat scaffolded before the "How you write and
|
|
84
|
+
// carry yourself" section existed gets NO instruction about self-presentation on
|
|
85
|
+
// this plane at all. A model with no such instruction falls back to the
|
|
86
|
+
// generic-assistant register it ships with, which is how quick replies came to
|
|
87
|
+
// open with a rendering of the `identity_line` template in
|
|
88
|
+
// policies/ai-disclosure.yaml inside the organisation's own channels — a line
|
|
89
|
+
// owed to an external first contact and to nobody else. Shipping the rules from
|
|
90
|
+
// the repo is what lets the fix reach a seat through `maestro upgrade`, once
|
|
91
|
+
// this is released — nothing here changes a seat that is still on an older SDK.
|
|
92
|
+
//
|
|
93
|
+
// Adding the rule is only half of it. The preamble below is SEAT-LOCAL PROSE,
|
|
94
|
+
// and some seats were hand-edited to carry the opener as a standing rule back
|
|
95
|
+
// when the send gate demanded it. A counter-instruction does not delete a
|
|
96
|
+
// contradiction, and the seat's copy arrives first, so the scrub strips the
|
|
97
|
+
// stale instruction out of the scraped text before the model ever sees it. The
|
|
98
|
+
// truthfulness invariant is exempt from the scrub by construction — see
|
|
99
|
+
// lib/identity/disclosure-scrub.mjs.
|
|
100
|
+
import { MESSAGE_CRAFT, SELF_PRESENTATION, renderSeatPersona } from "../../lib/identity/persona.mjs";
|
|
101
|
+
import { scrubSeatText } from "../../lib/identity/disclosure-scrub.mjs";
|
|
102
|
+
// The CLAUDE.md preamble helpers — one definition, shared with prompt-builder.
|
|
103
|
+
import {
|
|
104
|
+
scrapeClaudeMdSections as _scrape,
|
|
105
|
+
stripScaffoldSentinels as _stripSentinels,
|
|
106
|
+
preambleTargets,
|
|
107
|
+
} from "../../lib/identity/claude-md.mjs";
|
|
108
|
+
// A seat CLAUDE.md line ordering a self-introduction reaches this prompt and the
|
|
109
|
+
// repo cannot see it. The check therefore runs at RUNTIME over the composed
|
|
110
|
+
// preamble — see lib/identity/disclosure-instructions.mjs.
|
|
111
|
+
import { stripDisclosureInstructionsAndWarn } from "../../lib/identity/disclosure-instructions.mjs";
|
|
112
|
+
// The conversation frame. A reply into a live thread used to arrive as a bare
|
|
113
|
+
// transcript with no statement that the agent is IN it — see
|
|
114
|
+
// lib/org/inbound/conversation-frame.mjs for the rendered prompt that proved it.
|
|
115
|
+
import {
|
|
116
|
+
conversationFrame,
|
|
117
|
+
dedupeThreadAgainstHistory,
|
|
118
|
+
frameRecipientClass,
|
|
119
|
+
markOwnTurns,
|
|
120
|
+
} from "../../lib/org/inbound/conversation-frame.mjs";
|
|
121
|
+
// Internal vs external, by the SAME predicate the send gate uses, so the frame
|
|
122
|
+
// and the gate can never disagree about who the agent is talking to.
|
|
123
|
+
import { classifyRecipient } from "../../lib/comms/send-gate.mjs";
|
|
80
124
|
// A human's roll-call to the whole room. The reply-shaping block (≤ 5 lines,
|
|
81
125
|
// about myself only, no @mentions, no ack) lives with the vocabulary in
|
|
82
126
|
// broadcast.mjs so the session path (prompt-builder) says the same thing this
|
|
@@ -561,28 +605,95 @@ function logInteraction(item, responseText, o = {}) {
|
|
|
561
605
|
// Identity preamble (cached)
|
|
562
606
|
// ---------------------------------------------------------------------------
|
|
563
607
|
|
|
608
|
+
// The CLAUDE.md scrape and the scaffold-sentinel strip live in
|
|
609
|
+
// lib/identity/claude-md.mjs. They used to be defined HERE, which is how the
|
|
610
|
+
// full-session plane (prompt-builder.mjs) kept its own unfixed copy of the same
|
|
611
|
+
// scrape: one plane got the fix, the other did not, and the other is the tier a
|
|
612
|
+
// substantive reply escalates to. Re-exported for the tests that render this
|
|
613
|
+
// plane, so there is one definition and two callers.
|
|
614
|
+
export { scrapeClaudeMdSections, stripScaffoldSentinels } from "../../lib/identity/claude-md.mjs";
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The quick reply's identity + house-style preamble.
|
|
618
|
+
*
|
|
619
|
+
* IDENTITY COMES FROM config/agent.json, NOT FROM CLAUDE.md.
|
|
620
|
+
*
|
|
621
|
+
* This used to scrape `## Identity` out of the seat's CLAUDE.md and hand it to
|
|
622
|
+
* the model as-is. On every seat in the fleet that section is the scaffold
|
|
623
|
+
* template — `You are ` + "`{{agent.fullName}}`, `{{agent.title}}` at" +
|
|
624
|
+
* " `{{agent.company}}`…" — followed by "If those tokens are still unresolved,
|
|
625
|
+
* your identity has not been configured yet — run `maestro setup`". Nothing
|
|
626
|
+
* substitutes those tokens on this path: the responder is a `claude --print`
|
|
627
|
+
* call that is never shown config/agent.json. So EVERY reactive reply the fleet
|
|
628
|
+
* sent opened with an identity block that named nobody and then told the model
|
|
629
|
+
* its identity was unconfigured — which lib/identity/persona.mjs documents, in
|
|
630
|
+
* its own header, as the exact condition that makes an agent introduce and sign
|
|
631
|
+
* itself with a rendering of the policy's identity_line.
|
|
632
|
+
*
|
|
633
|
+
* `renderSeatPersona` is the renderer the FULL-SESSION path has used since that
|
|
634
|
+
* header was written. It resolves the real name, title, company, standing and
|
|
635
|
+
* reporting line from config/agent.json, and it carries `voiceRules()` —
|
|
636
|
+
* including "Do not introduce, describe or sign yourself as an assistant, a bot,
|
|
637
|
+
* an AI helper, or as 'working on behalf of' someone" AND the untouched
|
|
638
|
+
* truthfulness invariant. Using it here is what puts the two planes on one
|
|
639
|
+
* identity instead of one resolved and one template.
|
|
640
|
+
*
|
|
641
|
+
* The CLAUDE.md scrape is kept for the house-style sections the persona block
|
|
642
|
+
* does not cover, with `## Identity` DROPPED whenever the persona rendered —
|
|
643
|
+
* two identity blocks, one of them unresolved, is worse than either alone. It is
|
|
644
|
+
* RESTORED when nothing rendered, because a prompt that says "run maestro setup"
|
|
645
|
+
* is the honest rendering of a seat nobody has set up
|
|
646
|
+
* (lib/identity/claude-md.mjs#preambleTargets; pinned by
|
|
647
|
+
* responder-unconfigured-seat.test.mjs).
|
|
648
|
+
*
|
|
649
|
+
* AND THE SCRAPE IS FILTERED. It is seat-authored prose, and a seat file saying
|
|
650
|
+
* "On first message in any thread, introduce yourself as <the identity_line>."
|
|
651
|
+
* reaches this prompt verbatim, survives every upgrade,
|
|
652
|
+
* and defeats everything above — while being invisible from this repo, so no
|
|
653
|
+
* test that renders the scaffold can catch it. The check therefore runs at
|
|
654
|
+
* RUNTIME on the text about to go to the model, and warns once per line naming
|
|
655
|
+
* the file to edit (lib/identity/disclosure-instructions.mjs). It is narrow on
|
|
656
|
+
* purpose and keeps a line that FORBIDS the behaviour; the truthfulness
|
|
657
|
+
* invariant is untouched.
|
|
658
|
+
*/
|
|
564
659
|
function loadPreamble() {
|
|
565
660
|
if (cachedPreamble) return cachedPreamble;
|
|
661
|
+
const persona = renderSeatPersona(AGENT_REPO_DIR);
|
|
662
|
+
const claudeMd = join(AGENT_REPO_DIR, "CLAUDE.md");
|
|
663
|
+
let scraped = "";
|
|
566
664
|
try {
|
|
567
|
-
const raw = readFileSync(
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const sections = [];
|
|
571
|
-
let capturing = false;
|
|
572
|
-
const targets = ["## Identity", "## Company Context", "## Communication Rules"];
|
|
573
|
-
for (const line of lines) {
|
|
574
|
-
if (targets.some((h) => line.startsWith(h))) { capturing = true; sections.push(line); continue; }
|
|
575
|
-
if (capturing && /^## [A-Z]/.test(line) && !targets.some((h) => line.startsWith(h))) { capturing = false; continue; }
|
|
576
|
-
if (capturing) sections.push(line);
|
|
577
|
-
}
|
|
578
|
-
cachedPreamble = sections.join("\n").trim();
|
|
579
|
-
if (cachedPreamble.length < 100) cachedPreamble = FALLBACK_PREAMBLE;
|
|
665
|
+
const raw = readFileSync(claudeMd, "utf-8");
|
|
666
|
+
const targets = preambleTargets(Boolean(persona), ["## Company Context", "## Communication Rules"]);
|
|
667
|
+
scraped = _stripSentinels(_scrape(raw, targets));
|
|
580
668
|
} catch {
|
|
581
|
-
|
|
669
|
+
scraped = "";
|
|
582
670
|
}
|
|
671
|
+
// The seat file is the one input this repo cannot see. A line in it ordering a
|
|
672
|
+
// self-introduction survives every upgrade and defeats everything above, so it
|
|
673
|
+
// is dropped here, on the text about to go to the model, and warned about.
|
|
674
|
+
scraped = stripDisclosureInstructionsAndWarn(scraped, claudeMd).trim();
|
|
675
|
+
// Scrubbed as ONE string, and deliberately including the persona: the persona
|
|
676
|
+
// is rendered from config/agent.json, which is seat-local too, so a
|
|
677
|
+
// disclosure sentence stuffed into `persona`/`background`/`bio` renders into
|
|
678
|
+
// this prompt exactly as a CLAUDE.md line would. Scrubbing the composed text
|
|
679
|
+
// covers both seat inputs with one pass — and it happens BEFORE the length
|
|
680
|
+
// floor below, so a seat whose identity section is mostly a disclosure
|
|
681
|
+
// instruction falls through to the framework fallback rather than shipping a
|
|
682
|
+
// two-line stump. The truthfulness invariant is exempt by construction; see
|
|
683
|
+
// lib/identity/disclosure-scrub.mjs.
|
|
684
|
+
const composed = scrubSeatText([persona, scraped].filter(Boolean).join("\n\n").trim());
|
|
685
|
+
// The floor applies to the WHOLE preamble, not to the scrape alone: a seat
|
|
686
|
+
// with a resolved persona and an empty CLAUDE.md is configured, and must not
|
|
687
|
+
// be thrown back to the fallback that knows none of it.
|
|
688
|
+
cachedPreamble = composed.length < 100 ? FALLBACK_PREAMBLE : composed;
|
|
583
689
|
return cachedPreamble;
|
|
584
690
|
}
|
|
585
691
|
|
|
692
|
+
/** For tests: drop the cached preamble so a fresh seat dir is re-read. */
|
|
693
|
+
export function _resetPreambleCache() {
|
|
694
|
+
cachedPreamble = null;
|
|
695
|
+
}
|
|
696
|
+
|
|
586
697
|
function buildFallbackPreamble() {
|
|
587
698
|
const a = loadAgent();
|
|
588
699
|
const principalName = a.principal?.fullName || "the principal";
|
|
@@ -597,11 +708,34 @@ const FALLBACK_PREAMBLE = buildFallbackPreamble();
|
|
|
597
708
|
// Load user profile for context
|
|
598
709
|
// ---------------------------------------------------------------------------
|
|
599
710
|
|
|
711
|
+
/**
|
|
712
|
+
* The seat's own notes about a sender, injected into the SYSTEM prompt.
|
|
713
|
+
*
|
|
714
|
+
* Filtered like the CLAUDE.md scrape, and for the same reason: this is
|
|
715
|
+
* seat-authored prose in an instruction position, written on the seat's own
|
|
716
|
+
* disk where the repo cannot see it. A profile saying "introduce yourself as an
|
|
717
|
+
* AI assistant when writing to this person" would defeat every other half of
|
|
718
|
+
* the fix for exactly one correspondent — the hardest version of the bug to
|
|
719
|
+
* reproduce. See lib/identity/disclosure-instructions.mjs.
|
|
720
|
+
*
|
|
721
|
+
* @param {string} sender
|
|
722
|
+
* @returns {string|null}
|
|
723
|
+
*/
|
|
600
724
|
function loadUserProfile(sender) {
|
|
601
725
|
try {
|
|
602
726
|
const profileName = sender.replace(/\s+/g, "-").toLowerCase();
|
|
603
727
|
const path = join(AGENT_REPO_DIR, "memory", "profiles", "users", `${profileName}.yaml`);
|
|
604
|
-
|
|
728
|
+
// Both filters, for the same reason the CLAUDE.md preamble gets both: these
|
|
729
|
+
// files are seat-local, written by the agent itself over months, and land in
|
|
730
|
+
// the system prompt under a "Sender profile:" heading — i.e. as instruction,
|
|
731
|
+
// not as quoted data. `scrubSeatText` removes a rendered identity TEMPLATE;
|
|
732
|
+
// `stripDisclosureInstructionsAndWarn` removes an INSTRUCTION to open with
|
|
733
|
+
// one and names the file on stderr. A profile that recorded "always
|
|
734
|
+
// introduces itself to this person as an AI assistant" would otherwise
|
|
735
|
+
// re-arm the behaviour for that one sender — the hardest version of this
|
|
736
|
+
// bug to reproduce — no matter what the framework rules say.
|
|
737
|
+
const raw = readFileSync(path, "utf-8");
|
|
738
|
+
return stripDisclosureInstructionsAndWarn(scrubSeatText(raw), path) || null;
|
|
605
739
|
} catch {
|
|
606
740
|
return null;
|
|
607
741
|
}
|
|
@@ -844,23 +978,65 @@ export async function loadCurrentWork(item, deps = {}) {
|
|
|
844
978
|
* persona needs: describe it as your own work, and never quote a session name
|
|
845
979
|
* or id (they are internal — `session_status` says the same).
|
|
846
980
|
*/
|
|
847
|
-
export function buildQuickReplyUserContent({
|
|
981
|
+
export function buildQuickReplyUserContent({
|
|
982
|
+
item = {},
|
|
983
|
+
classResult = {},
|
|
984
|
+
conversationHistory = null,
|
|
985
|
+
currentWork = "",
|
|
986
|
+
rollCall = false,
|
|
987
|
+
agentName = "",
|
|
988
|
+
recipientClass = "internal",
|
|
989
|
+
} = {}) {
|
|
990
|
+
// A roll-call's thread context is the room's other answers — not evidence
|
|
991
|
+
// about this seat, and exactly what the reply must not summarise.
|
|
992
|
+
const rawThread = !rollCall && item.thread_context ? String(item.thread_context) : "";
|
|
993
|
+
// The two transcripts overlapped almost completely on a mid-thread channel
|
|
994
|
+
// reply: the same turns arrived twice, worth up to COHORT_HISTORY_LIMIT ×
|
|
995
|
+
// COHORT_HISTORY_CHARS, and read as two different records of one exchange.
|
|
996
|
+
const thread = dedupeThreadAgainstHistory(conversationHistory, rawThread);
|
|
997
|
+
const history = markOwnTurns(conversationHistory || "", agentName);
|
|
998
|
+
const threadMarked = markOwnTurns(thread, agentName);
|
|
999
|
+
|
|
1000
|
+
// The frame is what tells the model it is CONTINUING something. Spent only
|
|
1001
|
+
// when there is a conversation to frame, and paid for by the dedupe above.
|
|
1002
|
+
const frame = conversationFrame({
|
|
1003
|
+
item,
|
|
1004
|
+
agentName,
|
|
1005
|
+
recipientClass,
|
|
1006
|
+
history,
|
|
1007
|
+
threadContext: threadMarked,
|
|
1008
|
+
});
|
|
1009
|
+
|
|
848
1010
|
return [
|
|
1011
|
+
frame || null,
|
|
1012
|
+
frame ? "" : null,
|
|
849
1013
|
`From: ${item.sender} (${item.sender_privilege || "unknown"})`,
|
|
850
1014
|
`Via: ${item.service} / ${item.channel}`,
|
|
851
1015
|
item.subject ? `Subject: ${item.subject}` : null,
|
|
852
|
-
|
|
1016
|
+
history ? `\nRecent conversation history:\n${history}` : null,
|
|
853
1017
|
currentWork
|
|
854
1018
|
? `\n${currentWork}\n(If asked what you are doing, answer from the lines above — as your own work, in your own words, board item ids included where a person could look one up. Never quote session names or ids.)`
|
|
855
1019
|
: null,
|
|
856
1020
|
`\nCurrent message:\n${item.content || "(empty)"}`,
|
|
857
|
-
|
|
858
|
-
// about this seat, and exactly what the reply must not summarise.
|
|
859
|
-
!rollCall && item.thread_context ? `\nThread context:\n${item.thread_context}` : null,
|
|
1021
|
+
threadMarked ? `\nEarlier in this thread:\n${threadMarked}` : null,
|
|
860
1022
|
`\nClassification: ${classResult.summary}`,
|
|
861
|
-
].filter(
|
|
1023
|
+
].filter((l) => l !== null).join("\n");
|
|
862
1024
|
}
|
|
863
1025
|
|
|
1026
|
+
/**
|
|
1027
|
+
* Says, in the prompt, what the assembly order already implies: everything above
|
|
1028
|
+
* this point on this plane is SEAT-LOCAL (the seat's CLAUDE.md sections and the
|
|
1029
|
+
* per-sender profile), and the framework rules below outrank it.
|
|
1030
|
+
*
|
|
1031
|
+
* lib/identity/disclosure-scrub already removes the one seat-local instruction
|
|
1032
|
+
* known to have caused harm. This sentence covers the variants the scrub's shape
|
|
1033
|
+
* rule does not recognise: a model that is handed two conflicting instructions
|
|
1034
|
+
* and no ordering between them resolves the conflict by position, and the seat's
|
|
1035
|
+
* copy is first.
|
|
1036
|
+
*/
|
|
1037
|
+
const SEAT_TEXT_PRECEDENCE =
|
|
1038
|
+
"The sections above are this seat's own local notes. The rules that follow are the framework's, they apply to every member of this organisation, and where the two conflict the rules below win.";
|
|
1039
|
+
|
|
864
1040
|
/**
|
|
865
1041
|
* The real, CLI-backed answer generator. `deps` are test seams only —
|
|
866
1042
|
* `{runCLI, currentWorkBlock, forbidsTextFor, loadConversationHistory}` — so
|
|
@@ -877,14 +1053,19 @@ export async function realGenerateResponse(item, classResult, deps = {}) {
|
|
|
877
1053
|
|
|
878
1054
|
const systemPrompt = `${preamble}
|
|
879
1055
|
|
|
880
|
-
You are
|
|
1056
|
+
You are writing the next turn in a conversation you are already part of. Be concise and actionable.
|
|
881
1057
|
If it's a question, answer it. If it's a request, confirm and describe what you'll do or have done.
|
|
882
1058
|
If it's informational, acknowledge appropriately.
|
|
1059
|
+
Open with the substance. Do not greet, do not introduce or describe yourself, and do not restate your role, your reporting line or what you are — the people you are writing to already know, and none of them asked.
|
|
883
1060
|
|
|
884
1061
|
Keep responses focused — 1-4 sentences for simple items, up to a short paragraph for more nuanced ones.
|
|
885
1062
|
Match the sender's tone and urgency level.
|
|
886
1063
|
${profile ? `\nSender profile:\n${profile}` : ""}
|
|
887
1064
|
${rollCall ? `\n${collectiveInstructions({ intent: itemCollectiveIntent(item, isRollCallItem), respondents: item.respondents })}\n` : ""}
|
|
1065
|
+
${SEAT_TEXT_PRECEDENCE}
|
|
1066
|
+
|
|
1067
|
+
${SELF_PRESENTATION}
|
|
1068
|
+
|
|
888
1069
|
${MESSAGE_CRAFT}`;
|
|
889
1070
|
|
|
890
1071
|
// A roll-call is answered from what this seat KNOWS about itself — its main
|
|
@@ -897,7 +1078,26 @@ ${MESSAGE_CRAFT}`;
|
|
|
897
1078
|
const conversationHistory = rollCall ? null : await (deps.loadConversationHistory || loadConversationHistory)(item);
|
|
898
1079
|
const currentWork = await loadCurrentWork(item, deps);
|
|
899
1080
|
|
|
900
|
-
|
|
1081
|
+
// The seat's own name, so its prior turns in the transcript can be marked as
|
|
1082
|
+
// ITS OWN. Without it the model reads lines it authored as a third party's,
|
|
1083
|
+
// which is a conversation it has not joined — and a model joining a
|
|
1084
|
+
// conversation cold introduces itself.
|
|
1085
|
+
const agentName = (() => {
|
|
1086
|
+
const a = loadAgent();
|
|
1087
|
+
const n = (a.fullName || "").trim();
|
|
1088
|
+
if (n && !/^unconfigured\b/i.test(n)) return n;
|
|
1089
|
+
const f = (a.firstName || "").trim();
|
|
1090
|
+
return f && !/^unconfigured\b/i.test(f) ? f : "";
|
|
1091
|
+
})();
|
|
1092
|
+
// Origin first, then the send gate's own predicate — see
|
|
1093
|
+
// conversation-frame.mjs#frameRecipientClass for why a bare
|
|
1094
|
+
// `classifyRecipient(service, channel_id || channel)` put colleagues in the
|
|
1095
|
+
// agent's own #risk-compliance channel on the "external" branch, silently.
|
|
1096
|
+
const recipientClass = frameRecipientClass(item, classifyRecipient);
|
|
1097
|
+
|
|
1098
|
+
const userContent = buildQuickReplyUserContent({
|
|
1099
|
+
item, classResult, conversationHistory, currentWork, rollCall, agentName, recipientClass,
|
|
1100
|
+
});
|
|
901
1101
|
|
|
902
1102
|
// (b2) Session-router decision. Compute the routing key from a daemon→router
|
|
903
1103
|
// adapter view of the item. If the item can't be keyed (unknown service,
|