fix(agents): route requester roles into child origins

This commit is contained in:
Gustavo Madeira Santana
2026-04-17 21:43:44 -04:00
parent 8a59771adb
commit c443de6507
21 changed files with 101 additions and 0 deletions

View File

@@ -464,6 +464,7 @@ async function dispatchDiscordComponentEvent(params: {
SenderTag: senderTag,
GroupSubject: groupSubject,
GroupChannel: groupChannel,
MemberRoleIds: interactionCtx.memberRoleIds,
GroupSystemPrompt: interactionCtx.isDirectMessage ? undefined : groupSystemPrompt,
GroupSpace: guildInfo?.id ?? guildInfo?.slug ?? interactionCtx.rawGuildId ?? undefined,
OwnerAllowFrom: ownerAllowFrom,

View File

@@ -1083,6 +1083,7 @@ export async function preflightDiscordMessage(
messageChannelId,
author,
sender,
memberRoleIds,
channelInfo,
channelName,
isGuildMessage,

View File

@@ -44,6 +44,7 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
messageChannelId: string;
author: User;
sender: DiscordSenderIdentity;
memberRoleIds: string[];
channelInfo: DiscordChannelInfo | null;
channelName?: string;

View File

@@ -144,6 +144,7 @@ export async function processDiscordMessage(
displayChannelSlug,
guildInfo,
guildSlug,
memberRoleIds,
channelConfig,
baseSessionKey,
boundSessionKey,
@@ -481,6 +482,7 @@ export async function processDiscordMessage(
SenderTag: senderTag,
GroupSubject: groupSubject,
GroupChannel: groupChannel,
MemberRoleIds: memberRoleIds,
UntrustedContext: untrustedContext,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,

View File

@@ -50,6 +50,7 @@ describe("buildDiscordNativeCommandContext", () => {
interactionId: "interaction-1",
channelId: "chan-1",
threadParentId: "parent-1",
memberRoleIds: ["admin"],
guildName: "Ops",
channelTopic: "Production alerts only",
channelConfig: {
@@ -82,6 +83,8 @@ describe("buildDiscordNativeCommandContext", () => {
expect(ctx.ChatType).toBe("channel");
expect(ctx.ConversationLabel).toBe("chan-1");
expect(ctx.GroupSubject).toBe("Ops");
expect(ctx.GroupSpace).toBe("guild-1");
expect(ctx.MemberRoleIds).toEqual(["admin"]);
expect(ctx.GroupSystemPrompt).toBe("Use the runbook.");
expect(ctx.OwnerAllowFrom).toEqual(["user-1"]);
expect(ctx.MessageThreadId).toBe("chan-1");

View File

@@ -13,6 +13,8 @@ export type BuildDiscordNativeCommandContextParams = {
interactionId: string;
channelId: string;
threadParentId?: string;
memberRoleIds?: string[];
guildId?: string;
guildName?: string;
channelTopic?: string;
channelConfig?: DiscordChannelConfigResolved | null;
@@ -67,6 +69,10 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma
ChatType: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel",
ConversationLabel: conversationLabel,
GroupSubject: params.isGuild ? params.guildName : undefined,
GroupSpace: params.isGuild
? (params.guildInfo?.id ?? params.guildInfo?.slug ?? params.guildId)
: undefined,
MemberRoleIds: params.memberRoleIds,
GroupSystemPrompt: groupSystemPrompt,
UntrustedContext: untrustedContext,
OwnerAllowFrom: ownerAllowFrom,

View File

@@ -1183,6 +1183,8 @@ async function dispatchDiscordCommandInteraction(params: {
interactionId,
channelId,
threadParentId,
memberRoleIds,
guildId: interaction.guild?.id,
guildName: interaction.guild?.name,
channelTopic: channel && "topic" in channel ? (channel.topic ?? undefined) : undefined,
channelConfig,

View File

@@ -108,6 +108,8 @@ export type SpawnAcpContext = {
agentGroupId?: string;
/** Group space label (guild/team id) from the originating channel context. */
agentGroupSpace?: string | null;
/** Trusted provider role ids for the requester in this group turn. */
agentMemberRoleIds?: string[];
sandboxed?: boolean;
};
@@ -764,6 +766,7 @@ function resolveAcpSpawnRequesterState(params: {
requesterTo: params.ctx.agentTo,
requesterThreadId: params.ctx.agentThreadId,
requesterGroupSpace: params.ctx.agentGroupSpace,
requesterMemberRoleIds: params.ctx.agentMemberRoleIds,
}),
};
}

View File

@@ -592,6 +592,41 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
).toBe("bot-alpha-room-a");
});
it("sessions_spawn uses requester roles for role-scoped target-agent accounts", async () => {
expect(
await executeBoundAccountSpawn({
callId: "call-role-scoped-account",
agentId: "bot-alpha",
context: {
agentSessionKey: "main",
agentChannel: "discord",
agentAccountId: "bot-beta",
agentTo: "channel:ops",
agentGroupSpace: "guild-current",
agentMemberRoleIds: ["admin"],
},
bindings: [
{
type: "route",
agentId: "bot-alpha",
match: { channel: "discord", accountId: "bot-alpha-default" },
},
{
type: "route",
agentId: "bot-alpha",
match: {
channel: "discord",
guildId: "guild-current",
roles: ["admin"],
peer: { kind: "channel", id: "channel:ops" },
accountId: "bot-alpha-admin",
},
},
],
}),
).toBe("bot-alpha-admin");
});
it("sessions_spawn strips channel-side prefixes from agentTo before bound-account lookup", async () => {
const rawRoomId = "!exampleRoomId:example.org";
// agentTo arrives in delivery-target format (room:<id>), while the binding

View File

@@ -288,6 +288,7 @@ export function createOpenClawTools(
agentGroupId: options?.agentGroupId,
agentGroupChannel: options?.agentGroupChannel,
agentGroupSpace: options?.agentGroupSpace,
agentMemberRoleIds: options?.agentMemberRoleIds,
sandboxed: options?.sandboxed,
requesterAgentIdOverride: options?.requesterAgentIdOverride,
workspaceDir: spawnWorkspaceDir,

View File

@@ -687,6 +687,7 @@ export async function runEmbeddedPiAgent(
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
memberRoleIds: params.memberRoleIds,
spawnedBy: params.spawnedBy,
isCanonicalWorkspace,
senderId: params.senderId,

View File

@@ -487,6 +487,7 @@ export async function runEmbeddedAttempt(
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
memberRoleIds: params.memberRoleIds,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
senderName: params.senderName,

View File

@@ -40,6 +40,8 @@ export type RunEmbeddedPiAgentParams = {
groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Trusted provider role ids for the requester in this group turn. */
memberRoleIds?: string[];
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
/** Whether workspaceDir points at the canonical agent workspace for bootstrap purposes. */

View File

@@ -310,6 +310,8 @@ export function createOpenClawCodingTools(options?: {
groupChannel?: string | null;
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
groupSpace?: string | null;
/** Trusted provider role ids for the requester in this group turn. */
memberRoleIds?: string[];
/** Parent session key for subagent group policy inheritance. */
spawnedBy?: string | null;
senderId?: string | null;
@@ -570,6 +572,7 @@ export function createOpenClawCodingTools(options?: {
agentGroupId: options?.groupId ?? null,
agentGroupChannel: options?.groupChannel ?? null,
agentGroupSpace: options?.groupSpace ?? null,
agentMemberRoleIds: options?.memberRoleIds,
agentDir: options?.agentDir,
sandboxRoot,
sandboxContainerWorkdir: sandbox?.containerWorkdir,

View File

@@ -15,6 +15,7 @@ export type SpawnedToolContext = {
agentGroupId?: string | null;
agentGroupChannel?: string | null;
agentGroupSpace?: string | null;
agentMemberRoleIds?: string[];
workspaceDir?: string;
};

View File

@@ -114,6 +114,7 @@ export type SpawnSubagentContext = {
agentGroupId?: string | null;
agentGroupChannel?: string | null;
agentGroupSpace?: string | null;
agentMemberRoleIds?: string[];
requesterAgentIdOverride?: string;
/** Explicit workspace directory for subagent to inherit (optional). */
workspaceDir?: string;
@@ -468,6 +469,7 @@ export async function spawnSubagentDirect(
requesterTo: ctx.agentTo,
requesterThreadId: ctx.agentThreadId,
requesterGroupSpace: ctx.agentGroupSpace,
requesterMemberRoleIds: ctx.agentMemberRoleIds,
});
let childSessionOrigin = requesterOrigin;
if (targetAgentId !== requesterAgentId) {

View File

@@ -244,6 +244,7 @@ export function createSessionsSpawnTool(
agentThreadId: opts?.agentThreadId,
agentGroupId: opts?.agentGroupId ?? undefined,
agentGroupSpace: opts?.agentGroupSpace,
agentMemberRoleIds: opts?.agentMemberRoleIds,
sandboxed: opts?.sandboxed,
},
);
@@ -338,6 +339,7 @@ export function createSessionsSpawnTool(
agentGroupId: opts?.agentGroupId,
agentGroupChannel: opts?.agentGroupChannel,
agentGroupSpace: opts?.agentGroupSpace,
agentMemberRoleIds: opts?.agentMemberRoleIds,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
workspaceDir: opts?.workspaceDir,
},

View File

@@ -158,6 +158,7 @@ describe("agent-runner-utils", () => {
Provider: "OpenAI",
To: "channel-1",
SenderId: "sender-1",
MemberRoleIds: ["admin", " ", "operator"],
},
hasRepliedRef: undefined,
provider: "anthropic",
@@ -173,6 +174,7 @@ describe("agent-runner-utils", () => {
agentId: run.agentId,
messageProvider: "openai",
messageTo: "channel-1",
memberRoleIds: ["admin", "operator"],
});
expect(resolved.senderContext).toEqual({
senderId: "sender-1",

View File

@@ -238,6 +238,7 @@ export function buildEmbeddedContextFromTemplate(params: {
to: params.sessionCtx.To,
}),
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
memberRoleIds: normalizeMemberRoleIds(params.sessionCtx.MemberRoleIds),
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
sessionCtx: params.sessionCtx,
@@ -247,6 +248,15 @@ export function buildEmbeddedContextFromTemplate(params: {
};
}
function normalizeMemberRoleIds(value: TemplateContext["MemberRoleIds"]): string[] | undefined {
const roles = Array.isArray(value)
? value
.map((roleId) => normalizeOptionalString(roleId))
.filter((roleId): roleId is string => Boolean(roleId))
: [];
return roles.length > 0 ? roles : undefined;
}
export function buildTemplateSenderContext(sessionCtx: TemplateContext) {
return {
senderId: normalizeOptionalString(sessionCtx.SenderId),

View File

@@ -126,6 +126,8 @@ export type MsgContext = {
/** Human label for channel-like group conversations (e.g. #general, #support). */
GroupChannel?: string;
GroupSpace?: string;
/** Trusted provider role ids for the sender in this group turn. */
MemberRoleIds?: string[];
GroupMembers?: string;
GroupSystemPrompt?: string;
/** Untrusted metadata that must not be treated as system instructions. */

View File

@@ -265,6 +265,26 @@ describe("resolveDeliveryTarget", () => {
expect(result.accountId).toBe("peer-first");
});
it("does not infer scoped bound accountId for peerless cron delivery", async () => {
setMainSessionEntry(undefined);
const cfg = makeCfg({
bindings: [
{
agentId: AGENT_ID,
match: {
channel: "telegram",
guildId: "guild-1",
accountId: "tenant-account",
},
},
],
});
const result = await resolveForAgent({ cfg });
expect(result.accountId).toBeUndefined();
});
it("preserves session lastAccountId when present", async () => {
setMainSessionEntry({
sessionId: "sess-1",