fix: capture cron wake origin session

Capture the originating sessionKey and agentId for cron wake tool calls so non-main session and multi-agent wakes return to the conversation lane that requested them.

Carry stored delivery context through queued wake events so topic/thread replies route correctly, while preserving the default no-origin wake behavior and explicit target:none opt-out.

Refs #46886.
Refs #64556.
Thanks @anagnorisis2peripeteia.

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>
This commit is contained in:
anagnorisis2peripeteia
2026-06-10 20:52:40 +01:00
committed by GitHub
parent e6b0a22f36
commit f1f00cbf1d
24 changed files with 889 additions and 27 deletions
@@ -982,21 +982,25 @@ public struct WakeParams: Codable, Sendable {
public let mode: AnyCodable
public let text: String
public let sessionkey: String?
public let agentid: String?
public init(
mode: AnyCodable,
text: String,
sessionkey: String?)
sessionkey: String?,
agentid: String? = nil)
{
self.mode = mode
self.text = text
self.sessionkey = sessionkey
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case mode
case text
case sessionkey = "sessionKey"
case agentid = "agentId"
}
}
@@ -637,6 +637,35 @@ describe("validateWakeParams", () => {
}),
).toBe(true);
});
it("accepts optional sessionKey and agentId so per-session wakes can be routed", () => {
// Origin-capture fix for #46886 / #64556 — wakes that name an explicit
// session/agent must validate so the gateway handler can forward them
// through to the cron service.
expect(
validateWakeParams({
mode: "now",
text: "follow up on the report",
sessionKey: "agent:main:telegram:8661849123:topic:4052",
agentId: "main",
}),
).toBe(true);
expect(
validateWakeParams({
mode: "next-heartbeat",
text: "tick",
sessionKey: "agent:main:discord:guild123:thread456",
}),
).toBe(true);
});
it("rejects sessionKey or agentId when they are present but empty strings", () => {
// NonEmptyString — caller must omit the field entirely to fall back to
// the default routing. Explicit empties are an error rather than a
// silent no-op.
expect(validateWakeParams({ mode: "now", text: "x", sessionKey: "" })).toBe(false);
expect(validateWakeParams({ mode: "now", text: "x", agentId: "" })).toBe(false);
});
});
describe("validateChatEvent", () => {
@@ -275,6 +275,12 @@ export const WakeParamsSchema = Type.Object(
// Typed field; misspelled variants remain opaque metadata because wake
// senders already rely on additionalProperties.
sessionKey: Type.Optional(NonEmptyString),
/**
* Optional agent id paired with `sessionKey`. Routes multi-agent setups
* to the agent that owns the targeted session — closes the related half
* of #46886 ("always routes to default agent").
*/
agentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: true }, // external wake senders may attach opaque metadata
);
+142
View File
@@ -0,0 +1,142 @@
// Live-proof harness for PR #83738 (cron wake origin capture).
//
// Drives the patched gateway wake handler (validateWakeParams + isSubagentSessionKey
// guard) into the patched cron service wake() (wake.ts) with deps wired to LOG
// every enqueueSystemEvent / requestHeartbeat call. Captures stdout that
// demonstrates a non-main cron wake routing to the originating session/agent
// rather than the heartbeat/main default.
//
// Run: pnpm exec tsx scripts/proof-cron-wake-origin.mts
//
// All identifiers in this script are synthetic. Real Telegram chat ids /
// session keys are not used.
import { cronHandlers } from "../src/gateway/server-methods/cron.js";
import { wake as cronServiceWake } from "../src/cron/service/wake.js";
import type { CronServiceState } from "../src/cron/service/state.js";
type EnqueueArgs = [string, { sessionKey?: string; agentId?: string } | undefined];
type HeartbeatArgs = [
{ source: string; intent: string; reason: string; sessionKey?: string; agentId?: string },
];
const log = (...parts: unknown[]) => {
console.log(...parts);
};
function makeShimmedState(): {
state: CronServiceState;
recorder: { enqueue: EnqueueArgs[]; heartbeat: HeartbeatArgs[] };
} {
const recorder = { enqueue: [] as EnqueueArgs[], heartbeat: [] as HeartbeatArgs[] };
const state = {
deps: {
enqueueSystemEvent: (...args: EnqueueArgs) => {
recorder.enqueue.push(args);
const [text, opts] = args;
log(
`[gateway/cron] enqueueSystemEvent text=${JSON.stringify(text)} opts=${JSON.stringify(opts)}`,
);
},
requestHeartbeat: (...args: HeartbeatArgs) => {
recorder.heartbeat.push(args);
log(`[gateway/heartbeat] requestHeartbeat ${JSON.stringify(args[0])}`);
},
},
} as unknown as CronServiceState;
return { state, recorder };
}
type ScenarioResult = { ok: boolean; payload?: unknown; error?: unknown };
async function drive(label: string, params: unknown): Promise<ScenarioResult> {
log("");
log(`=== ${label} ===`);
log(`> wake params: ${JSON.stringify(params)}`);
const { state } = makeShimmedState();
let response: ScenarioResult = { ok: false };
const respond = (ok: boolean, payload: unknown, error: unknown) => {
response = { ok, payload, error };
};
const context = {
cron: {
wake: (
opts: {
mode: "now" | "next-heartbeat";
text: string;
sessionKey?: string;
agentId?: string;
},
) => cronServiceWake(state, opts),
},
} as unknown as Parameters<typeof cronHandlers.wake>[0]["context"];
// cronHandlers.wake is sync (calls respond synchronously) but typed as
// returning void; await on a Promise wrapper to flush console.log ordering.
await Promise.resolve(
cronHandlers.wake({
params,
respond,
context,
request: {} as never,
requestId: 1 as never,
logger: undefined as never,
} as never),
);
log(`< wake result: ok=${response.ok} payload=${JSON.stringify(response.payload)}`);
if (response.error) {
log(`< wake error: ${JSON.stringify(response.error)}`);
}
return response;
}
async function main() {
log("=== PR #83738 cron wake origin-capture: live-proof harness ===");
log("Driving the patched gateway wake handler through cron.wake() with");
log("logging deps. All ids below are synthetic.");
// Scenario 1: real-world bug-reproduction case — a wake fired from inside
// a non-main Telegram topic session for a non-default agent.
await drive("non-main session + non-default agent (the bug-fix case)", {
mode: "now",
text: "follow up on report",
sessionKey: "agent:coding:telegram:<chat-id-redacted>:topic:<topic-id-redacted>",
agentId: "coding",
});
// Scenario 2: backwards-compatible — no origin → default routing.
await drive("no origin (backwards-compatible default routing)", {
mode: "now",
text: "ping",
});
// Scenario 3: next-heartbeat + sessionKey collapses to a targeted-immediate
// heartbeat because the regularly-scheduled heartbeat fires for the
// agent's main session, never peeking the targeted lane's queue.
await drive("next-heartbeat + sessionKey collapses to targeted-immediate", {
mode: "next-heartbeat",
text: "check the queue",
sessionKey: "agent:coding:discord:<thread-redacted>",
agentId: "coding",
});
// Scenario 4: subagent sessionKey rejected at the gateway handler.
await drive("subagent sessionKey rejected by gateway handler guard", {
mode: "now",
text: "wake my subagent",
sessionKey: "subagent:scratch:<id-redacted>",
});
// Scenario 5: whitespace-only origin falls through to default routing.
await drive("whitespace-only origin falls through (defence-in-depth)", {
mode: "now",
text: "x",
sessionKey: " ",
agentId: "\t",
});
log("");
log("=== Done. ===");
}
void main();
+142
View File
@@ -447,6 +447,148 @@ describe("cron tool", () => {
expect(params).toEqual({ includeDisabled: true, agentId: "ops" });
});
describe("wake routing", () => {
// Pin the agentId / sessionKey resolution contract for `action: "wake"`.
// The gateway target resolver treats `agentId` as authoritative, so
// pairing the caller's inferred agentId with a foreign explicit
// sessionKey would canonicalize the wake back to the caller agent's
// main lane.
it("infers sessionKey + agentId from the calling agent's session when neither is supplied", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-default", { action: "wake", text: "ping" });
const params = expectSingleGatewayCallMethod("wake");
expect(params).toEqual({
mode: "next-heartbeat",
text: "ping",
sessionKey: "agent:agent-123:telegram:direct:channing",
agentId: "agent-123",
});
});
it("derives agentId from an explicit cross-agent sessionKey instead of the caller's agentId", async () => {
// A caller in agent-123 explicitly waking an agent-456 session must
// NOT have agent-123's agentId paired with agent-456's sessionKey —
// that would canonicalize back to agent-123's main lane on the
// gateway side.
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-cross-agent", {
action: "wake",
text: "follow up",
sessionKey: "agent:agent-456:discord:thread-xyz",
});
const params = expectSingleGatewayCallMethod("wake");
expect(params).toEqual({
mode: "next-heartbeat",
text: "follow up",
sessionKey: "agent:agent-456:discord:thread-xyz",
agentId: "agent-456",
});
});
it("rejects a contradictory explicit agentId + agent-prefixed sessionKey pair", async () => {
// The gateway target resolver treats agentId as authoritative, so a
// contradictory pair would silently canonicalize the wake onto a session
// the caller never named. The tool rejects instead of guessing.
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(
tool.execute("call-wake-explicit-pair", {
action: "wake",
text: "manual",
sessionKey: "agent:agent-456:discord:thread-xyz",
agentId: "ops",
}),
).rejects.toThrow(/contradicts/);
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("accepts an explicit agentId that matches the agent owning the explicit sessionKey", async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-matching-pair", {
action: "wake",
text: "manual",
sessionKey: "agent:agent-456:discord:thread-xyz",
agentId: "agent-456",
});
const params = expectSingleGatewayCallMethod("wake");
expect(params).toEqual({
mode: "next-heartbeat",
text: "manual",
sessionKey: "agent:agent-456:discord:thread-xyz",
agentId: "agent-456",
});
});
it("omits agentId when explicit sessionKey is not in agent:<id>:* form and no explicit agentId is given", async () => {
// Defence-in-depth: if the explicit sessionKey can't be parsed for an
// agentId, we'd rather omit it (gateway falls back to default routing
// for that session) than incorrectly attach the caller's agentId.
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-unparseable", {
action: "wake",
text: "x",
sessionKey: "subagent:weird:format",
});
const params = expectSingleGatewayCallMethod("wake");
expect(params).toEqual({
mode: "next-heartbeat",
text: "x",
sessionKey: "subagent:weird:format",
// No agentId — explicit sessionKey wasn't parseable + no explicit
// override, so we deliberately drop agentId rather than inherit
// the caller's.
});
});
it("requires text for action wake", async () => {
// Mutation-test survivor: `required: true` -> false silently sent an
// undefined-text wake. Pin the guard.
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await expect(tool.execute("call-wake-no-text", { action: "wake" })).rejects.toThrow();
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("sends a bare wake when no calling-session context exists", async () => {
// Mutation-test survivor: `opts?.agentSessionKey` -> `opts.agentSessionKey`
// crashed context-less callers. A tool created without session context
// must fall through to default routing, not throw.
const tool = createTestCronTool();
await tool.execute("call-wake-no-context", { action: "wake", text: "ping" });
const params = expectSingleGatewayCallMethod("wake");
expect(params).toEqual({ mode: "next-heartbeat", text: "ping" });
});
it('honours an explicit mode: "next-heartbeat"', async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-nh", { action: "wake", text: "tick", mode: "next-heartbeat" });
const params = expectSingleGatewayCallMethod("wake");
expect(params).toMatchObject({ mode: "next-heartbeat", text: "tick" });
});
it('threads mode: "now" through unchanged', async () => {
const tool = createTestCronTool({
agentSessionKey: "agent:agent-123:telegram:direct:channing",
});
await tool.execute("call-wake-now", { action: "wake", text: "ping", mode: "now" });
const params = expectSingleGatewayCallMethod("wake");
expect(params).toMatchObject({ mode: "now", text: "ping" });
});
});
it("documents deferred follow-up guidance in the tool description", () => {
const tool = createTestCronTool();
expect(tool.description).toContain(
+75 -3
View File
@@ -11,6 +11,7 @@ import { assertCronDeliveryInputNonBlankFields } from "../../cron/delivery-targe
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import type { CronDelivery } from "../../cron/types.js";
import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import { extractTextFromChatContent } from "../../shared/chat-content.js";
import { isRecord, truncateUtf16Safe } from "../../utils.js";
import type { DeliveryContext } from "../../utils/delivery-context.shared.js";
@@ -308,7 +309,18 @@ export function createCronToolSchema(): TSchema {
contextMessages: Type.Optional(
Type.Integer({ minimum: 0, maximum: REMINDER_CONTEXT_MESSAGES_MAX }),
),
agentId: Type.Optional(Type.String({ description: "List filter: agent id" })),
agentId: Type.Optional(
Type.String({
description:
'List filter for `action: "list"`; wake target override for `action: "wake"` (defaults to the calling agent when omitted on wake)',
}),
),
sessionKey: Type.Optional(
Type.String({
description:
'Wake target override for `action: "wake"`: route the event to the named session rather than the calling agent\'s current session. Defaults to the resolved calling-session key when omitted.',
}),
),
},
{ additionalProperties: true },
);
@@ -525,7 +537,7 @@ ACTIONS:
- remove: delete job; needs jobId
- run: trigger now; needs jobId
- runs: run history; needs jobId
- wake: send wake event; needs text, optional mode
- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane.
JOB SCHEMA (for add action):
{
@@ -831,8 +843,68 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me
params.mode === "now" || params.mode === "next-heartbeat"
? params.mode
: "next-heartbeat";
// Resolve the calling agent's session key into the internal form
// the cron service routes by (mirrors the `add` action above).
// Without this, the wake gateway call goes through with no session
// key and the system event lands on the heartbeat / main default
// rather than the originating conversation lane. Closes the
// upstream half of openclaw/openclaw#46886 (#64556 — agentId/
// sessionKey silently ignored for `action: "wake"`). Explicit
// params on the tool call still take precedence over the inferred
// value, so call sites that want to wake a different session can
// pass `sessionKey` / `agentId` directly.
const cfg = getRuntimeConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const explicitSessionKey = readStringParam(params, "sessionKey");
const explicitAgentId = readStringParam(params, "agentId");
const inferredSessionKey = opts?.agentSessionKey
? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey })
: undefined;
const inferredAgentId = opts?.agentSessionKey
? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg })
: undefined;
const sessionKey = explicitSessionKey ?? inferredSessionKey;
// When a caller supplies an explicit cross-agent sessionKey without
// an explicit agentId, the gateway target resolver treats agentId as
// authoritative — pairing the caller's inferred agentId with a
// foreign session key would canonicalize the wake back to the
// caller's main lane. Derive the agentId from the explicit canonical
// session key instead; only fall through to the inferred
// caller-agent when no explicit sessionKey was supplied.
const agentIdFromExplicitSessionKey = explicitSessionKey
? parseAgentSessionKey(explicitSessionKey)?.agentId
: undefined;
// A contradictory explicit pair (agentId X + a sessionKey owned by
// agent Y) is ambiguous: the gateway target resolver treats agentId
// as authoritative and would silently canonicalize the wake onto a
// session under X that the caller never named. Reject instead of
// guessing one canonical owner.
if (
explicitAgentId &&
agentIdFromExplicitSessionKey &&
normalizeLowercaseStringOrEmpty(explicitAgentId) !==
normalizeLowercaseStringOrEmpty(agentIdFromExplicitSessionKey)
) {
throw new Error(
`wake agentId "${explicitAgentId}" contradicts the agent that owns sessionKey ` +
`("${agentIdFromExplicitSessionKey}"); pass a single canonical wake target`,
);
}
const agentId =
explicitAgentId ??
(explicitSessionKey ? agentIdFromExplicitSessionKey : inferredAgentId);
return jsonResult(
await callGateway("wake", gatewayOpts, { mode, text }, { expectFinal: false }),
await callGateway(
"wake",
gatewayOpts,
{
mode,
text,
...(sessionKey ? { sessionKey } : {}),
...(agentId ? { agentId } : {}),
},
{ expectFinal: false },
),
);
}
default:
+6 -1
View File
@@ -34,5 +34,10 @@ export interface CronServiceContract {
getJob(id: string): CronJob | undefined;
readJob(id: string): Promise<CronJob | undefined>;
getDefaultAgentId(): string | undefined;
wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string }): CronWakeResult;
wake(opts: {
mode: CronWakeMode;
text: string;
sessionKey?: string;
agentId?: string;
}): CronWakeResult;
}
+1 -1
View File
@@ -77,7 +77,7 @@ export class CronService implements CronServiceContract {
return this.state.deps.defaultAgentId;
}
wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string }) {
wake(opts: { mode: CronWakeMode; text: string; sessionKey?: string; agentId?: string }) {
return ops.wakeNow(this.state, opts);
}
}
+1 -1
View File
@@ -962,7 +962,7 @@ export async function enqueueRun(state: CronServiceState, id: string, mode?: "du
/** Enqueues manual wake text through the cron wake API. */
export function wakeNow(
state: CronServiceState,
opts: { mode: CronWakeMode; text: string; sessionKey?: string },
opts: { mode: CronWakeMode; text: string; sessionKey?: string; agentId?: string },
) {
return wake(state, opts);
}
+11
View File
@@ -91,6 +91,17 @@ export type CronServiceDeps = {
deliveryContext?: DeliveryContext;
},
) => void;
/**
* Resolve the channel-correct origin delivery context for a session key (the
* value the channel's send expects, e.g. Telegram message_thread_id), sourced
* from the session store entry the wake targets. Used to carry the bound
* thread/topic onto manual wake system events. Optional: when unset, wakes
* route as before. Returning `undefined` is also a no-op (default routing).
*/
resolveOriginDeliveryContext?: (params: {
sessionKey?: string;
agentId?: string;
}) => DeliveryContext | undefined;
requestHeartbeat: (opts: HeartbeatWakeRequest) => void;
runHeartbeatOnce?: (opts?: {
source?: HeartbeatWakeRequest["source"];
@@ -0,0 +1,151 @@
// Covers the "capture origin delivery context, carry it to the wake event"
// half of the cron wake origin fix: a sessionKey-targeted wake() must thread
// the bound channel thread/topic (e.g. Telegram topic 4052) onto the enqueued
// system event's deliveryContext so the delivered heartbeat routes back into
// the originating thread instead of the chat root.
//
// The channel-correct threadId is sourced via the resolveOriginDeliveryContext
// dep (implemented in server-cron from the session store), NOT by splitting the
// composite session-key thread suffix. The tests mock that dep so they exercise
// only wake()'s carry behavior. Scheduled main-session cron jobs resolve their
// delivery context natively in timer.ts (resolveMainSessionCronDeliveryContext)
// and are covered there.
import { describe, expect, it, vi } from "vitest";
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { CronServiceState } from "./state.js";
import { wake } from "./wake.js";
const TOPIC_DELIVERY_CONTEXT: DeliveryContext = {
channel: "telegram",
to: "telegram:8661849123:topic:4052",
accountId: "default",
threadId: "4052",
};
function makeStateWithMocks(
resolveOriginDeliveryContext?: (params: {
sessionKey?: string;
agentId?: string;
}) => DeliveryContext | undefined,
): {
state: CronServiceState;
enqueueSystemEvent: ReturnType<typeof vi.fn>;
requestHeartbeat: ReturnType<typeof vi.fn>;
resolveOriginDeliveryContext: ReturnType<typeof vi.fn>;
} {
const enqueueSystemEvent = vi.fn();
const requestHeartbeat = vi.fn();
const resolveOrigin = vi.fn(resolveOriginDeliveryContext ?? (() => undefined));
const state = {
deps: {
enqueueSystemEvent,
requestHeartbeat,
resolveOriginDeliveryContext: resolveOrigin,
},
} as unknown as CronServiceState;
return {
state,
enqueueSystemEvent,
requestHeartbeat,
resolveOriginDeliveryContext: resolveOrigin,
};
}
describe("cron wake() origin delivery-context carry", () => {
it("threads the resolved deliveryContext onto a sessionKey-targeted wake", () => {
const { state, enqueueSystemEvent, resolveOriginDeliveryContext } = makeStateWithMocks(
() => TOPIC_DELIVERY_CONTEXT,
);
const result = wake(state, {
mode: "now",
text: "check the queue",
sessionKey: "agent:main:telegram:8661849123:topic:4052",
agentId: "main",
});
expect(result).toEqual({ ok: true });
expect(resolveOriginDeliveryContext).toHaveBeenCalledWith({
sessionKey: "agent:main:telegram:8661849123:topic:4052",
agentId: "main",
});
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("check the queue", {
sessionKey: "agent:main:telegram:8661849123:topic:4052",
agentId: "main",
deliveryContext: TOPIC_DELIVERY_CONTEXT,
});
});
it("resolves and carries deliveryContext for a sessionKey-only wake (no agentId)", () => {
// Caught by mutation testing: `sessionKey || agentId` -> `&&` in the
// resolver guard survived because every resolver-wired test passed both
// fields. A sessionKey-only wake (the common tool-path shape for
// single-agent setups) must still consult the resolver and carry the
// stored topic/thread context.
const { state, enqueueSystemEvent, resolveOriginDeliveryContext } = makeStateWithMocks(
() => TOPIC_DELIVERY_CONTEXT,
);
wake(state, {
mode: "now",
text: "check the queue",
sessionKey: "agent:main:telegram:8661849123:topic:4052",
});
expect(resolveOriginDeliveryContext).toHaveBeenCalledExactlyOnceWith({
sessionKey: "agent:main:telegram:8661849123:topic:4052",
agentId: undefined,
});
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("check the queue", {
sessionKey: "agent:main:telegram:8661849123:topic:4052",
deliveryContext: TOPIC_DELIVERY_CONTEXT,
});
});
it("omits deliveryContext when no origin context resolves (unchanged default routing)", () => {
const { state, enqueueSystemEvent } = makeStateWithMocks(() => undefined);
wake(state, {
mode: "now",
text: "check the queue",
sessionKey: "agent:main:telegram:8661849123:topic:4052",
});
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("check the queue", {
sessionKey: "agent:main:telegram:8661849123:topic:4052",
});
const [, options] = enqueueSystemEvent.mock.calls[0] as [string, Record<string, unknown>];
expect(options).not.toHaveProperty("deliveryContext");
});
it("works when no resolveOriginDeliveryContext dep is wired (legacy deps)", () => {
const { state, enqueueSystemEvent } = makeStateWithMocks();
// Drop the dep entirely to mirror a deployment whose adapter predates the fix.
(state.deps as { resolveOriginDeliveryContext?: unknown }).resolveOriginDeliveryContext =
undefined;
const result = wake(state, {
mode: "now",
text: "check the queue",
sessionKey: "agent:main:telegram:8661849123:topic:4052",
});
expect(result).toEqual({ ok: true });
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("check the queue", {
sessionKey: "agent:main:telegram:8661849123:topic:4052",
});
});
it("keeps the no-origin call shape (enqueueSystemEvent(text, undefined)) when untargeted", () => {
const { state, enqueueSystemEvent, resolveOriginDeliveryContext } = makeStateWithMocks(
() => TOPIC_DELIVERY_CONTEXT,
);
wake(state, { mode: "now", text: "no origin" });
// Untargeted wakes must not even consult the resolver, preserving the exact
// pre-fix default-sessionKey binding behavior.
expect(resolveOriginDeliveryContext).not.toHaveBeenCalled();
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("no origin", undefined);
});
});
+118
View File
@@ -0,0 +1,118 @@
// Regression coverage for cron wake origin capture (openclaw/openclaw#46886,
// #64556): wake must thread sessionKey + agentId through to enqueueSystemEvent
// and the heartbeat request so multi-agent / non-main-session wakes land on the
// originating conversation lane. Base sessionKey threading and the no-origin
// default shape are covered by wake.test.ts; these tests pin the agentId half.
import { describe, expect, it, vi } from "vitest";
import type { CronServiceState } from "./state.js";
import { wake } from "./wake.js";
// Minimal CronServiceState shim — `wake` only touches `state.deps` so the
// other state fields aren't relevant. Cast through `unknown` to avoid
// pulling in the full state factory just to exercise two callbacks.
function makeStateWithMocks(): {
state: CronServiceState;
enqueueSystemEvent: ReturnType<typeof vi.fn>;
requestHeartbeat: ReturnType<typeof vi.fn>;
} {
const enqueueSystemEvent = vi.fn();
const requestHeartbeat = vi.fn();
const state = {
deps: { enqueueSystemEvent, requestHeartbeat },
} as unknown as CronServiceState;
return { state, enqueueSystemEvent, requestHeartbeat };
}
describe("cron service wake() origin capture", () => {
it("forwards sessionKey + agentId to enqueueSystemEvent so the event lands on the originating session", () => {
// Prior to this change the wake function forwarded only sessionKey, so
// multi-agent setups routed every wake to the default agent regardless
// of which agent owned the originating session.
const { state, enqueueSystemEvent, requestHeartbeat } = makeStateWithMocks();
const result = wake(state, {
mode: "now",
text: "follow up on the report",
sessionKey: "agent:main:telegram:8661849123:topic:4052",
agentId: "main",
});
expect(result).toEqual({ ok: true });
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("follow up on the report", {
sessionKey: "agent:main:telegram:8661849123:topic:4052",
agentId: "main",
});
expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({
source: "manual",
intent: "immediate",
reason: "wake",
sessionKey: "agent:main:telegram:8661849123:topic:4052",
agentId: "main",
});
});
it("threads sessionKey + agentId into the targeted-immediate heartbeat for next-heartbeat+sessionKey too", () => {
// wake() collapses --mode now and --mode next-heartbeat into the same
// targeted-immediate behavior when sessionKey is present — the regularly
// scheduled heartbeat fires for the agent's main session, so a non-main
// wake needs an explicit targeted nudge to peek the session's queue.
// agentId must thread through that nudge too.
const { state, enqueueSystemEvent, requestHeartbeat } = makeStateWithMocks();
const result = wake(state, {
mode: "next-heartbeat",
text: "check the queue",
sessionKey: "agent:coding:discord:thread123",
agentId: "coding",
});
expect(result).toEqual({ ok: true });
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("check the queue", {
sessionKey: "agent:coding:discord:thread123",
agentId: "coding",
});
expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({
source: "manual",
intent: "immediate",
reason: "wake",
sessionKey: "agent:coding:discord:thread123",
agentId: "coding",
});
});
it("forwards an agentId-only wake so the event reaches that agent's default lane", () => {
// Caught by mutation testing: `sessionKey || agentId` -> `&&` survived
// because no test exercised agentId without sessionKey. An agentId-only
// wake must still build enqueue opts (the gateway resolves the agent's
// default session from agentId) rather than fall back to the global
// default lane.
const { state, enqueueSystemEvent, requestHeartbeat } = makeStateWithMocks();
const result = wake(state, { mode: "now", text: "agent only", agentId: "ops" });
expect(result).toEqual({ ok: true });
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("agent only", {
agentId: "ops",
});
expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({
source: "manual",
intent: "immediate",
reason: "wake",
agentId: "ops",
});
});
it("drops whitespace-only sessionKey / agentId rather than routing to a meaningless lane", () => {
// Defence-in-depth: gateway handler already trims, but the wake function
// is also reachable directly by other in-process call sites. Empty /
// whitespace fields must fall through to default routing, not route
// the event to a session named " " (which would silently drop it).
const { state, enqueueSystemEvent, requestHeartbeat } = makeStateWithMocks();
wake(state, {
mode: "now",
text: "x",
sessionKey: " ",
agentId: "\t",
});
expect(enqueueSystemEvent).toHaveBeenCalledExactlyOnceWith("x", undefined);
expect(requestHeartbeat).toHaveBeenCalledExactlyOnceWith({
source: "manual",
intent: "immediate",
reason: "wake",
});
});
});
+41 -2
View File
@@ -5,23 +5,61 @@ import type { CronServiceState } from "./state.js";
/** Enqueues a manual cron wake event and optionally pokes the targeted heartbeat loop. */
export function wake(
state: CronServiceState,
opts: { mode: "now" | "next-heartbeat"; text: string; sessionKey?: string },
opts: {
mode: "now" | "next-heartbeat";
text: string;
/**
* Internal session key to enqueue the system event against. When omitted,
* the dep's default (heartbeat / main) is used — wakes from a non-main
* session would otherwise route to the wrong place. Callers wiring an
* agent-tool `wake` should thread the resolved session key (e.g. from
* `cron-tool`'s `resolveInternalSessionKey`) so the event lands on the
* originating conversation lane.
*/
sessionKey?: string;
/**
* Agent id paired with `sessionKey`. Forwarded to `enqueueSystemEvent`
* and the heartbeat request so multi-agent setups route to the agent
* that owns the targeted session — fixes the related half of #46886
* ("always routes to default agent").
*/
agentId?: string;
},
) {
const text = opts.text.trim();
if (!text) {
return { ok: false } as const;
}
const sessionKey = opts.sessionKey?.trim() || undefined;
const agentId = opts.agentId?.trim() || undefined;
if (sessionKey && isSubagentSessionKey(sessionKey)) {
return { ok: false, reason: "unwakeable-session-key" } as const;
}
state.deps.enqueueSystemEvent(text, sessionKey ? { sessionKey } : undefined);
// Carry the originating session's channel-correct delivery context (e.g. the
// bound Telegram topic/thread) so a wake routes back into that thread instead
// of the chat root. Only attempt this when an origin session is targeted; a
// no-origin wake keeps the exact pre-fix `enqueueSystemEvent(text, undefined)`
// shape so its default-sessionKey binding still kicks in.
const originDeliveryContext =
sessionKey || agentId
? state.deps.resolveOriginDeliveryContext?.({ sessionKey, agentId })
: undefined;
const enqueueOpts =
sessionKey || agentId
? {
...(sessionKey ? { sessionKey } : {}),
...(agentId ? { agentId } : {}),
...(originDeliveryContext ? { deliveryContext: originDeliveryContext } : {}),
}
: undefined;
state.deps.enqueueSystemEvent(text, enqueueOpts);
if (opts.mode === "now") {
state.deps.requestHeartbeat({
source: "manual",
intent: "immediate",
reason: "wake",
...(sessionKey ? { sessionKey } : {}),
...(agentId ? { agentId } : {}),
});
} else if (sessionKey) {
// next-heartbeat + sessionKey still needs a targeted immediate wake.
@@ -41,6 +79,7 @@ export function wake(
intent: "immediate",
reason: "wake",
sessionKey,
...(agentId ? { agentId } : {}),
});
}
return { ok: true } as const;
+14
View File
@@ -16,6 +16,7 @@ import { resolveStorePath } from "../config/sessions/paths.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { runCronCommandJob } from "../cron/command-runner.js";
import { resolveCronStoredDeliveryContext } from "../cron/delivery-context.js";
import { resolveCronDeliveryPlan, sendCronAnnouncePayloadStrict } from "../cron/delivery.js";
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
import { appendCronRunLog, resolveCronRunLogPruneOptions } from "../cron/run-log.js";
@@ -328,6 +329,19 @@ export function buildGatewayCronService(params: {
deliveryContext: opts?.deliveryContext,
});
},
resolveOriginDeliveryContext: (opts) => {
// Resolve the wake target the same way the enqueue/heartbeat deps do,
// then read the channel-correct delivery context from that session's
// store entry (NOT by string-splitting the composite session key).
const { runtimeConfig, sessionKey } = resolveCronTarget({
...opts,
preserveUntargeted: true,
});
if (!sessionKey) {
return undefined;
}
return resolveCronStoredDeliveryContext({ cfg: runtimeConfig, sessionKey });
},
requestHeartbeat: (opts) => {
const { agentId, sessionKey } = resolveCronTarget({ ...opts, preserveUntargeted: true });
requestHeartbeat({
+28
View File
@@ -33,6 +33,7 @@ import {
} from "../../infra/outbound/channel-target-prefix.js";
import { listConfiguredAnnounceChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
@@ -255,12 +256,19 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
// Caller-supplied sessionKey / agentId thread through to `cron.wake` so
// multi-session deployments wake the originating conversation lane
// instead of the heartbeat / main default. Empty strings are dropped
// (schema permits omission; presence with empty payload should not
// override the default).
const p = params as {
mode: "now" | "next-heartbeat";
text: string;
sessionKey?: string;
agentId?: string;
};
const sessionKey = p.sessionKey?.trim() || undefined;
const agentId = p.agentId?.trim() || undefined;
if (sessionKey && isSubagentSessionKey(sessionKey)) {
// Wake requests resume user-visible sessions only; subagent sessions are
// internal task execution targets and should not receive operator wakes.
@@ -271,10 +279,30 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
// Mirror the cron tool's contradictory-pair guard for direct RPC callers
// and generated clients: the cron target resolver treats agentId as
// authoritative, so an agentId that disagrees with the agent owning an
// agent-prefixed sessionKey would silently wake a lane the caller never
// named. Reject instead of guessing a canonical owner.
const sessionKeyAgentId = sessionKey
? parseAgentSessionKey(sessionKey)?.agentId?.trim().toLowerCase()
: undefined;
if (agentId && sessionKeyAgentId && agentId.toLowerCase() !== sessionKeyAgentId) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"wake agentId contradicts the agent that owns sessionKey; pass a single canonical wake target",
),
);
return;
}
const result = context.cron.wake({
mode: p.mode,
text: p.text,
...(sessionKey ? { sessionKey } : {}),
...(agentId ? { agentId } : {}),
});
respond(true, result, undefined);
},
@@ -1033,6 +1033,34 @@ describe("cron method validation", () => {
expectResponseError(respond, { code: "INVALID_REQUEST", messageIncludes: "sessionKey" });
});
it("rejects a contradictory explicit agentId + agent-prefixed sessionKey pair", async () => {
// The cron target resolver treats agentId as authoritative; a
// contradictory pair would silently wake a lane the caller never named.
const { context, respond } = await invokeWake({
mode: "now",
text: "ping",
sessionKey: "agent:agent-456:discord:thread-xyz",
agentId: "ops",
});
expect(context.cron.wake).not.toHaveBeenCalled();
expectResponseError(respond, { code: "INVALID_REQUEST", messageIncludes: "contradicts" });
});
it("accepts an explicit agentId matching the agent that owns the sessionKey", async () => {
const { context } = await invokeWake({
mode: "now",
text: "ping",
sessionKey: "agent:agent-456:discord:thread-xyz",
agentId: "agent-456",
});
expect(context.cron.wake).toHaveBeenCalledWith({
mode: "now",
text: "ping",
sessionKey: "agent:agent-456:discord:thread-xyz",
agentId: "agent-456",
});
});
it("treats whitespace-only sessionKey as omitted at the handler boundary", async () => {
const { context, respond } = await invokeWake({
mode: "now",
+49
View File
@@ -493,6 +493,55 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("room:ops:topic:1008013");
});
it("delivers an origin-carrying event when no heartbeat target is configured", () => {
// A wake/cron event that explicitly carried its origin delivery context
// names its own destination; the reply must not be dropped just because
// the deployment never configured agents.defaults.heartbeat.
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
sessionId: "sess-origin-no-config",
updatedAt: 1,
lastChannel: "alpha",
lastTo: "chat:one",
},
turnSource: { channel: "alpha", to: "chat:one", threadId: "77" },
});
expect(resolved.channel).toBe("alpha");
expect(resolved.to).toBe("chat:one");
expect(resolved.threadId).toBe("77");
});
it("keeps an explicit target:none suppressing origin-carrying events", () => {
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
sessionId: "sess-origin-target-none",
updatedAt: 1,
lastChannel: "alpha",
lastTo: "chat:one",
},
heartbeat: { target: "none" },
turnSource: { channel: "alpha", to: "chat:one" },
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("target-none");
});
it("stays suppressed with unset heartbeat config and no origin turn source", () => {
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
sessionId: "sess-no-config-no-origin",
updatedAt: 1,
lastChannel: "alpha",
lastTo: "chat:one",
},
});
expect(resolved.channel).toBe("none");
expect(resolved.reason).toBe("target-none");
});
const resolveHeartbeatTarget = (entry: SessionEntry, directPolicy?: "allow" | "block") =>
resolveHeartbeatDeliveryTarget({
cfg: {},
+12
View File
@@ -111,6 +111,18 @@ export function resolveHeartbeatDeliveryTarget(params: {
if (normalized) {
target = normalized;
}
} else if (
rawTarget === undefined &&
params.turnSource?.to &&
params.turnSource.channel &&
isDeliverableMessageChannel(params.turnSource.channel)
) {
// No heartbeat target configured, but this run drains an event that
// explicitly carried its origin delivery context (e.g. a cron wake from a
// channel thread/topic). The event named its destination, so deliver to it
// instead of silently dropping the reply. An explicit `target: "none"`
// still suppresses delivery (operator opt-out above takes precedence).
target = "last";
}
if (target === "none") {
@@ -150,7 +150,7 @@
},
{
"deferLoading": true,
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -159,7 +159,7 @@
"type": "string"
},
"agentId": {
"description": "List filter: agent id",
"description": "List filter for `action: \"list\"`; wake target override for `action: \"wake\"` (defaults to the calling agent when omitted on wake)",
"type": "string"
},
"contextMessages": {
@@ -745,6 +745,10 @@
"enum": ["due", "force"],
"type": "string"
},
"sessionKey": {
"description": "Wake target override for `action: \"wake\"`: route the event to the named session rather than the calling agent's current session. Defaults to the resolved calling-session key when omitted.",
"type": "string"
},
"text": {
"type": "string"
},
@@ -150,7 +150,7 @@
},
{
"deferLoading": true,
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -159,7 +159,7 @@
"type": "string"
},
"agentId": {
"description": "List filter: agent id",
"description": "List filter for `action: \"list\"`; wake target override for `action: \"wake\"` (defaults to the calling agent when omitted on wake)",
"type": "string"
},
"contextMessages": {
@@ -745,6 +745,10 @@
"enum": ["due", "force"],
"type": "string"
},
"sessionKey": {
"description": "Wake target override for `action: \"wake\"`: route the event to the named session rather than the calling agent's current session. Defaults to the resolved calling-session key when omitted.",
"type": "string"
},
"text": {
"type": "string"
},
@@ -150,7 +150,7 @@
},
{
"deferLoading": true,
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"description": "Manage Gateway cron jobs and wake events: reminders, check-back-later, delayed follow-ups, recurring work. Do not emulate scheduling with exec sleep/process polling.\n\nMain cron => system events for heartbeat. Isolated cron => background task in `openclaw tasks`.\n\nACTIONS:\n- status: scheduler status\n- list: jobs; includeDisabled true includes disabled; agentId filter auto-filled from session\n- get: one job; needs jobId\n- add: create job; needs job object\n- update: patch job; needs jobId + patch\n- remove: delete job; needs jobId\n- run: trigger now; needs jobId\n- runs: run history; needs jobId\n- wake: send wake event; needs text, optional mode; defaults the target to the calling session/agent. Pass top-level sessionKey/agentId to wake a different lane.\n\nJOB SCHEMA (for add action):\n{\n \"name\": \"string\",\n \"schedule\": { ... }, // required\n \"payload\": { ... }, // required\n \"delivery\": { ... }, // optional announce for isolated/current/session, webhook for any target\n \"sessionTarget\": \"main\" | \"isolated\" | \"current\" | \"session:<id>\",\n \"enabled\": true | false // default true\n}\n\nSESSION TARGET OPTIONS:\n- \"main\": main session; requires payload.kind=\"systemEvent\"\n- \"isolated\": ephemeral isolated session; requires payload.kind=\"agentTurn\"\n- \"current\": bind current session at creation\n- \"session:<id>\": persistent named session\n\nDEFAULTS:\n- payload.kind=\"systemEvent\" → defaults to \"main\"\n- payload.kind=\"agentTurn\" → defaults to \"isolated\"\nCurrent binding needs sessionTarget=\"current\".\n\nSCHEDULE TYPES (schedule.kind):\n- \"at\": one-shot absolute time\n { \"kind\": \"at\", \"at\": \"<ISO-8601 timestamp>\" }\n- \"every\": recurring interval\n { \"kind\": \"every\", \"everyMs\": <ms>, \"anchorMs\": <optional-ms> }\n- \"cron\": expr in supplied timezone, or Gateway host local timezone when tz omitted\n { \"kind\": \"cron\", \"expr\": \"<cron-expression>\", \"tz\": \"<optional-IANA-timezone>\" }\n Write expr in local wall-clock time; do not convert the requested local time to UTC first.\n tz omitted => Gateway host local timezone, not UTC.\n Example 6pm Shanghai daily: { \"kind\": \"cron\", \"expr\": \"0 18 * * *\", \"tz\": \"Asia/Shanghai\" }\n\nFor \"at\", ISO timestamps without timezone are UTC.\n\nPAYLOAD TYPES (payload.kind):\n- \"systemEvent\": inject text as system event\n { \"kind\": \"systemEvent\", \"text\": \"<message>\" }\n- \"agentTurn\": run agent with prompt; isolated/current/session only\n { \"kind\": \"agentTurn\", \"message\": \"<prompt>\", \"model\": \"<optional>\", \"thinking\": \"<optional>\", \"timeoutSeconds\": <optional, 0=no timeout> }\n\nDELIVERY (top-level):\n { \"mode\": \"none|announce|webhook\", \"channel\": \"<optional>\", \"to\": \"<optional>\", \"threadId\": \"<optional>\", \"bestEffort\": <optional-bool> }\n - isolated agentTurn default when omitted: \"announce\"\n - announce: send to chat channel; isolated/current/session only; optional channel/to\n - threadId: chat thread/topic id\n - webhook: POST finished-run event to delivery.to URL\n - Specific chat/recipient: set announce delivery.channel/to; do not call messaging tools inside run.\n\nCRITICAL CONSTRAINTS:\n- sessionTarget=\"main\" REQUIRES payload.kind=\"systemEvent\"\n- sessionTarget=\"isolated\" | \"current\" | \"session:xxx\" REQUIRES payload.kind=\"agentTurn\"\n- Webhook: delivery.mode=\"webhook\" and delivery.to URL.\nDefault: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.\n\nRESTRICTED CRON RUNS:\n- Some isolated cron runs get narrow self-cleanup grant: status/list self-only, get/runs current job only, mutation only remove current job.\n\nWAKE MODES (for wake action):\n- \"next-heartbeat\" default: wake next heartbeat\n- \"now\": wake immediately\n\nUse jobId canonical; id accepted compat. contextMessages (0-10) adds previous messages as job context.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -159,7 +159,7 @@
"type": "string"
},
"agentId": {
"description": "List filter: agent id",
"description": "List filter for `action: \"list\"`; wake target override for `action: \"wake\"` (defaults to the calling agent when omitted on wake)",
"type": "string"
},
"contextMessages": {
@@ -745,6 +745,10 @@
"enum": ["due", "force"],
"type": "string"
},
"sessionKey": {
"description": "Wake target override for `action: \"wake\"`: route the event to the named session rather than the calling agent's current session. Defaults to the resolved calling-session key when omitted.",
"type": "string"
},
"text": {
"type": "string"
},
@@ -223,8 +223,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 44406,
"roughTokens": 11102
"chars": 44908,
"roughTokens": 11227
},
"openClawDeveloperInstructions": {
"chars": 2988,
@@ -235,8 +235,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6925
},
"totalWithDynamicToolsJson": {
"chars": 72108,
"roughTokens": 18027
"chars": 72610,
"roughTokens": 18153
},
"userInputText": {
"chars": 1629,
@@ -223,8 +223,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 44127,
"roughTokens": 11032
"chars": 44629,
"roughTokens": 11158
},
"openClawDeveloperInstructions": {
"chars": 1964,
@@ -235,8 +235,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6544
},
"totalWithDynamicToolsJson": {
"chars": 70305,
"roughTokens": 17577
"chars": 70807,
"roughTokens": 17702
},
"userInputText": {
"chars": 1129,
@@ -224,8 +224,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 45222,
"roughTokens": 11306
"chars": 45724,
"roughTokens": 11431
},
"openClawDeveloperInstructions": {
"chars": 1983,
@@ -236,8 +236,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6780
},
"totalWithDynamicToolsJson": {
"chars": 72343,
"roughTokens": 18086
"chars": 72845,
"roughTokens": 18212
},
"userInputText": {
"chars": 1367,