diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da301e14..8d7032e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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: 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. - 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. diff --git a/src/agents/timeout.ts b/src/agents/timeout.ts new file mode 100644 index 000000000..65d0eeb9c --- /dev/null +++ b/src/agents/timeout.ts @@ -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); +} diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 085563d6c..65b94e931 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -11,6 +11,7 @@ import { resolveEmbeddedSessionLane, } from "../agents/pi-embedded.js"; import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, @@ -221,8 +222,7 @@ export async function getReplyFromConfig( ensureBootstrapFiles: true, }); const workspaceDir = workspace.dir; - const timeoutSeconds = Math.max(agentCfg?.timeoutSeconds ?? 600, 1); - const timeoutMs = timeoutSeconds * 1000; + const timeoutMs = resolveAgentTimeoutMs({ cfg }); const configuredTypingSeconds = agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds; const typingIntervalSeconds = diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 4e96b55a9..fa5287534 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -16,6 +16,7 @@ import { } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, @@ -190,11 +191,17 @@ export async function agentCommand( const timeoutSecondsRaw = opts.timeout !== undefined ? Number.parseInt(String(opts.timeout), 10) - : (agentCfg?.timeoutSeconds ?? 600); - if (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) { + : undefined; + if ( + timeoutSecondsRaw !== undefined && + (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) + ) { 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({ cfg, diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 93c24083a..a121636a5 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -13,6 +13,7 @@ import { } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, @@ -234,12 +235,13 @@ export async function runCronIsolatedAgentTurn(params: { }); } - const timeoutSecondsRaw = - params.job.payload.kind === "agentTurn" && params.job.payload.timeoutSeconds - ? params.job.payload.timeoutSeconds - : (agentCfg?.timeoutSeconds ?? 600); - const timeoutSeconds = Math.max(Math.floor(timeoutSecondsRaw), 1); - const timeoutMs = timeoutSeconds * 1000; + const timeoutMs = resolveAgentTimeoutMs({ + cfg: params.cfg, + overrideSeconds: + params.job.payload.kind === "agentTurn" + ? params.job.payload.timeoutSeconds + : undefined, + }); const delivery = params.job.payload.kind === "agentTurn" && diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 7ce5dc598..d50e6365d 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -10,6 +10,7 @@ import { resolveModelRefFromString, resolveThinkingDefault, } from "../agents/model-selection.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { abortEmbeddedPiRun, isEmbeddedPiRunActive, @@ -927,14 +928,10 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { const { cfg, storePath, store, entry } = loadSessionEntry( p.sessionKey, ); - const defaultTimeoutMs = Math.max( - Math.floor((cfg.agent?.timeoutSeconds ?? 600) * 1000), - 0, - ); - const timeoutMs = - typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs) - ? Math.max(0, Math.floor(p.timeoutMs)) - : defaultTimeoutMs; + const timeoutMs = resolveAgentTimeoutMs({ + cfg, + overrideMs: p.timeoutMs, + }); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); const sessionEntry: SessionEntry = { diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index d5fd0de15..9d687de53 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; +import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { agentCommand } from "../../commands/agent.js"; import { type SessionEntry, saveSessionStore } from "../../config/sessions.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 defaultTimeoutMs = Math.max( - Math.floor((cfg.agent?.timeoutSeconds ?? 600) * 1000), - 0, - ); - const timeoutMs = - typeof p.timeoutMs === "number" && Number.isFinite(p.timeoutMs) - ? Math.max(0, Math.floor(p.timeoutMs)) - : defaultTimeoutMs; + const timeoutMs = resolveAgentTimeoutMs({ + cfg, + overrideMs: p.timeoutMs, + }); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); const sessionEntry: SessionEntry = { diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts index ffb8e09a8..18c078a79 100644 --- a/src/gateway/server.chat.test.ts +++ b/src/gateway/server.chat.test.ts @@ -40,6 +40,27 @@ describe("gateway server chat", () => { 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 () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index fe2d99db8..98caaa89a 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -84,6 +84,7 @@ export const cronIsolatedRun = hoisted.cronIsolatedRun; export const agentCommand = hoisted.agentCommand; export const testState = { + agentConfig: undefined as Record | undefined, sessionStorePath: undefined as string | undefined, sessionConfig: undefined as Record | undefined, allowFrom: undefined as string[] | undefined, @@ -243,6 +244,7 @@ vi.mock("../config/config.js", async () => { agent: { model: "anthropic/claude-opus-4-5", workspace: path.join(os.tmpdir(), "clawd-gateway-test"), + ...testState.agentConfig, }, whatsapp: { allowFrom: testState.allowFrom, @@ -351,6 +353,7 @@ export function installGatewayTestHooks() { testState.cronStorePath = undefined; testState.sessionConfig = undefined; testState.sessionStorePath = undefined; + testState.agentConfig = undefined; testState.allowFrom = undefined; testIsNixMode.value = false; cronIsolatedRun.mockClear();