diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 71cbba402928..66e01cd15794 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -439,6 +439,8 @@ snapshots; OpenClaw owns all persistence and lifecycle coordination. Gateway-backed runs return the canonical accepted `sessionKey` alongside `runId`. The field is optional in the TypeScript result only so explicit custom runtimes remain compatible. + `waitForRun(...)` returns the canonical Gateway wait result. `status` is `"ok"`, `"error"`, `"timeout"`, or `"pending"`; pending is a normal nonterminal observation, not an exception. Optional `error`, `startedAt`, `endedAt`, `stopReason`, `livenessState`, `yielded`, `pendingError`, `timeoutPhase`, `providerStarted`, and `terminalReply` metadata is preserved so callers can distinguish observation timeouts from terminal outcomes. `timeoutMs` bounds the wait call; it does not cancel the run. + Model overrides (`provider`/`model`) require operator opt-in via `plugins.entries..subagent.allowModelOverride: true` in config. Untrusted plugins can still run subagents, but override requests are rejected. diff --git a/src/agents/run-wait.ts b/src/agents/run-wait.ts index 3687ad824f43..ff2f2022723e 100644 --- a/src/agents/run-wait.ts +++ b/src/agents/run-wait.ts @@ -25,17 +25,16 @@ import { buildAgentRunTerminalOutcomeFromWaitResult, type AgentRunTerminalOutcome, } from "./agent-run-terminal-outcome.js"; -import { - normalizeAgentRunTerminalReplySnapshot, - type AgentRunTerminalReplySnapshot, -} from "./agent-run-terminal-reply.js"; +import { normalizeAgentRunTerminalReplySnapshot } from "./agent-run-terminal-reply.js"; import { normalizeAgentRunTimeoutPhase, normalizeProviderStarted, - type AgentRunTimeoutPhase, } from "./run-timeout-attribution.js"; +import type { AgentWaitResult } from "./run-wait.types.js"; import { extractStoredAssistantText, stripToolMessages } from "./tools/chat-history-text.js"; +export type { AgentWaitResult }; + type GatewayCaller = typeof callGateway; function resolveRunWaitTimeoutMs(value: number | undefined): number { @@ -58,21 +57,6 @@ export type AssistantReplySnapshot = { fingerprint?: string; }; -/** Normalized terminal or pending state returned by `agent.wait`. */ -export type AgentWaitResult = { - status: "ok" | "timeout" | "error" | "pending"; - error?: string; - startedAt?: number; - endedAt?: number; - stopReason?: string; - livenessState?: string; - yielded?: boolean; - pendingError?: boolean; - timeoutPhase?: AgentRunTimeoutPhase; - providerStarted?: boolean; - terminalReply?: AgentRunTerminalReplySnapshot; -}; - /** Summary returned after waiting for a dynamic set of pending runs to drain. */ type AgentRunsDrainResult = { timedOut: boolean; diff --git a/src/agents/run-wait.types.ts b/src/agents/run-wait.types.ts new file mode 100644 index 000000000000..d6cde0a94aaa --- /dev/null +++ b/src/agents/run-wait.types.ts @@ -0,0 +1,17 @@ +import type { AgentRunTerminalReplySnapshot } from "./agent-run-terminal-reply.js"; +import type { AgentRunTimeoutPhase } from "./run-timeout-attribution.js"; + +/** Normalized terminal or pending state returned by `agent.wait`. */ +export type AgentWaitResult = { + status: "ok" | "timeout" | "error" | "pending"; + error?: string; + startedAt?: number; + endedAt?: number; + stopReason?: string; + livenessState?: string; + yielded?: boolean; + pendingError?: boolean; + timeoutPhase?: AgentRunTimeoutPhase; + providerStarted?: boolean; + terminalReply?: AgentRunTerminalReplySnapshot; +}; diff --git a/src/gateway/agent-turn/agent-dedupe.ts b/src/gateway/agent-turn/agent-dedupe.ts index b346987c05bd..1eeb21499863 100644 --- a/src/gateway/agent-turn/agent-dedupe.ts +++ b/src/gateway/agent-turn/agent-dedupe.ts @@ -36,6 +36,7 @@ export function isAcceptedAgentDedupePayload(payload: unknown): payload is { ownerDeviceId?: unknown; reservationId?: unknown; runId?: unknown; + runtime?: unknown; sessionKey?: unknown; status: "accepted"; } { diff --git a/src/gateway/agent-turn/agent-turn-service.ts b/src/gateway/agent-turn/agent-turn-service.ts index d3cc00c5ab67..afa98b589b8d 100644 --- a/src/gateway/agent-turn/agent-turn-service.ts +++ b/src/gateway/agent-turn/agent-turn-service.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, type AgentWaitParams } from "../../../packages/gateway-protocol/src/index.js"; import { scheduleMainSessionRecoveryPendingTarget } from "../../agents/main-session-recovery/main-session-recovery-owner-release.js"; @@ -70,6 +71,7 @@ function replayAgentTurnIfCached(params: { typeof cached.payload.agentId === "string" && cached.payload.agentId.trim() ? cached.payload.agentId.trim() : undefined; + const cachedRuntime = asOptionalRecord(cached.payload.runtime); params.io.emitAcceptance( [ true, @@ -78,6 +80,7 @@ function replayAgentTurnIfCached(params: { status: "in_flight" as const, ...(cachedSessionKey ? { sessionKey: cachedSessionKey } : {}), ...(cachedAgentId ? { agentId: cachedAgentId } : {}), + ...(cachedRuntime ? { runtime: cachedRuntime } : {}), }, undefined, ], diff --git a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts index a2a981415534..177e933b2c53 100644 --- a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts +++ b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts @@ -2553,7 +2553,7 @@ describe("gateway agent handler", () => { }); }); - it("preserves selected-global agent id on cached accepted responses", async () => { + it("preserves accepted session and runtime metadata on cached responses", async () => { const context = makeContext(); mocks.listAgentIds.mockReturnValue(["main", "work"]); mocks.loadConfigReturn = { @@ -2569,6 +2569,11 @@ describe("gateway agent handler", () => { sessionKey: "global", agentId: "work", status: "accepted", + runtime: { + harness: "claude-cli", + provider: "anthropic", + model: "claude-sonnet-4-6", + }, }, }); const respond = vi.fn(); @@ -2588,6 +2593,11 @@ describe("gateway agent handler", () => { sessionKey: "global", agentId: "work", status: "in_flight", + runtime: { + harness: "claude-cli", + provider: "anthropic", + model: "claude-sonnet-4-6", + }, }); expect(mocks.agentCommand).not.toHaveBeenCalled(); }); diff --git a/src/gateway/server-plugins.subagent-ended-hook.test.ts b/src/gateway/server-plugins.subagent-ended-hook.test.ts index e02c394e6ae8..71b8bfbf2f46 100644 --- a/src/gateway/server-plugins.subagent-ended-hook.test.ts +++ b/src/gateway/server-plugins.subagent-ended-hook.test.ts @@ -341,29 +341,47 @@ describe("createGatewaySubagentRuntime.run subagent_ended tracking (#59164)", () ).rejects.toThrow(/not found/); }); - test("normalizes completed agent.wait envelopes for plugin subagents", async () => { + test.each([ + { + name: "pending queue observation", + result: { + status: "pending", + timeoutPhase: "queue", + providerStarted: false, + }, + }, + { + name: "metadata-rich observation timeout", + result: { + status: "timeout", + error: "provider retry is still pending", + startedAt: 1_000, + endedAt: 2_000, + stopReason: "timeout", + livenessState: "blocked", + yielded: true, + pendingError: true, + timeoutPhase: "provider", + providerStarted: true, + terminalReply: { disposition: "empty" }, + }, + }, + { + name: "legacy completed status", + result: { status: "completed" }, + expected: { status: "ok" }, + }, + { + name: "legacy completed error", + result: { status: "error", error: "completed" }, + expected: { status: "ok" }, + }, + ])("preserves the agent.wait $name result", async ({ result, expected = result }) => { const serverPlugins = await loadServerPlugins(); const runtime = serverPlugins.createGatewaySubagentRuntime(); serverPlugins.setFallbackGatewayContext(createTestContext("plugin-wait", createTestCfg())); + internalAgentTurnFacade.wait.mockResolvedValue(result); - internalAgentTurnFacade.wait.mockResolvedValue({ status: "completed" }); - - await expect(runtime.waitForRun({ runId: "plugin-run-completed" })).resolves.toEqual({ - status: "ok", - }); - }); - - test("normalizes malformed completed wait errors for plugin subagents", async () => { - const serverPlugins = await loadServerPlugins(); - const runtime = serverPlugins.createGatewaySubagentRuntime(); - serverPlugins.setFallbackGatewayContext( - createTestContext("plugin-wait-error", createTestCfg()), - ); - - internalAgentTurnFacade.wait.mockResolvedValue({ status: "error", error: "completed" }); - - await expect(runtime.waitForRun({ runId: "plugin-run-error-completed" })).resolves.toEqual({ - status: "ok", - }); + await expect(runtime.waitForRun({ runId: "plugin-run-wait" })).resolves.toEqual(expected); }); }); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index bc5e743294f1..a0986f894bdc 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { performance } from "node:perf_hooks"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import type { AgentWaitResult } from "../agents/run-wait.types.js"; import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js"; import { allowsProcessHomeSessionScan } from "../config/paths.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; @@ -322,7 +323,9 @@ export function createGatewaySubagentRuntime( return { runId, sessionKey, ...(runtime ? { runtime } : {}) }; }, async waitForRun(params) { - const payload = await dispatchGatewayMethodInProcess<{ status?: string; error?: string }>( + const payload = await dispatchGatewayMethodInProcess< + Omit & { status?: string } + >( "agent.wait", { runId: params.runId, @@ -330,20 +333,20 @@ export function createGatewaySubagentRuntime( }, { resolveGatewayContext }, ); - let status = payload?.status; + const { status: rawStatus, error, ...metadata } = payload; + let status = rawStatus; if (status === "completed" || status === "succeeded") { status = "ok"; - } else if (status === "error" && payload?.error?.trim().toLowerCase() === "completed") { + } else if (status === "error" && error?.trim().toLowerCase() === "completed") { status = "ok"; } - if (status !== "ok" && status !== "error" && status !== "timeout") { - throw new Error(`Gateway agent.wait returned unexpected status: ${payload?.status}`); + if (status !== "ok" && status !== "error" && status !== "timeout" && status !== "pending") { + throw new Error(`Gateway agent.wait returned unexpected status: ${rawStatus}`); } return { + ...metadata, status, - ...(status !== "ok" && - typeof payload?.error === "string" && - payload.error && { error: payload.error }), + ...(status !== "ok" && error ? { error } : {}), }; }, getSessionMessages, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 3a049c514984..228d499dd2bf 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -2,6 +2,7 @@ // Owner schema module import keeps the ProtocolSchemas registry out of the // public plugin-sdk dts graph (check-plugin-sdk-exports guards this). import type { NodePluginToolDescriptor } from "../../../packages/gateway-protocol/src/schema/nodes.js"; +import type { AgentWaitResult } from "../../agents/run-wait.types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { OperatorScope } from "../../gateway/operator-scopes.js"; import type { PluginRuntimeCore, RuntimeLogger } from "./types-core.js"; @@ -51,11 +52,6 @@ type SubagentWaitParams = { timeoutMs?: number; }; -type SubagentWaitResult = { - status: "ok" | "error" | "timeout"; - error?: string; -}; - type SubagentGetSessionMessagesParams = { sessionKey: string; limit?: number; @@ -127,7 +123,7 @@ export type PluginRuntime = PluginRuntimeCore & { }; subagent: { run: (params: SubagentRunParams) => Promise; - waitForRun: (params: SubagentWaitParams) => Promise; + waitForRun: (params: SubagentWaitParams) => Promise; getSessionMessages: ( params: SubagentGetSessionMessagesParams, ) => Promise;