fix(agents): keep cron media completions run-scoped

This commit is contained in:
ai-hpc
2026-05-25 16:54:14 +02:00
committed by Peter Steinberger
parent 9445960d9d
commit f5d2db2a60
4 changed files with 147 additions and 7 deletions

View File

@@ -6,6 +6,7 @@ import { callGateway } from "../gateway/call.js";
import { isEmbeddedMode } from "../infra/embedded-mode.js";
import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime-state.js";
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime-web-tools-state.js";
import { isCronRunSessionKey } from "../sessions/session-key-utils.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import type { GatewayMessageChannel } from "../utils/message-channel.js";
import { resolveAgentWorkspaceDir, resolveSessionAgentIds } from "./agent-scope.js";
@@ -201,6 +202,11 @@ export function createOpenClawTools(
toolAllowlist: options?.pluginToolAllowlist,
toolDenylist: options?.pluginToolDenylist,
});
const trimmedRunSessionKey = options?.runSessionKey?.trim();
const mediaGenerationAgentSessionKey =
trimmedRunSessionKey && isCronRunSessionKey(trimmedRunSessionKey)
? trimmedRunSessionKey
: options?.agentSessionKey;
const imageToolAgentDir = options?.agentDir;
const imageTool = resolveImageToolFactoryAvailable({
config: availabilityConfig ?? resolvedConfig,
@@ -226,7 +232,7 @@ export function createOpenClawTools(
config: options?.config,
agentDir: options?.agentDir,
authProfileStore: options?.authProfileStore,
agentSessionKey: options?.agentSessionKey,
agentSessionKey: mediaGenerationAgentSessionKey,
requesterOrigin: deliveryContext ?? undefined,
workspaceDir,
sandbox,
@@ -240,7 +246,7 @@ export function createOpenClawTools(
config: options?.config,
agentDir: options?.agentDir,
authProfileStore: options?.authProfileStore,
agentSessionKey: options?.agentSessionKey,
agentSessionKey: mediaGenerationAgentSessionKey,
requesterOrigin: deliveryContext ?? undefined,
workspaceDir,
sandbox,
@@ -254,7 +260,7 @@ export function createOpenClawTools(
config: options?.config,
agentDir: options?.agentDir,
authProfileStore: options?.authProfileStore,
agentSessionKey: options?.agentSessionKey,
agentSessionKey: mediaGenerationAgentSessionKey,
requesterOrigin: deliveryContext ?? undefined,
workspaceDir,
sandbox,

View File

@@ -17,6 +17,9 @@ const mocks = vi.hoisted(() => {
return {
stubTool,
createCronToolOptions: vi.fn(),
createImageGenerateToolOptions: vi.fn(),
createMusicGenerateToolOptions: vi.fn(),
createVideoGenerateToolOptions: vi.fn(),
textToSpeech: vi.fn(async () => ({
success: true,
audioPath: "/tmp/openclaw/tts-config-test.opus",
@@ -50,7 +53,10 @@ vi.mock("./tools/gateway-tool.js", () => ({
}));
vi.mock("./tools/image-generate-tool.js", () => ({
createImageGenerateTool: () => mocks.stubTool("image_generate"),
createImageGenerateTool: (options: unknown) => {
mocks.createImageGenerateToolOptions(options);
return mocks.stubTool("image_generate");
},
}));
vi.mock("./tools/image-tool.js", () => ({
@@ -62,7 +68,10 @@ vi.mock("./tools/message-tool.js", () => ({
}));
vi.mock("./tools/music-generate-tool.js", () => ({
createMusicGenerateTool: () => mocks.stubTool("music_generate"),
createMusicGenerateTool: (options: unknown) => {
mocks.createMusicGenerateToolOptions(options);
return mocks.stubTool("music_generate");
},
}));
vi.mock("./tools/nodes-tool.js", () => ({
@@ -106,7 +115,10 @@ vi.mock("./tools/update-plan-tool.js", () => ({
}));
vi.mock("./tools/video-generate-tool.js", () => ({
createVideoGenerateTool: () => mocks.stubTool("video_generate"),
createVideoGenerateTool: (options: unknown) => {
mocks.createVideoGenerateToolOptions(options);
return mocks.stubTool("video_generate");
},
}));
vi.mock("./tools/web-tools.js", () => ({
@@ -134,6 +146,9 @@ function getTextToSpeechParams() {
describe("createOpenClawTools TTS config wiring", () => {
beforeEach(() => {
mocks.createCronToolOptions.mockClear();
mocks.createImageGenerateToolOptions.mockClear();
mocks.createMusicGenerateToolOptions.mockClear();
mocks.createVideoGenerateToolOptions.mockClear();
mocks.textToSpeech.mockClear();
});
@@ -266,6 +281,74 @@ describe("createOpenClawTools TTS config wiring", () => {
});
});
describe("createOpenClawTools media generation session wiring", () => {
beforeEach(() => {
mocks.createImageGenerateToolOptions.mockClear();
mocks.createMusicGenerateToolOptions.mockClear();
mocks.createVideoGenerateToolOptions.mockClear();
});
it("uses the isolated cron run key for background media completions", () => {
const config = {
agents: {
defaults: {
imageGenerationModel: { primary: "image-owner/model" },
videoGenerationModel: { primary: "video-owner/model" },
musicGenerationModel: { primary: "music-owner/model" },
},
},
} satisfies OpenClawConfig;
createOpenClawTools({
config,
agentSessionKey: "agent:main:cron:daily-media",
runSessionKey: "agent:main:cron:daily-media:run:run-123",
disableMessageTool: true,
disablePluginTools: true,
});
expect(mocks.createImageGenerateToolOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentSessionKey: "agent:main:cron:daily-media:run:run-123",
}),
);
expect(mocks.createVideoGenerateToolOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentSessionKey: "agent:main:cron:daily-media:run:run-123",
}),
);
expect(mocks.createMusicGenerateToolOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentSessionKey: "agent:main:cron:daily-media:run:run-123",
}),
);
});
it("keeps the requester session key for non-cron media completions", () => {
const config = {
agents: {
defaults: {
imageGenerationModel: { primary: "image-owner/model" },
},
},
} satisfies OpenClawConfig;
createOpenClawTools({
config,
agentSessionKey: "agent:main:slack:channel:C123",
runSessionKey: "agent:main:slack:channel:C123:run:run-123",
disableMessageTool: true,
disablePluginTools: true,
});
expect(mocks.createImageGenerateToolOptions).toHaveBeenCalledWith(
expect.objectContaining({
agentSessionKey: "agent:main:slack:channel:C123",
}),
);
});
});
describe("createOpenClawTools cron context wiring", () => {
beforeEach(() => {
mocks.createCronToolOptions.mockClear();

View File

@@ -2385,6 +2385,47 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
);
});
it("no-ops stale isolated cron run media completions", async () => {
const callGateway = createGatewayMock();
const sendMessage = createSendMessageMock();
const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(true);
const result = await deliverSlackChannelAnnouncement({
callGateway,
sendMessage,
queueEmbeddedPiMessageWithOutcome,
sessionId: "stale-cron-run-session",
isActive: false,
requesterSessionKey: "agent:main:cron:daily-media:run:run-123",
expectsCompletionMessage: true,
directIdempotencyKey: "announce-stale-cron-media",
sourceTool: "image_generate",
internalEvents: [
{
type: "task_completion",
source: "image_generation",
childSessionKey: "image_generate:task-123",
childSessionId: "task-123",
announceType: "image generation task",
taskLabel: "daily media",
status: "ok",
statusLabel: "completed successfully",
result: "Generated 1 image.\nMEDIA:/tmp/generated-daily.png",
mediaUrls: ["/tmp/generated-daily.png"],
replyInstruction: "Deliver the generated image through the requester run.",
},
],
});
expectRecordFields(result, {
delivered: true,
path: "none",
phases: [{ phase: "direct-primary", delivered: true, path: "none", error: undefined }],
});
expect(queueEmbeddedPiMessageWithOutcome).not.toHaveBeenCalled();
expect(callGateway).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalled();
});
it.each([
{
name: "legacy Discord channel",

View File

@@ -10,7 +10,7 @@ import {
isAgentMediatedCompletionSourceTool,
shouldPreserveUserFacingSessionStateForInputProvenance,
} from "../sessions/input-provenance.js";
import { isCronSessionKey } from "../sessions/session-key-utils.js";
import { isCronRunSessionKey, isCronSessionKey } from "../sessions/session-key-utils.js";
import { isNonTerminalAgentRunStatus } from "../shared/agent-run-status.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { mergeDeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
@@ -908,6 +908,16 @@ async function sendSubagentAnnounceDirectly(params: {
)}`,
);
}
if (
params.expectsCompletionMessage &&
isCronRunSessionKey(canonicalRequesterSessionKey) &&
!resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive
) {
return {
delivered: true,
path: "none",
};
}
if (params.signal?.aborted) {
return {
delivered: false,