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");
});
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 () => {
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", () => {
const block = formatUserContext(user, { sharedChannel: true }) ?? "";
const block = formatUserContext(user, { basicsOnly: true }) ?? "";
expect(block).toContain("Name: Ada Example");
expect(block).toContain("Time zone: Europe/Berlin");
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
* 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
* is omitted. A run that posts into a shared messaging channel gets only the basics: the
* summary and facts stay out of rooms where other people read the replies.
* is omitted. A run whose replies other people read (a shared messaging channel, or another
* owner's agent over an agent connection) gets only the basics: the summary and facts stay
* with the user.
*/
export function formatUserContext(
user: UserContext,
options: { sharedChannel?: boolean } = {},
options: { basicsOnly?: boolean } = {},
): string | undefined {
const field = (label: string, value: string | null | undefined, max?: number) => {
const text = userText(value, max);
return text ? `${label}: ${text}` : undefined;
};
const facts = options.sharedChannel
const facts = options.basicsOnly
? []
: user.facts
.map((fact) => ({ category: fact.category, value: userText(fact.value) }))
@ -79,7 +80,7 @@ export function formatUserContext(
field("Locale", user.locale),
field("Language", user.language),
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] : []),
].filter((line): line is string => Boolean(line));
if (lines.length === 0) return undefined;

View File

@ -3542,8 +3542,16 @@ export function createRunExecutor(deps: ExecutorDeps) {
)
: undefined;
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, {
sharedChannel: messagingChannelRun,
basicsOnly: messagingChannelRun || foreignAgentRun,
});
const userBlock = userInstruction ? redactSecrets(userInstruction, runSecrets) : undefined;