fix: propagate agent run context for subagent announce
This commit is contained in:
@@ -9,7 +9,7 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
||||||
- Web: trim HTML error bodies in web_fetch failures. (#1193) — thanks @sebslight.
|
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||||
|
|
||||||
## 2026.1.18-5
|
## 2026.1.18-5
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
|
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
|
||||||
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
|
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
|
||||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
||||||
- Exec: add `tools.exec.pathPrepend` for prepending PATH entries on exec runs.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||||
|
|||||||
@@ -157,4 +157,79 @@ describe("clawdbot-tools: subagents", () => {
|
|||||||
// Session should be deleted since cleanup=delete
|
// Session should be deleted since cleanup=delete
|
||||||
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sessions_spawn announces with requester accountId", async () => {
|
||||||
|
resetSubagentRegistryForTests();
|
||||||
|
callGatewayMock.mockReset();
|
||||||
|
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||||
|
let agentCallCount = 0;
|
||||||
|
let childRunId: string | undefined;
|
||||||
|
|
||||||
|
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||||
|
const request = opts as { method?: string; params?: unknown };
|
||||||
|
calls.push(request);
|
||||||
|
if (request.method === "agent") {
|
||||||
|
agentCallCount += 1;
|
||||||
|
const runId = `run-${agentCallCount}`;
|
||||||
|
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
|
||||||
|
if (params?.lane === "subagent") {
|
||||||
|
childRunId = runId;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
status: "accepted",
|
||||||
|
acceptedAt: 4000 + agentCallCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (request.method === "agent.wait") {
|
||||||
|
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
|
||||||
|
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
|
||||||
|
}
|
||||||
|
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createClawdbotTools({
|
||||||
|
agentSessionKey: "main",
|
||||||
|
agentChannel: "whatsapp",
|
||||||
|
agentAccountId: "kev",
|
||||||
|
}).find((candidate) => candidate.name === "sessions_spawn");
|
||||||
|
if (!tool) throw new Error("missing sessions_spawn tool");
|
||||||
|
|
||||||
|
const result = await tool.execute("call2", {
|
||||||
|
task: "do thing",
|
||||||
|
runTimeoutSeconds: 1,
|
||||||
|
cleanup: "keep",
|
||||||
|
});
|
||||||
|
expect(result.details).toMatchObject({
|
||||||
|
status: "accepted",
|
||||||
|
runId: "run-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!childRunId) throw new Error("missing child runId");
|
||||||
|
emitAgentEvent({
|
||||||
|
runId: childRunId,
|
||||||
|
stream: "lifecycle",
|
||||||
|
data: {
|
||||||
|
phase: "end",
|
||||||
|
startedAt: 1000,
|
||||||
|
endedAt: 2000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const agentCalls = calls.filter((call) => call.method === "agent");
|
||||||
|
expect(agentCalls).toHaveLength(2);
|
||||||
|
const announceParams = agentCalls[1]?.params as
|
||||||
|
| { accountId?: string; channel?: string; deliver?: boolean }
|
||||||
|
| undefined;
|
||||||
|
expect(announceParams?.deliver).toBe(true);
|
||||||
|
expect(announceParams?.channel).toBe("whatsapp");
|
||||||
|
expect(announceParams?.accountId).toBe("kev");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -335,6 +335,42 @@ describe("agentCommand", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers runContext for embedded routing", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
mockConfig(home, store);
|
||||||
|
|
||||||
|
await agentCommand(
|
||||||
|
{
|
||||||
|
message: "hi",
|
||||||
|
to: "+1555",
|
||||||
|
channel: "whatsapp",
|
||||||
|
runContext: { messageChannel: "slack", accountId: "acct-2" },
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||||
|
expect(callArgs?.messageChannel).toBe("slack");
|
||||||
|
expect(callArgs?.agentAccountId).toBe("acct-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards accountId to embedded runs", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const store = path.join(home, "sessions.json");
|
||||||
|
mockConfig(home, store);
|
||||||
|
|
||||||
|
await agentCommand(
|
||||||
|
{ message: "hi", to: "+1555", accountId: "kev" },
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
|
||||||
|
expect(callArgs?.agentAccountId).toBe("kev");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("logs output when delivery is disabled", async () => {
|
it("logs output when delivery is disabled", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const store = path.join(home, "sessions.json");
|
const store = path.join(home, "sessions.json");
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { applyVerboseOverride } from "../sessions/level-overrides.js";
|
|||||||
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
||||||
import { resolveMessageChannel } from "../utils/message-channel.js";
|
import { resolveMessageChannel } from "../utils/message-channel.js";
|
||||||
import { deliverAgentCommandResult } from "./agent/delivery.js";
|
import { deliverAgentCommandResult } from "./agent/delivery.js";
|
||||||
|
import { resolveAgentRunContext } from "./agent/run-context.js";
|
||||||
import { resolveSession } from "./agent/session.js";
|
import { resolveSession } from "./agent/session.js";
|
||||||
import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js";
|
import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js";
|
||||||
import type { AgentCommandOpts } from "./agent/types.js";
|
import type { AgentCommandOpts } from "./agent/types.js";
|
||||||
@@ -367,8 +368,9 @@ export async function agentCommand(
|
|||||||
let fallbackProvider = provider;
|
let fallbackProvider = provider;
|
||||||
let fallbackModel = model;
|
let fallbackModel = model;
|
||||||
try {
|
try {
|
||||||
|
const runContext = resolveAgentRunContext(opts);
|
||||||
const messageChannel = resolveMessageChannel(
|
const messageChannel = resolveMessageChannel(
|
||||||
opts.messageChannel,
|
runContext.messageChannel,
|
||||||
opts.replyChannel ?? opts.channel,
|
opts.replyChannel ?? opts.channel,
|
||||||
);
|
);
|
||||||
const fallbackResult = await runWithModelFallback({
|
const fallbackResult = await runWithModelFallback({
|
||||||
@@ -402,6 +404,11 @@ export async function agentCommand(
|
|||||||
sessionId,
|
sessionId,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
messageChannel,
|
messageChannel,
|
||||||
|
agentAccountId: runContext.accountId,
|
||||||
|
currentChannelId: runContext.currentChannelId,
|
||||||
|
currentThreadTs: runContext.currentThreadTs,
|
||||||
|
replyToMode: runContext.replyToMode,
|
||||||
|
hasRepliedRef: runContext.hasRepliedRef,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|||||||
18
src/commands/agent/run-context.ts
Normal file
18
src/commands/agent/run-context.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { normalizeAccountId } from "../../utils/account-id.js";
|
||||||
|
import { resolveMessageChannel } from "../../utils/message-channel.js";
|
||||||
|
import type { AgentCommandOpts, AgentRunContext } from "./types.js";
|
||||||
|
|
||||||
|
export function resolveAgentRunContext(opts: AgentCommandOpts): AgentRunContext {
|
||||||
|
const merged: AgentRunContext = opts.runContext ? { ...opts.runContext } : {};
|
||||||
|
|
||||||
|
const normalizedChannel = resolveMessageChannel(
|
||||||
|
merged.messageChannel ?? opts.messageChannel,
|
||||||
|
opts.replyChannel ?? opts.channel,
|
||||||
|
);
|
||||||
|
if (normalizedChannel) merged.messageChannel = normalizedChannel;
|
||||||
|
|
||||||
|
const normalizedAccountId = normalizeAccountId(merged.accountId ?? opts.accountId);
|
||||||
|
if (normalizedAccountId) merged.accountId = normalizedAccountId;
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
@@ -7,6 +7,15 @@ export type ImageContent = {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentRunContext = {
|
||||||
|
messageChannel?: string;
|
||||||
|
accountId?: string;
|
||||||
|
currentChannelId?: string;
|
||||||
|
currentThreadTs?: string;
|
||||||
|
replyToMode?: "off" | "first" | "all";
|
||||||
|
hasRepliedRef?: { value: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
export type AgentCommandOpts = {
|
export type AgentCommandOpts = {
|
||||||
message: string;
|
message: string;
|
||||||
/** Optional image attachments for multimodal messages. */
|
/** Optional image attachments for multimodal messages. */
|
||||||
@@ -33,6 +42,8 @@ export type AgentCommandOpts = {
|
|||||||
channel?: string; // delivery channel (whatsapp|telegram|...)
|
channel?: string; // delivery channel (whatsapp|telegram|...)
|
||||||
/** Account ID for multi-account channel routing (e.g., WhatsApp account). */
|
/** Account ID for multi-account channel routing (e.g., WhatsApp account). */
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
/** Context for embedded run routing (channel/account/thread). */
|
||||||
|
runContext?: AgentRunContext;
|
||||||
deliveryTargetMode?: ChannelOutboundTargetMode;
|
deliveryTargetMode?: ChannelOutboundTargetMode;
|
||||||
bestEffortDeliver?: boolean;
|
bestEffortDeliver?: boolean;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
|
|||||||
@@ -310,6 +310,10 @@ export const agentHandlers: GatewayRequestHandlers = {
|
|||||||
deliveryTargetMode,
|
deliveryTargetMode,
|
||||||
channel: resolvedChannel,
|
channel: resolvedChannel,
|
||||||
accountId: resolvedAccountId,
|
accountId: resolvedAccountId,
|
||||||
|
runContext: {
|
||||||
|
messageChannel: resolvedChannel,
|
||||||
|
accountId: resolvedAccountId,
|
||||||
|
},
|
||||||
timeout: request.timeout?.toString(),
|
timeout: request.timeout?.toString(),
|
||||||
bestEffortDeliver,
|
bestEffortDeliver,
|
||||||
messageChannel: resolvedChannel,
|
messageChannel: resolvedChannel,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const BASE_IMAGE_PNG =
|
|||||||
function expectChannels(call: Record<string, unknown>, channel: string) {
|
function expectChannels(call: Record<string, unknown>, channel: string) {
|
||||||
expect(call.channel).toBe(channel);
|
expect(call.channel).toBe(channel);
|
||||||
expect(call.messageChannel).toBe(channel);
|
expect(call.messageChannel).toBe(channel);
|
||||||
|
const runContext = call.runContext as { messageChannel?: string } | undefined;
|
||||||
|
expect(runContext?.messageChannel).toBe(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||||
@@ -325,6 +327,8 @@ describe("gateway server agent", () => {
|
|||||||
expectChannels(call, "whatsapp");
|
expectChannels(call, "whatsapp");
|
||||||
expect(call.to).toBe("+1555");
|
expect(call.to).toBe("+1555");
|
||||||
expect(call.accountId).toBe("kev");
|
expect(call.accountId).toBe("kev");
|
||||||
|
const runContext = call.runContext as { accountId?: string } | undefined;
|
||||||
|
expect(runContext?.accountId).toBe("kev");
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user