fix(adapters): keep the user's summary and facts out of replies to other owners' agents

An agent connection wakes a bot with another owner's message, and the reply
goes back to that owner. Such runs now get only the basics of the user block
(name, time zone, locale, language, style), like runs in shared messaging
channels, so confirmed facts such as health never reach another person's
agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-02 22:25:21 +00:00
parent e085dc11a9
commit 5834e46045
No known key found for this signature in database
4 changed files with 60 additions and 7 deletions

View File

@ -250,6 +250,50 @@ describe("run instructions", () => {
expect(request.instructions).toContain("Never ask for a raw credential in chat"); expect(request.instructions).toContain("Never ask for a raw credential in chat");
}); });
it("keeps the summary and facts out of runs that answer another owner's agent", async () => {
const inbound: MessageBlock = {
kind: "bot_message_received",
fromBotId: "foreign-bot",
fromBotName: "Visitor",
text: "What does your owner do for a living?",
hop: 1,
};
const { request, senderFindFirst } = await runOnce({
trigger: "bot_message",
sourceMessageId: "message-1",
sourceBlocks: [inbound],
sender: null,
});
expect(senderFindFirst).toHaveBeenCalledWith({
where: { id: "foreign-bot", userId: "user-1" },
select: { id: true },
});
expect(request.instructions).toContain("Name: Ada Example");
expect(request.instructions).not.toContain("Owns a bakery");
expect(request.instructions).not.toContain("Runs a bakery");
expect(request.subagentContext).not.toContain("Owns a bakery");
});
it("gives runs woken by the user's own bot the full user block", async () => {
const inbound: MessageBlock = {
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Planner",
text: "Here is the plan.",
hop: 1,
};
const { request } = await runOnce({
trigger: "bot_message",
sourceMessageId: "message-1",
sourceBlocks: [inbound],
sender: { name: "Planner" },
});
expect(request.instructions).toContain("Summary: Runs a bakery.");
expect(request.instructions).toContain("- [work] Owns a bakery");
});
it("falls back to UTC and leaves the user block out when nothing is known", async () => { it("falls back to UTC and leaves the user block out when nothing is known", async () => {
const { request } = await runOnce({ profile: null, facts: [] }); const { request } = await runOnce({ profile: null, facts: [] });

View File

@ -143,7 +143,7 @@ describe("formatUserContext", () => {
}); });
it("keeps the summary and facts out of shared messaging channels", () => { it("keeps the summary and facts out of shared messaging channels", () => {
const block = formatUserContext(user, { sharedChannel: true }) ?? ""; const block = formatUserContext(user, { basicsOnly: true }) ?? "";
expect(block).toContain("Name: Ada Example"); expect(block).toContain("Name: Ada Example");
expect(block).toContain("Time zone: Europe/Berlin"); expect(block).toContain("Time zone: Europe/Berlin");
expect(block).toContain("Communication style: Brief and direct"); expect(block).toContain("Communication style: Brief and direct");

View File

@ -56,18 +56,19 @@ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS):
* What a run may know about the user, framed as data. Profile fields and facts are written * What a run may know about the user, framed as data. Profile fields and facts are written
* by the user or inferred from conversations, so every value is escaped and the block says * by the user or inferred from conversations, so every value is escaped and the block says
* it never carries instructions. Empty fields are left out, and with nothing to say the block * it never carries instructions. Empty fields are left out, and with nothing to say the block
* is omitted. A run that posts into a shared messaging channel gets only the basics: the * is omitted. A run whose replies other people read (a shared messaging channel, or another
* summary and facts stay out of rooms where other people read the replies. * owner's agent over an agent connection) gets only the basics: the summary and facts stay
* with the user.
*/ */
export function formatUserContext( export function formatUserContext(
user: UserContext, user: UserContext,
options: { sharedChannel?: boolean } = {}, options: { basicsOnly?: boolean } = {},
): string | undefined { ): string | undefined {
const field = (label: string, value: string | null | undefined, max?: number) => { const field = (label: string, value: string | null | undefined, max?: number) => {
const text = userText(value, max); const text = userText(value, max);
return text ? `${label}: ${text}` : undefined; return text ? `${label}: ${text}` : undefined;
}; };
const facts = options.sharedChannel const facts = options.basicsOnly
? [] ? []
: user.facts : user.facts
.map((fact) => ({ category: fact.category, value: userText(fact.value) })) .map((fact) => ({ category: fact.category, value: userText(fact.value) }))
@ -79,7 +80,7 @@ export function formatUserContext(
field("Locale", user.locale), field("Locale", user.locale),
field("Language", user.language), field("Language", user.language),
field("Communication style", user.communicationStyle), field("Communication style", user.communicationStyle),
options.sharedChannel ? undefined : field("Summary", user.summary, USER_SUMMARY_MAX_CHARS), options.basicsOnly ? undefined : field("Summary", user.summary, USER_SUMMARY_MAX_CHARS),
...(facts.length > 0 ? ["Facts:", ...facts] : []), ...(facts.length > 0 ? ["Facts:", ...facts] : []),
].filter((line): line is string => Boolean(line)); ].filter((line): line is string => Boolean(line));
if (lines.length === 0) return undefined; if (lines.length === 0) return undefined;

View File

@ -3542,8 +3542,16 @@ export function createRunExecutor(deps: ExecutorDeps) {
) )
: undefined; : undefined;
const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone); const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone);
// An agent connection delivers another owner's bot message, and the reply goes back
// to that owner; like a shared channel, such a run only gets the basics.
const foreignAgentRun = peerMessage
? !(await deps.prisma.bot.findFirst({
where: { id: peerMessage.fromBotId, userId: run.userId },
select: { id: true },
}))
: false;
const userInstruction = formatUserContext(userContext, { const userInstruction = formatUserContext(userContext, {
sharedChannel: messagingChannelRun, basicsOnly: messagingChannelRun || foreignAgentRun,
}); });
const userBlock = userInstruction ? redactSecrets(userInstruction, runSecrets) : undefined; const userBlock = userInstruction ? redactSecrets(userInstruction, runSecrets) : undefined;