mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 00:52:10 -06:00
fix(plugins): preserve subagent lifecycle results (#126167)
This commit is contained in:
committed by
GitHub
parent
a6b77ffc07
commit
404eddbc6a
@@ -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.
|
||||
|
||||
<Warning>
|
||||
Model overrides (`provider`/`model`) require operator opt-in via `plugins.entries.<id>.subagent.allowModelOverride: true` in config. Untrusted plugins can still run subagents, but override requests are rejected.
|
||||
</Warning>
|
||||
|
||||
+4
-20
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -36,6 +36,7 @@ export function isAcceptedAgentDedupePayload(payload: unknown): payload is {
|
||||
ownerDeviceId?: unknown;
|
||||
reservationId?: unknown;
|
||||
runId?: unknown;
|
||||
runtime?: unknown;
|
||||
sessionKey?: unknown;
|
||||
status: "accepted";
|
||||
} {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AgentWaitResult, "status"> & { 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,
|
||||
|
||||
@@ -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<SubagentRunResult>;
|
||||
waitForRun: (params: SubagentWaitParams) => Promise<SubagentWaitResult>;
|
||||
waitForRun: (params: SubagentWaitParams) => Promise<AgentWaitResult>;
|
||||
getSessionMessages: (
|
||||
params: SubagentGetSessionMessagesParams,
|
||||
) => Promise<SubagentGetSessionMessagesResult>;
|
||||
|
||||
Reference in New Issue
Block a user