refactor: centralize agent timeout defaults
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
|
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
|
||||||
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
|
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
|
||||||
- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running.
|
- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running.
|
||||||
|
- Gateway: honor `agent.timeoutSeconds` for `chat.send` and share timeout defaults across chat/cron/auto-reply. Thanks @MSch for PR #229.
|
||||||
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
|
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
|
||||||
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
|
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
|
||||||
- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256.
|
- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256.
|
||||||
|
|||||||
35
src/agents/timeout.ts
Normal file
35
src/agents/timeout.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
|
||||||
|
const DEFAULT_AGENT_TIMEOUT_SECONDS = 600;
|
||||||
|
|
||||||
|
const normalizeNumber = (value: unknown): number | undefined =>
|
||||||
|
typeof value === "number" && Number.isFinite(value)
|
||||||
|
? Math.floor(value)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number {
|
||||||
|
const raw = normalizeNumber(cfg?.agent?.timeoutSeconds);
|
||||||
|
const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS;
|
||||||
|
return Math.max(seconds, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAgentTimeoutMs(opts: {
|
||||||
|
cfg?: ClawdbotConfig;
|
||||||
|
overrideMs?: number | null;
|
||||||
|
overrideSeconds?: number | null;
|
||||||
|
minMs?: number;
|
||||||
|
}): number {
|
||||||
|
const minMs = Math.max(normalizeNumber(opts.minMs) ?? 1, 1);
|
||||||
|
const defaultMs = resolveAgentTimeoutSeconds(opts.cfg) * 1000;
|
||||||
|
const overrideMs = normalizeNumber(opts.overrideMs);
|
||||||
|
if (overrideMs !== undefined) {
|
||||||
|
if (overrideMs <= 0) return defaultMs;
|
||||||
|
return Math.max(overrideMs, minMs);
|
||||||
|
}
|
||||||
|
const overrideSeconds = normalizeNumber(opts.overrideSeconds);
|
||||||
|
if (overrideSeconds !== undefined) {
|
||||||
|
if (overrideSeconds <= 0) return defaultMs;
|
||||||
|
return Math.max(overrideSeconds * 1000, minMs);
|
||||||
|
}
|
||||||
|
return Math.max(defaultMs, minMs);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
resolveEmbeddedSessionLane,
|
resolveEmbeddedSessionLane,
|
||||||
} from "../agents/pi-embedded.js";
|
} from "../agents/pi-embedded.js";
|
||||||
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
||||||
|
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||||
ensureAgentWorkspace,
|
ensureAgentWorkspace,
|
||||||
@@ -221,8 +222,7 @@ export async function getReplyFromConfig(
|
|||||||
ensureBootstrapFiles: true,
|
ensureBootstrapFiles: true,
|
||||||
});
|
});
|
||||||
const workspaceDir = workspace.dir;
|
const workspaceDir = workspace.dir;
|
||||||
const timeoutSeconds = Math.max(agentCfg?.timeoutSeconds ?? 600, 1);
|
const timeoutMs = resolveAgentTimeoutMs({ cfg });
|
||||||
const timeoutMs = timeoutSeconds * 1000;
|
|
||||||
const configuredTypingSeconds =
|
const configuredTypingSeconds =
|
||||||
agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds;
|
agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds;
|
||||||
const typingIntervalSeconds =
|
const typingIntervalSeconds =
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "../agents/model-selection.js";
|
} from "../agents/model-selection.js";
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||||
|
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||||
ensureAgentWorkspace,
|
ensureAgentWorkspace,
|
||||||
@@ -190,11 +191,17 @@ export async function agentCommand(
|
|||||||
const timeoutSecondsRaw =
|
const timeoutSecondsRaw =
|
||||||
opts.timeout !== undefined
|
opts.timeout !== undefined
|
||||||
? Number.parseInt(String(opts.timeout), 10)
|
? Number.parseInt(String(opts.timeout), 10)
|
||||||
: (agentCfg?.timeoutSeconds ?? 600);
|
: undefined;
|
||||||
if (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) {
|
if (
|
||||||
|
timeoutSecondsRaw !== undefined &&
|
||||||
|
(Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0)
|
||||||
|
) {
|
||||||
throw new Error("--timeout must be a positive integer (seconds)");
|
throw new Error("--timeout must be a positive integer (seconds)");
|
||||||
}
|
}
|
||||||
const timeoutMs = Math.max(timeoutSecondsRaw, 1) * 1000;
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
|
cfg,
|
||||||
|
overrideSeconds: timeoutSecondsRaw,
|
||||||
|
});
|
||||||
|
|
||||||
const sessionResolution = resolveSession({
|
const sessionResolution = resolveSession({
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../agents/model-selection.js";
|
} from "../agents/model-selection.js";
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||||
|
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||||
ensureAgentWorkspace,
|
ensureAgentWorkspace,
|
||||||
@@ -234,12 +235,13 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutSecondsRaw =
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
params.job.payload.kind === "agentTurn" && params.job.payload.timeoutSeconds
|
cfg: params.cfg,
|
||||||
? params.job.payload.timeoutSeconds
|
overrideSeconds:
|
||||||
: (agentCfg?.timeoutSeconds ?? 600);
|
params.job.payload.kind === "agentTurn"
|
||||||
const timeoutSeconds = Math.max(Math.floor(timeoutSecondsRaw), 1);
|
? params.job.payload.timeoutSeconds
|
||||||
const timeoutMs = timeoutSeconds * 1000;
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const delivery =
|
const delivery =
|
||||||
params.job.payload.kind === "agentTurn" &&
|
params.job.payload.kind === "agentTurn" &&
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolveModelRefFromString,
|
resolveModelRefFromString,
|
||||||
resolveThinkingDefault,
|
resolveThinkingDefault,
|
||||||
} from "../agents/model-selection.js";
|
} from "../agents/model-selection.js";
|
||||||
|
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||||
import {
|
import {
|
||||||
abortEmbeddedPiRun,
|
abortEmbeddedPiRun,
|
||||||
isEmbeddedPiRunActive,
|
isEmbeddedPiRunActive,
|
||||||
@@ -927,14 +928,10 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
const { cfg, storePath, store, entry } = loadSessionEntry(
|
const { cfg, storePath, store, entry } = loadSessionEntry(
|
||||||
p.sessionKey,
|
p.sessionKey,
|
||||||
);
|
);
|
||||||
const defaultTimeoutMs = Math.max(
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
Math.floor((cfg.agent?.timeoutSeconds ?? 600) * 1000),
|
cfg,
|
||||||
0,
|
overrideMs: p.timeoutMs,
|
||||||
);
|
});
|
||||||
const timeoutMs =
|
|
||||||
typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs)
|
|
||||||
? Math.max(0, Math.floor(p.timeoutMs))
|
|
||||||
: defaultTimeoutMs;
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sessionId = entry?.sessionId ?? randomUUID();
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
const sessionEntry: SessionEntry = {
|
const sessionEntry: SessionEntry = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||||
|
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||||
import { agentCommand } from "../../commands/agent.js";
|
import { agentCommand } from "../../commands/agent.js";
|
||||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||||
@@ -188,14 +189,10 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { cfg, storePath, store, entry } = loadSessionEntry(p.sessionKey);
|
const { cfg, storePath, store, entry } = loadSessionEntry(p.sessionKey);
|
||||||
const defaultTimeoutMs = Math.max(
|
const timeoutMs = resolveAgentTimeoutMs({
|
||||||
Math.floor((cfg.agent?.timeoutSeconds ?? 600) * 1000),
|
cfg,
|
||||||
0,
|
overrideMs: p.timeoutMs,
|
||||||
);
|
});
|
||||||
const timeoutMs =
|
|
||||||
typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs)
|
|
||||||
? Math.max(0, Math.floor(p.timeoutMs))
|
|
||||||
: defaultTimeoutMs;
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sessionId = entry?.sessionId ?? randomUUID();
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
const sessionEntry: SessionEntry = {
|
const sessionEntry: SessionEntry = {
|
||||||
|
|||||||
@@ -40,6 +40,27 @@ describe("gateway server chat", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("chat.send defaults to agent timeout config", async () => {
|
||||||
|
testState.agentConfig = { timeoutSeconds: 123 };
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "chat.send", {
|
||||||
|
sessionKey: "main",
|
||||||
|
message: "hello",
|
||||||
|
idempotencyKey: "idem-timeout-1",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
|
const call = vi.mocked(agentCommand).mock.calls.at(-1)?.[0] as
|
||||||
|
| { timeout?: string }
|
||||||
|
| undefined;
|
||||||
|
expect(call?.timeout).toBe("123");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("chat.send blocked by send policy", async () => {
|
test("chat.send blocked by send policy", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const cronIsolatedRun = hoisted.cronIsolatedRun;
|
|||||||
export const agentCommand = hoisted.agentCommand;
|
export const agentCommand = hoisted.agentCommand;
|
||||||
|
|
||||||
export const testState = {
|
export const testState = {
|
||||||
|
agentConfig: undefined as Record<string, unknown> | undefined,
|
||||||
sessionStorePath: undefined as string | undefined,
|
sessionStorePath: undefined as string | undefined,
|
||||||
sessionConfig: undefined as Record<string, unknown> | undefined,
|
sessionConfig: undefined as Record<string, unknown> | undefined,
|
||||||
allowFrom: undefined as string[] | undefined,
|
allowFrom: undefined as string[] | undefined,
|
||||||
@@ -243,6 +244,7 @@ vi.mock("../config/config.js", async () => {
|
|||||||
agent: {
|
agent: {
|
||||||
model: "anthropic/claude-opus-4-5",
|
model: "anthropic/claude-opus-4-5",
|
||||||
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
|
||||||
|
...testState.agentConfig,
|
||||||
},
|
},
|
||||||
whatsapp: {
|
whatsapp: {
|
||||||
allowFrom: testState.allowFrom,
|
allowFrom: testState.allowFrom,
|
||||||
@@ -351,6 +353,7 @@ export function installGatewayTestHooks() {
|
|||||||
testState.cronStorePath = undefined;
|
testState.cronStorePath = undefined;
|
||||||
testState.sessionConfig = undefined;
|
testState.sessionConfig = undefined;
|
||||||
testState.sessionStorePath = undefined;
|
testState.sessionStorePath = undefined;
|
||||||
|
testState.agentConfig = undefined;
|
||||||
testState.allowFrom = undefined;
|
testState.allowFrom = undefined;
|
||||||
testIsNixMode.value = false;
|
testIsNixMode.value = false;
|
||||||
cronIsolatedRun.mockClear();
|
cronIsolatedRun.mockClear();
|
||||||
|
|||||||
Reference in New Issue
Block a user