diff --git a/CHANGELOG.md b/CHANGELOG.md index 59660a242..e2ab70b17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,7 @@ Docs: https://docs.openclaw.ai - Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson. - Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) when runs execute tools successfully but return no final assistant text, preventing silent no-reply turns after tool-only completions. (#22834) Thanks @Oldshue. - Agents/Subagents: honor `tools.subagents.tools.alsoAllow` and explicit subagent `allow` entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example `sessions_send`) are no longer blocked unless re-denied in `tools.subagents.tools.deny`. (#23359) Thanks @goren-beehero. +- Agents/Subagents: make announce call timeouts configurable via `agents.defaults.subagents.announceTimeoutMs` and restore a 60s default to prevent false timeout failures on slower announce paths. (#22719) Thanks @Valadon. - Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize. - Agents/Auth profiles: skip auth-profile cooldown writes for timeout failures in embedded runner rotation so model/network timeouts do not poison same-provider fallback model selection while still allowing in-turn account rotation. (#22622) Thanks @vageeshkumar. - Plugins/Hooks: run legacy `before_agent_start` once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710. diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts new file mode 100644 index 000000000..34b08dac0 --- /dev/null +++ b/src/agents/subagent-announce.timeout.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type GatewayCall = { + method?: string; + timeoutMs?: number; + expectFinal?: boolean; + params?: Record; +}; + +const gatewayCalls: GatewayCall[] = []; +let sessionStore: Record> = {}; +let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = { + session: { + mainKey: "main", + scope: "per-sender", + }, +}; + +vi.mock("../gateway/call.js", () => ({ + callGateway: vi.fn(async (request: GatewayCall) => { + gatewayCalls.push(request); + if (request.method === "chat.history") { + return { messages: [] }; + } + return {}; + }), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => configOverride, + }; +}); + +vi.mock("../config/sessions.js", () => ({ + loadSessionStore: vi.fn(() => sessionStore), + resolveAgentIdFromSessionKey: () => "main", + resolveStorePath: () => "/tmp/sessions-main.json", + resolveMainSessionKey: () => "agent:main:main", +})); + +vi.mock("./subagent-depth.js", () => ({ + getSubagentDepthFromSessionStore: () => 0, +})); + +vi.mock("./pi-embedded.js", () => ({ + isEmbeddedPiRunActive: () => false, + queueEmbeddedPiMessage: () => false, + waitForEmbeddedPiRunEnd: async () => true, +})); + +vi.mock("./subagent-registry.js", () => ({ + countActiveDescendantRuns: () => 0, + isSubagentSessionRunActive: () => true, + resolveRequesterForChildSession: () => null, +})); + +import { runSubagentAnnounceFlow } from "./subagent-announce.js"; + +describe("subagent announce timeout config", () => { + beforeEach(() => { + gatewayCalls.length = 0; + sessionStore = {}; + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + }, + }; + }); + + it("uses 60s timeout by default for direct announce agent call", async () => { + await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-default-timeout", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + timeoutMs: 1_000, + cleanup: "keep", + roundOneReply: "done", + waitForCompletion: false, + outcome: { status: "ok" }, + }); + + const directAgentCall = gatewayCalls.find( + (call) => call.method === "agent" && call.expectFinal === true, + ); + expect(directAgentCall?.timeoutMs).toBe(60_000); + }); + + it("honors configured announce timeout for direct announce agent call", async () => { + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + defaults: { + subagents: { + announceTimeoutMs: 90_000, + }, + }, + }, + }; + + await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-config-timeout-agent", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "do thing", + timeoutMs: 1_000, + cleanup: "keep", + roundOneReply: "done", + waitForCompletion: false, + outcome: { status: "ok" }, + }); + + const directAgentCall = gatewayCalls.find( + (call) => call.method === "agent" && call.expectFinal === true, + ); + expect(directAgentCall?.timeoutMs).toBe(90_000); + }); + + it("honors configured announce timeout for completion direct send call", async () => { + configOverride = { + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + defaults: { + subagents: { + announceTimeoutMs: 90_000, + }, + }, + }, + }; + + await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:worker", + childRunId: "run-config-timeout-send", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "discord", + to: "12345", + }, + task: "do thing", + timeoutMs: 1_000, + cleanup: "keep", + roundOneReply: "done", + waitForCompletion: false, + outcome: { status: "ok" }, + expectsCompletionMessage: true, + }); + + const sendCall = gatewayCalls.find((call) => call.method === "send"); + expect(sendCall?.timeoutMs).toBe(90_000); + }); +}); diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index e900f8be5..5d6f55010 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -41,6 +41,8 @@ import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-help 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; type ToolResultMessage = { role?: unknown; @@ -55,6 +57,14 @@ type SubagentAnnounceDeliveryResult = { error?: string; }; +function resolveSubagentAnnounceTimeoutMs(cfg: ReturnType): number { + const configured = cfg.agents?.defaults?.subagents?.announceTimeoutMs; + if (typeof configured !== "number" || !Number.isFinite(configured)) { + return DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS; + } + return Math.min(Math.max(1, Math.floor(configured)), MAX_TIMER_SAFE_TIMEOUT_MS); +} + function buildCompletionDeliveryMessage(params: { findings: string; subagentName: string; @@ -468,6 +478,8 @@ async function resolveSubagentCompletionOrigin(params: { } async function sendAnnounce(item: AnnounceQueueItem) { + const cfg = loadConfig(); + const announceTimeoutMs = resolveSubagentAnnounceTimeoutMs(cfg); const requesterDepth = getSubagentDepthFromSessionStore(item.sessionKey); const requesterIsSubagent = requesterDepth >= 1; const origin = item.origin; @@ -494,7 +506,7 @@ async function sendAnnounce(item: AnnounceQueueItem) { deliver: !requesterIsSubagent, idempotencyKey, }, - timeoutMs: 15_000, + timeoutMs: announceTimeoutMs, }); } @@ -627,6 +639,7 @@ async function sendSubagentAnnounceDirectly(params: { requesterIsSubagent: boolean; }): Promise { const cfg = loadConfig(); + const announceTimeoutMs = resolveSubagentAnnounceTimeoutMs(cfg); const canonicalRequesterSessionKey = resolveRequesterStoreKey( cfg, params.targetRequesterSessionKey, @@ -689,7 +702,7 @@ async function sendSubagentAnnounceDirectly(params: { message: params.completionMessage, idempotencyKey: params.directIdempotencyKey, }, - timeoutMs: 15_000, + timeoutMs: announceTimeoutMs, }); return { @@ -717,7 +730,7 @@ async function sendSubagentAnnounceDirectly(params: { idempotencyKey: params.directIdempotencyKey, }, expectFinal: true, - timeoutMs: 15_000, + timeoutMs: announceTimeoutMs, }); return { diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 60b623db5..3af07f83a 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -247,6 +247,8 @@ export type AgentDefaultsConfig = { model?: AgentModelConfig; /** Default thinking level for spawned sub-agents (e.g. "off", "low", "medium", "high"). */ thinking?: string; + /** Gateway timeout in ms for sub-agent announce delivery calls (default: 60000). */ + announceTimeoutMs?: number; }; /** Optional sandbox settings for non-main sessions. */ sandbox?: AgentSandboxConfig; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 4ec06f66b..5f6025e58 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -164,6 +164,7 @@ export const AgentDefaultsSchema = z archiveAfterMinutes: z.number().int().positive().optional(), model: AgentModelSchema.optional(), thinking: z.string().optional(), + announceTimeoutMs: z.number().int().positive().optional(), }) .strict() .optional(),