@cohortapp/agent-sdk 2.18.11 → 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.
@@ -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 never renders the persona block, which is why the doctrine is a
78
- // separately-exported constant rather than part of voiceRules().
79
- import { MESSAGE_CRAFT } from "../../lib/identity/persona.mjs";
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(join(AGENT_REPO_DIR, "CLAUDE.md"), "utf-8");
568
- // Extract just the identity and communication rules
569
- const lines = raw.split("\n");
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
- cachedPreamble = FALLBACK_PREAMBLE;
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
- return readFileSync(path, "utf-8");
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({ item = {}, classResult = {}, conversationHistory = null, currentWork = "", rollCall = false } = {}) {
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
- conversationHistory ? `\nRecent conversation history:\n${conversationHistory}` : null,
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
- // A roll-call's thread context is the room's other answers — not evidence
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(Boolean).join("\n");
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 generating a direct response to this message. Be concise and actionable.
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
- const userContent = buildQuickReplyUserContent({ item, classResult, conversationHistory, currentWork, rollCall });
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,