Co-authored-by: Shawn <shenghuikevin@shenghuideMac-mini.local> Co-authored-by: 不做了睡大觉 <user@example.com> Co-authored-by: Marcus Widing <widing.marcus@gmail.com>
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.
|
||||
- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin.
|
||||
- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
|
||||
- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js";
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
||||
@@ -42,7 +42,7 @@ function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
|
||||
expect(pathValue).not.toContain(key);
|
||||
expect(pathValue).not.toContain("..");
|
||||
|
||||
const tmpRoot = path.resolve(os.tmpdir());
|
||||
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||
const resolved = path.resolve(pathValue);
|
||||
const rel = path.relative(tmpRoot, resolved);
|
||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
||||
|
||||
@@ -401,6 +401,102 @@ describe("subagent announce formatting", () => {
|
||||
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
|
||||
});
|
||||
|
||||
it("suppresses completion delivery when subagent reply is ANNOUNCE_SKIP", async () => {
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct-completion-skip",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
roundOneReply: "ANNOUNCE_SKIP",
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses announce flow for whitespace-padded ANNOUNCE_SKIP and still runs cleanup", async () => {
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct-skip-whitespace",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
cleanup: "delete",
|
||||
roundOneReply: " ANNOUNCE_SKIP ",
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
expect(sessionsDeleteSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries completion direct send on transient channel-unavailable errors", async () => {
|
||||
sendSpy
|
||||
.mockRejectedValueOnce(new Error("Error: No active WhatsApp Web listener (account: default)"))
|
||||
.mockRejectedValueOnce(new Error("UNAVAILABLE: listener reconnecting"))
|
||||
.mockResolvedValueOnce({ runId: "send-main", status: "ok" });
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct-completion-retry",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { channel: "whatsapp", to: "+15550000000", accountId: "default" },
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
roundOneReply: "final answer",
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(sendSpy).toHaveBeenCalledTimes(3);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not retry completion direct send on permanent channel errors", async () => {
|
||||
sendSpy.mockRejectedValueOnce(new Error("unsupported channel: telegram"));
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct-completion-no-retry",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { channel: "telegram", to: "telegram:1234" },
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
roundOneReply: "final answer",
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(false);
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries direct agent announce on transient channel-unavailable errors", async () => {
|
||||
agentSpy
|
||||
.mockRejectedValueOnce(new Error("No active WhatsApp Web listener (account: default)"))
|
||||
.mockRejectedValueOnce(new Error("UNAVAILABLE: delivery temporarily unavailable"))
|
||||
.mockResolvedValueOnce({ runId: "run-main", status: "ok" });
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:test",
|
||||
childRunId: "run-direct-agent-retry",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
requesterOrigin: { channel: "whatsapp", to: "+15551112222", accountId: "default" },
|
||||
...defaultOutcomeAnnounce,
|
||||
roundOneReply: "worker result",
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(agentSpy).toHaveBeenCalledTimes(3);
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps completion-mode delivery coordinated when sibling runs are still active", async () => {
|
||||
sessionStore = {
|
||||
"agent:main:subagent:test": {
|
||||
|
||||
@@ -37,12 +37,16 @@ import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
|
||||
import type { SpawnSubagentMode } from "./subagent-spawn.js";
|
||||
import { readLatestAssistantReply } from "./tools/agent-step.js";
|
||||
import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js";
|
||||
import { isAnnounceSkip } from "./tools/sessions-send-helpers.js";
|
||||
|
||||
const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1";
|
||||
const FAST_TEST_RETRY_INTERVAL_MS = 8;
|
||||
const FAST_TEST_REPLY_CHANGE_WAIT_MS = 20;
|
||||
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 60_000;
|
||||
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
|
||||
const DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS = FAST_TEST_MODE
|
||||
? ([8, 16, 32] as const)
|
||||
: ([5_000, 10_000, 20_000] as const);
|
||||
|
||||
type ToolResultMessage = {
|
||||
role?: unknown;
|
||||
@@ -72,6 +76,9 @@ function buildCompletionDeliveryMessage(params: {
|
||||
outcome?: SubagentRunOutcome;
|
||||
}): string {
|
||||
const findingsText = params.findings.trim();
|
||||
if (isAnnounceSkip(findingsText)) {
|
||||
return "";
|
||||
}
|
||||
const hasFindings = findingsText.length > 0 && findingsText !== "(no output)";
|
||||
const header = (() => {
|
||||
if (params.outcome?.status === "error") {
|
||||
@@ -111,6 +118,92 @@ function summarizeDeliveryError(error: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
|
||||
/\berrorcode=unavailable\b/i,
|
||||
/\bstatus\s*[:=]\s*"?unavailable\b/i,
|
||||
/\bUNAVAILABLE\b/,
|
||||
/no active .* listener/i,
|
||||
/gateway not connected/i,
|
||||
/gateway closed \(1006/i,
|
||||
/gateway timeout/i,
|
||||
/\b(econnreset|econnrefused|etimedout|enotfound|ehostunreach|network error)\b/i,
|
||||
];
|
||||
|
||||
const PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
|
||||
/unsupported channel/i,
|
||||
/unknown channel/i,
|
||||
/chat not found/i,
|
||||
/user not found/i,
|
||||
/bot was blocked by the user/i,
|
||||
/forbidden: bot was kicked/i,
|
||||
/recipient is not a valid/i,
|
||||
/outbound not configured for channel/i,
|
||||
];
|
||||
|
||||
function isTransientAnnounceDeliveryError(error: unknown): boolean {
|
||||
const message = summarizeDeliveryError(error);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
if (PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message))) {
|
||||
return false;
|
||||
}
|
||||
return TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message));
|
||||
}
|
||||
|
||||
async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
if (!signal) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function runAnnounceDeliveryWithRetry<T>(params: {
|
||||
operation: string;
|
||||
signal?: AbortSignal;
|
||||
run: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
let retryIndex = 0;
|
||||
for (;;) {
|
||||
if (params.signal?.aborted) {
|
||||
throw new Error("announce delivery aborted");
|
||||
}
|
||||
try {
|
||||
return await params.run();
|
||||
} catch (err) {
|
||||
const delayMs = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS[retryIndex];
|
||||
if (delayMs == null || !isTransientAnnounceDeliveryError(err) || params.signal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
const nextAttempt = retryIndex + 2;
|
||||
const maxAttempts = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS.length + 1;
|
||||
defaultRuntime.log(
|
||||
`[warn] Subagent announce ${params.operation} transient failure, retrying ${nextAttempt}/${maxAttempts} in ${Math.round(delayMs / 1000)}s: ${summarizeDeliveryError(err)}`,
|
||||
);
|
||||
retryIndex += 1;
|
||||
await waitForAnnounceRetryDelay(delayMs, params.signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolResultText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return sanitizeTextContent(content);
|
||||
@@ -712,18 +805,23 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
path: "none",
|
||||
};
|
||||
}
|
||||
await callGateway({
|
||||
method: "send",
|
||||
params: {
|
||||
channel: completionChannel,
|
||||
to: completionTo,
|
||||
accountId: completionDirectOrigin?.accountId,
|
||||
threadId: completionThreadId,
|
||||
sessionKey: canonicalRequesterSessionKey,
|
||||
message: params.completionMessage,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
},
|
||||
timeoutMs: announceTimeoutMs,
|
||||
await runAnnounceDeliveryWithRetry({
|
||||
operation: "completion direct send",
|
||||
signal: params.signal,
|
||||
run: async () =>
|
||||
await callGateway({
|
||||
method: "send",
|
||||
params: {
|
||||
channel: completionChannel,
|
||||
to: completionTo,
|
||||
accountId: completionDirectOrigin?.accountId,
|
||||
threadId: completionThreadId,
|
||||
sessionKey: canonicalRequesterSessionKey,
|
||||
message: params.completionMessage,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
},
|
||||
timeoutMs: announceTimeoutMs,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -754,21 +852,26 @@ async function sendSubagentAnnounceDirectly(params: {
|
||||
path: "none",
|
||||
};
|
||||
}
|
||||
await callGateway({
|
||||
method: "agent",
|
||||
params: {
|
||||
sessionKey: canonicalRequesterSessionKey,
|
||||
message: params.triggerMessage,
|
||||
deliver: shouldDeliverExternally,
|
||||
bestEffortDeliver: params.bestEffortDeliver,
|
||||
channel: shouldDeliverExternally ? directChannel : undefined,
|
||||
accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined,
|
||||
to: shouldDeliverExternally ? directTo : undefined,
|
||||
threadId: shouldDeliverExternally ? threadId : undefined,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: announceTimeoutMs,
|
||||
await runAnnounceDeliveryWithRetry({
|
||||
operation: "direct announce agent call",
|
||||
signal: params.signal,
|
||||
run: async () =>
|
||||
await callGateway({
|
||||
method: "agent",
|
||||
params: {
|
||||
sessionKey: canonicalRequesterSessionKey,
|
||||
message: params.triggerMessage,
|
||||
deliver: shouldDeliverExternally,
|
||||
bestEffortDeliver: params.bestEffortDeliver,
|
||||
channel: shouldDeliverExternally ? directChannel : undefined,
|
||||
accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined,
|
||||
to: shouldDeliverExternally ? directTo : undefined,
|
||||
threadId: shouldDeliverExternally ? threadId : undefined,
|
||||
idempotencyKey: params.directIdempotencyKey,
|
||||
},
|
||||
expectFinal: true,
|
||||
timeoutMs: announceTimeoutMs,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -1096,6 +1199,10 @@ export async function runSubagentAnnounceFlow(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAnnounceSkip(reply)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!outcome) {
|
||||
outcome = { status: "unknown" };
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
|
||||
label: "cron",
|
||||
description: "Schedule tasks",
|
||||
sectionId: "automation",
|
||||
profiles: [],
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -56,6 +56,8 @@ describe("tool-policy", () => {
|
||||
it("resolves known profiles and ignores unknown ones", () => {
|
||||
const coding = resolveToolProfilePolicy("coding");
|
||||
expect(coding?.allow).toContain("read");
|
||||
expect(coding?.allow).toContain("cron");
|
||||
expect(coding?.allow).not.toContain("gateway");
|
||||
expect(resolveToolProfilePolicy("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -120,4 +120,23 @@ describe("tools invoke HTTP denylist", () => {
|
||||
|
||||
expect(cronRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it("keeps cron available under coding profile without exposing gateway", async () => {
|
||||
cfg = {
|
||||
tools: {
|
||||
profile: "coding",
|
||||
},
|
||||
gateway: {
|
||||
tools: {
|
||||
allow: ["cron"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cronRes = await invoke("cron");
|
||||
const gatewayRes = await invoke("gateway");
|
||||
|
||||
expect(cronRes.status).toBe(200);
|
||||
expect(gatewayRes.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user