diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 25a2199e7..60fde06fb 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -31,6 +31,12 @@ export function createClawdbotTools(options?: { agentTo?: string; /** Thread/topic identifier for routing replies to the originating thread. */ agentThreadId?: string | number; + /** Group id for channel-level tool policy inheritance. */ + agentGroupId?: string | null; + /** Group channel label for channel-level tool policy inheritance. */ + agentGroupChannel?: string | null; + /** Group space label for channel-level tool policy inheritance. */ + agentGroupSpace?: string | null; agentDir?: string; sandboxRoot?: string; workspaceDir?: string; @@ -114,6 +120,9 @@ export function createClawdbotTools(options?: { agentAccountId: options?.agentAccountId, agentTo: options?.agentTo, agentThreadId: options?.agentThreadId, + agentGroupId: options?.agentGroupId, + agentGroupChannel: options?.agentGroupChannel, + agentGroupSpace: options?.agentGroupSpace, sandboxed: options?.sandboxed, }), createSessionStatusTool({ diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 9c0f420b6..771994a83 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -73,6 +73,14 @@ export async function compactEmbeddedPiSession(params: { messageChannel?: string; messageProvider?: string; agentAccountId?: string; + /** Group id for channel-level tool policy resolution. */ + groupId?: string | null; + /** Group channel label (e.g. #general) for channel-level tool policy resolution. */ + groupChannel?: string | null; + /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ + groupSpace?: string | null; + /** Parent session key for subagent policy inheritance. */ + spawnedBy?: string | null; sessionFile: string; workspaceDir: string; agentDir?: string; @@ -207,6 +215,10 @@ export async function compactEmbeddedPiSession(params: { messageProvider: params.messageChannel ?? params.messageProvider, agentAccountId: params.agentAccountId, sessionKey: params.sessionKey ?? params.sessionId, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, agentDir, workspaceDir: effectiveWorkspace, config: params.config, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 3e4c0926b..2873f4143 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -267,6 +267,7 @@ export async function runEmbeddedPiAgent( groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, replyToMode: params.replyToMode, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index aa392710b..f64578369 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -211,6 +211,7 @@ export async function runEmbeddedAttempt( groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, sessionKey: params.sessionKey ?? params.sessionId, agentDir, workspaceDir: effectiveWorkspace, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 596e35a21..b3b35cbdc 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -33,6 +33,8 @@ export type RunEmbeddedPiAgentParams = { groupChannel?: string | null; /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ groupSpace?: string | null; + /** Parent session key for subagent policy inheritance. */ + spawnedBy?: string | null; /** Current channel ID for auto-threading (Slack). */ currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index c7ddc2627..90bdfb721 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -29,6 +29,8 @@ export type EmbeddedRunAttemptParams = { groupChannel?: string | null; /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ groupSpace?: string | null; + /** Parent session key for subagent policy inheritance. */ + spawnedBy?: string | null; currentChannelId?: string; currentThreadTs?: string; replyToMode?: "off" | "first" | "all"; diff --git a/src/agents/pi-tools-agent-config.test.ts b/src/agents/pi-tools-agent-config.test.ts index 488051b9a..bec7680a5 100644 --- a/src/agents/pi-tools-agent-config.test.ts +++ b/src/agents/pi-tools-agent-config.test.ts @@ -295,6 +295,31 @@ describe("Agent-specific tool filtering", () => { expect(names).not.toContain("exec"); }); + it("should inherit group tool policy for subagents from spawnedBy session keys", () => { + const cfg: ClawdbotConfig = { + channels: { + whatsapp: { + groups: { + trusted: { + tools: { allow: ["read"] }, + }, + }, + }, + }, + }; + + const tools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:subagent:test", + spawnedBy: "agent:main:whatsapp:group:trusted", + workspaceDir: "/tmp/test-subagent-group", + agentDir: "/tmp/agent-subagent", + }); + const names = tools.map((t) => t.name); + expect(names).toContain("read"); + expect(names).not.toContain("exec"); + }); + it("should apply global tool policy before agent-specific policy", () => { const cfg: ClawdbotConfig = { tools: { diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 360f4e0e1..98585ca9d 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -120,7 +120,10 @@ function resolveGroupContextFromSessionKey(sessionKey?: string | null): { if (!raw) return {}; const base = resolveThreadParentSessionKey(raw) ?? raw; const parts = base.split(":").filter(Boolean); - const body = parts[0] === "agent" ? parts.slice(2) : parts; + let body = parts[0] === "agent" ? parts.slice(2) : parts; + if (body[0] === "subagent") { + body = body.slice(1); + } if (body.length < 3) return {}; const [channel, kind, ...rest] = body; if (kind !== "group" && kind !== "channel") return {}; @@ -198,6 +201,7 @@ export function resolveEffectiveToolPolicy(params: { export function resolveGroupToolPolicy(params: { config?: ClawdbotConfig; sessionKey?: string; + spawnedBy?: string | null; messageProvider?: string; groupId?: string | null; groupChannel?: string | null; @@ -206,9 +210,10 @@ export function resolveGroupToolPolicy(params: { }): SandboxToolPolicy | undefined { if (!params.config) return undefined; const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey); - const groupId = params.groupId ?? sessionContext.groupId; + const spawnedContext = resolveGroupContextFromSessionKey(params.spawnedBy); + const groupId = params.groupId ?? sessionContext.groupId ?? spawnedContext.groupId; if (!groupId) return undefined; - const channelRaw = params.messageProvider ?? sessionContext.channel; + const channelRaw = params.messageProvider ?? sessionContext.channel ?? spawnedContext.channel; const channel = normalizeMessageChannel(channelRaw); if (!channel) return undefined; let dock; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 7b3caf591..292b706b7 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -135,6 +135,8 @@ export function createClawdbotCodingTools(options?: { groupChannel?: string | null; /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ groupSpace?: string | null; + /** Parent session key for subagent group policy inheritance. */ + spawnedBy?: string | null; /** Reply-to mode for Slack auto-threading. */ replyToMode?: "off" | "first" | "all"; /** Mutable ref to track if a reply was sent (for "first" mode). */ @@ -161,6 +163,7 @@ export function createClawdbotCodingTools(options?: { const groupPolicy = resolveGroupToolPolicy({ config: options?.config, sessionKey: options?.sessionKey, + spawnedBy: options?.spawnedBy, messageProvider: options?.messageProvider, groupId: options?.groupId, groupChannel: options?.groupChannel, @@ -290,6 +293,9 @@ export function createClawdbotCodingTools(options?: { agentAccountId: options?.agentAccountId, agentTo: options?.messageTo, agentThreadId: options?.messageThreadId, + agentGroupId: options?.groupId ?? null, + agentGroupChannel: options?.groupChannel ?? null, + agentGroupSpace: options?.groupSpace ?? null, agentDir: options?.agentDir, sandboxRoot, workspaceDir: options?.workspaceDir, diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index f3b294484..838badfc3 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -63,6 +63,9 @@ export function createSessionsSpawnTool(opts?: { agentAccountId?: string; agentTo?: string; agentThreadId?: string | number; + agentGroupId?: string | null; + agentGroupChannel?: string | null; + agentGroupSpace?: string | null; sandboxed?: boolean; }): AnyAgentTool { return { @@ -153,7 +156,7 @@ export function createSessionsSpawnTool(opts?: { } } const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`; - const shouldPatchSpawnedBy = opts?.sandboxed === true; + const spawnedByKey = requesterInternalKey; const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId); const resolvedModel = normalizeModelSelection(modelOverride) ?? @@ -219,7 +222,10 @@ export function createSessionsSpawnTool(opts?: { thinking: thinkingOverride, timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined, label: label || undefined, - spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined, + spawnedBy: spawnedByKey, + groupId: opts?.agentGroupId ?? undefined, + groupChannel: opts?.agentGroupChannel ?? undefined, + groupSpace: opts?.agentGroupSpace ?? undefined, }, timeoutMs: 10_000, })) as { runId?: string }; diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 7e3f0d960..9210a04a4 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -67,6 +67,10 @@ export const handleCompactCommand: CommandHandler = async (params) => { sessionId, sessionKey: params.sessionKey, messageChannel: params.command.channel, + groupId: params.sessionEntry.groupId, + groupChannel: params.sessionEntry.groupChannel, + groupSpace: params.sessionEntry.space, + spawnedBy: params.sessionEntry.spawnedBy, sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry), workspaceDir: params.workspaceDir, config: params.cfg, diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index 5cdc9f3d7..c38ae6b35 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -81,6 +81,10 @@ async function resolveContextReport( workspaceDir, sessionKey: params.sessionKey, messageProvider: params.command.channel, + groupId: params.sessionEntry?.groupId ?? undefined, + groupChannel: params.sessionEntry?.groupChannel ?? undefined, + groupSpace: params.sessionEntry?.space ?? undefined, + spawnedBy: params.sessionEntry?.spawnedBy ?? undefined, modelProvider: params.provider, modelId: params.model, }); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 9033759f4..ef9718833 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -377,6 +377,7 @@ export async function agentCommand( runContext.messageChannel, opts.replyChannel ?? opts.channel, ); + const spawnedBy = opts.spawnedBy ?? sessionEntry?.spawnedBy; const fallbackResult = await runWithModelFallback({ cfg, provider, @@ -412,6 +413,10 @@ export async function agentCommand( agentAccountId: runContext.accountId, messageTo: opts.replyTo ?? opts.to, messageThreadId: opts.threadId, + groupId: runContext.groupId, + groupChannel: runContext.groupChannel, + groupSpace: runContext.groupSpace, + spawnedBy, currentChannelId: runContext.currentChannelId, currentThreadTs: runContext.currentThreadTs, replyToMode: runContext.replyToMode, diff --git a/src/commands/agent/run-context.ts b/src/commands/agent/run-context.ts index a8da80d88..cc21a73cc 100644 --- a/src/commands/agent/run-context.ts +++ b/src/commands/agent/run-context.ts @@ -14,6 +14,15 @@ export function resolveAgentRunContext(opts: AgentCommandOpts): AgentRunContext const normalizedAccountId = normalizeAccountId(merged.accountId ?? opts.accountId); if (normalizedAccountId) merged.accountId = normalizedAccountId; + const groupId = (merged.groupId ?? opts.groupId)?.toString().trim(); + if (groupId) merged.groupId = groupId; + + const groupChannel = (merged.groupChannel ?? opts.groupChannel)?.toString().trim(); + if (groupChannel) merged.groupChannel = groupChannel; + + const groupSpace = (merged.groupSpace ?? opts.groupSpace)?.toString().trim(); + if (groupSpace) merged.groupSpace = groupSpace; + if ( merged.currentThreadTs == null && opts.threadId != null && diff --git a/src/commands/agent/types.ts b/src/commands/agent/types.ts index b0afadd91..e59c88725 100644 --- a/src/commands/agent/types.ts +++ b/src/commands/agent/types.ts @@ -17,6 +17,9 @@ export type AgentStreamParams = { export type AgentRunContext = { messageChannel?: string; accountId?: string; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; currentChannelId?: string; currentThreadTs?: string; replyToMode?: "off" | "first" | "all"; @@ -55,6 +58,14 @@ export type AgentCommandOpts = { accountId?: string; /** Context for embedded run routing (channel/account/thread). */ runContext?: AgentRunContext; + /** Group id for channel-level tool policy resolution. */ + groupId?: string | null; + /** Group channel label for channel-level tool policy resolution. */ + groupChannel?: string | null; + /** Group space label for channel-level tool policy resolution. */ + groupSpace?: string | null; + /** Parent session key for subagent policy inheritance. */ + spawnedBy?: string | null; deliveryTargetMode?: ChannelOutboundTargetMode; bestEffortDeliver?: boolean; abortSignal?: AbortSignal; diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 0d4b2e802..3f1a5b5a8 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -59,6 +59,9 @@ export const AgentParamsSchema = Type.Object( accountId: Type.Optional(Type.String()), replyAccountId: Type.Optional(Type.String()), threadId: Type.Optional(Type.String()), + groupId: Type.Optional(Type.String()), + groupChannel: Type.Optional(Type.String()), + groupSpace: Type.Optional(Type.String()), timeout: Type.Optional(Type.Integer({ minimum: 0 })), lane: Type.Optional(Type.String()), extraSystemPrompt: Type.Optional(Type.String()), diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index e015e1d17..8c5782e00 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -76,6 +76,9 @@ export const agentHandlers: GatewayRequestHandlers = { accountId?: string; replyAccountId?: string; threadId?: string; + groupId?: string; + groupChannel?: string; + groupSpace?: string; lane?: string; extraSystemPrompt?: string; idempotencyKey: string; @@ -85,6 +88,15 @@ export const agentHandlers: GatewayRequestHandlers = { }; const cfg = loadConfig(); const idem = request.idempotencyKey; + const groupIdRaw = typeof request.groupId === "string" ? request.groupId.trim() : ""; + const groupChannelRaw = + typeof request.groupChannel === "string" ? request.groupChannel.trim() : ""; + const groupSpaceRaw = typeof request.groupSpace === "string" ? request.groupSpace.trim() : ""; + let resolvedGroupId: string | undefined = groupIdRaw || undefined; + let resolvedGroupChannel: string | undefined = groupChannelRaw || undefined; + let resolvedGroupSpace: string | undefined = groupSpaceRaw || undefined; + let spawnedByValue = + typeof request.spawnedBy === "string" ? request.spawnedBy.trim() : undefined; const cached = context.dedupe.get(`agent:${idem}`); if (cached) { respond(cached.ok, cached.payload, cached.error, { @@ -198,7 +210,25 @@ export const agentHandlers: GatewayRequestHandlers = { const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); const labelValue = request.label?.trim() || entry?.label; - const spawnedByValue = request.spawnedBy?.trim() || entry?.spawnedBy; + spawnedByValue = spawnedByValue || entry?.spawnedBy; + let inheritedGroup: + | { groupId?: string; groupChannel?: string; groupSpace?: string } + | undefined; + if (spawnedByValue && (!resolvedGroupId || !resolvedGroupChannel || !resolvedGroupSpace)) { + try { + const parentEntry = loadSessionEntry(spawnedByValue)?.entry; + inheritedGroup = { + groupId: parentEntry?.groupId, + groupChannel: parentEntry?.groupChannel, + groupSpace: parentEntry?.space, + }; + } catch { + inheritedGroup = undefined; + } + } + resolvedGroupId = resolvedGroupId || inheritedGroup?.groupId; + resolvedGroupChannel = resolvedGroupChannel || inheritedGroup?.groupChannel; + resolvedGroupSpace = resolvedGroupSpace || inheritedGroup?.groupSpace; const deliveryFields = normalizeSessionDeliveryFields(entry); const nextEntry: SessionEntry = { sessionId, @@ -217,6 +247,10 @@ export const agentHandlers: GatewayRequestHandlers = { providerOverride: entry?.providerOverride, label: labelValue, spawnedBy: spawnedByValue, + channel: entry?.channel ?? request.channel?.trim(), + groupId: resolvedGroupId ?? entry?.groupId, + groupChannel: resolvedGroupChannel ?? entry?.groupChannel, + space: resolvedGroupSpace ?? entry?.space, }; sessionEntry = nextEntry; const sendPolicy = resolveSendPolicy({ @@ -326,8 +360,15 @@ export const agentHandlers: GatewayRequestHandlers = { runContext: { messageChannel: resolvedChannel, accountId: resolvedAccountId, + groupId: resolvedGroupId, + groupChannel: resolvedGroupChannel, + groupSpace: resolvedGroupSpace, currentThreadTs: resolvedThreadId != null ? String(resolvedThreadId) : undefined, }, + groupId: resolvedGroupId, + groupChannel: resolvedGroupChannel, + groupSpace: resolvedGroupSpace, + spawnedBy: spawnedByValue, timeout: request.timeout?.toString(), bestEffortDeliver, messageChannel: resolvedChannel,