fix(subagents): restore configurable announce timeout
Co-authored-by: Valadon <20071960+Valadon@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
164
src/agents/subagent-announce.timeout.test.ts
Normal file
164
src/agents/subagent-announce.timeout.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type GatewayCall = {
|
||||
method?: string;
|
||||
timeoutMs?: number;
|
||||
expectFinal?: boolean;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const gatewayCalls: GatewayCall[] = [];
|
||||
let sessionStore: Record<string, Record<string, unknown>> = {};
|
||||
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<typeof import("../config/config.js")>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof loadConfig>): 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<SubagentAnnounceDeliveryResult> {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user