Skip to content

Commit 210ff7d

Browse files
committed
fix(agents): yield during model stream bursts
1 parent f50c65f commit 210ff7d

3 files changed

Lines changed: 134 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747
- Cron: keep legacy string schedules and blank system-event jobs available for runtime repair/skip handling instead of dropping them as malformed persisted rows.
4848
- Task persistence: drop malformed array/scalar requester-origin JSON from task and task-flow SQLite sidecars instead of restoring it as delivery metadata.
4949
- Agents/timeouts: clarify model idle-timeout errors and docs so provider `timeoutSeconds` is shown as bounded by the whole agent/run timeout ceiling.
50+
- Agents/OpenAI streams: yield cooperatively while processing bursty Completions and Responses chunks, keeping aborts, channel liveness timers, and startup heartbeats responsive under noisy model output. Refs #82462.
5051
- Release tooling: align the published launcher Node floor, `npm start`, package script checks, sharded lint locking, Vitest root project coverage, and plugin-SDK declaration build cache metadata so release/package validation does not silently skip or ship stale surfaces.
5152
- Cron/agents: honor configured subagent model fallbacks for isolated scheduled runs and forward that fallback policy into embedded agent timeout failover. Fixes #74985. Thanks @chrisgwynne.
5253
- Codex app-server/MCP: scope user MCP servers to specific OpenClaw agent ids through an optional `mcp.servers.<name>.codex.agents` list and accept `codex.defaultToolsApprovalMode` (`auto`/`prompt`/`approve`) for native Codex approval defaults; OpenClaw strips the `codex` block before handing `mcp_servers` config to Codex. (#82180) Thanks @sercada.

src/agents/openai-transport-stream.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,85 @@ describe("openai transport stream", () => {
10341034
});
10351035
});
10361036

1037+
it("yields to aborts during bursty OpenAI-compatible streams", async () => {
1038+
const model = {
1039+
id: "deepseek-v4-flash",
1040+
name: "DeepSeek V4 Flash",
1041+
api: "openai-completions",
1042+
provider: "opencode-go",
1043+
baseUrl: "http://localhost:8000/v1",
1044+
reasoning: false,
1045+
input: ["text"],
1046+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1047+
contextWindow: 128000,
1048+
maxTokens: 4096,
1049+
} satisfies Model<"openai-completions">;
1050+
const output = createAssistantOutput(model);
1051+
const abort = new AbortController();
1052+
const stream = { push: vi.fn() };
1053+
let yieldedToTimer = false;
1054+
1055+
async function* mockStream() {
1056+
for (let index = 0; index < 512; index += 1) {
1057+
yield {
1058+
id: "chatcmpl-bursty",
1059+
object: "chat.completion.chunk" as const,
1060+
created: 1775425651,
1061+
model: model.id,
1062+
choices: [
1063+
{
1064+
index: 0,
1065+
delta: { role: "assistant" as const, content: "x" },
1066+
logprobs: null,
1067+
finish_reason: null,
1068+
},
1069+
],
1070+
};
1071+
}
1072+
}
1073+
1074+
setTimeout(() => {
1075+
yieldedToTimer = true;
1076+
abort.abort();
1077+
}, 0);
1078+
1079+
await expect(
1080+
__testing.processOpenAICompletionsStream(mockStream(), output, model, stream, {
1081+
signal: abort.signal,
1082+
}),
1083+
).rejects.toThrow("Request was aborted");
1084+
expect(yieldedToTimer).toBe(true);
1085+
expect(stream.push.mock.calls.length).toBeLessThan(512);
1086+
});
1087+
1088+
it("yields to aborts during bursty Responses streams", async () => {
1089+
const model = createAzureResponsesModel();
1090+
const output = createResponsesAssistantOutput(model);
1091+
const abort = new AbortController();
1092+
const stream = { push: vi.fn() };
1093+
let yieldedToTimer = false;
1094+
1095+
async function* mockStream() {
1096+
yield { type: "response.output_item.added", item: { type: "message" } };
1097+
for (let index = 0; index < 512; index += 1) {
1098+
yield { type: "response.output_text.delta", delta: "x" };
1099+
}
1100+
}
1101+
1102+
setTimeout(() => {
1103+
yieldedToTimer = true;
1104+
abort.abort();
1105+
}, 0);
1106+
1107+
await expect(
1108+
__testing.processResponsesStream(mockStream(), output, stream, model, {
1109+
signal: abort.signal,
1110+
}),
1111+
).rejects.toThrow("Request was aborted");
1112+
expect(yieldedToTimer).toBe(true);
1113+
expect(stream.push.mock.calls.length).toBeLessThan(512);
1114+
});
1115+
10371116
it("skips null and non-object OpenAI-compatible stream chunks", async () => {
10381117
const model = {
10391118
id: "glm-5",

src/agents/openai-transport-stream.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ const DEFAULT_AZURE_OPENAI_API_VERSION = "preview";
7373
const OPENAI_CODEX_RESPONSES_EMPTY_INPUT_TEXT = " ";
7474
const GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP = "skip_thought_signature_validator";
7575
const AZURE_RESPONSES_FIRST_EVENT_TIMEOUT_MS = 30_000;
76+
const MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12;
77+
const MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64;
7678
const log = createSubsystemLogger("openai-transport");
7779

7880
type ReplayableResponseOutputMessage = Omit<ResponseOutputMessage, "id"> & { id?: string };
@@ -92,6 +94,42 @@ type BaseStreamOptions = {
9294
responseFormat?: Record<string, unknown>;
9395
};
9496

97+
type ModelStreamCooperativeScheduler = {
98+
afterEvent: () => Promise<void>;
99+
};
100+
101+
function throwIfModelStreamAborted(signal?: AbortSignal): void {
102+
if (signal?.aborted) {
103+
throw new Error("Request was aborted");
104+
}
105+
}
106+
107+
function createModelStreamCooperativeScheduler(
108+
signal?: AbortSignal,
109+
): ModelStreamCooperativeScheduler {
110+
let lastYieldedAt = Date.now();
111+
let eventsSinceYield = 0;
112+
return {
113+
async afterEvent() {
114+
throwIfModelStreamAborted(signal);
115+
eventsSinceYield += 1;
116+
const now = Date.now();
117+
if (
118+
eventsSinceYield < MODEL_STREAM_COOPERATIVE_YIELD_MAX_EVENTS &&
119+
now - lastYieldedAt < MODEL_STREAM_COOPERATIVE_YIELD_INTERVAL_MS
120+
) {
121+
return;
122+
}
123+
eventsSinceYield = 0;
124+
lastYieldedAt = now;
125+
await new Promise<void>((resolve) => {
126+
setImmediate(resolve);
127+
});
128+
throwIfModelStreamAborted(signal);
129+
},
130+
};
131+
}
132+
95133
type OpenAIResponsesOptions = BaseStreamOptions & {
96134
reasoning?: OpenAIReasoningEffort;
97135
reasoningEffort?: OpenAIReasoningEffort;
@@ -722,6 +760,7 @@ async function processResponsesStream(
722760
serviceTier?: ResponseCreateParamsStreaming["service_tier"],
723761
) => void;
724762
firstEventTimeoutMs?: number;
763+
signal?: AbortSignal;
725764
},
726765
) {
727766
let currentItem: Record<string, unknown> | null = null;
@@ -736,7 +775,9 @@ async function processResponsesStream(
736775
model,
737776
options?.firstEventTimeoutMs,
738777
);
778+
const cooperativeScheduler = createModelStreamCooperativeScheduler(options?.signal);
739779
for await (const rawEvent of guardedStream) {
780+
throwIfModelStreamAborted(options?.signal);
740781
const event = rawEvent as Record<string, unknown>;
741782
const type = stringifyUnknown(event.type);
742783
eventCount += 1;
@@ -933,6 +974,7 @@ async function processResponsesStream(
933974
: "Unknown error (no error details in response)";
934975
throw new Error(msg);
935976
}
977+
await cooperativeScheduler.afterEvent();
936978
}
937979
const eventTypeSummary = [...eventTypes.entries()]
938980
.slice(0, 12)
@@ -1141,6 +1183,7 @@ export function createOpenAIResponsesTransportStreamFn(): StreamFn {
11411183
await processResponsesStream(responseStream, output, stream, model, {
11421184
serviceTier: (options as OpenAIResponsesOptions | undefined)?.serviceTier,
11431185
applyServiceTierPricing,
1186+
signal: options?.signal,
11441187
});
11451188
if (options?.signal?.aborted) {
11461189
throw new Error("Request was aborted");
@@ -1538,6 +1581,7 @@ export function createAzureOpenAIResponsesTransportStreamFn(): StreamFn {
15381581
stream.push({ type: "start", partial: output as never });
15391582
await processResponsesStream(responseStream, output, stream, model, {
15401583
firstEventTimeoutMs: AZURE_RESPONSES_FIRST_EVENT_TIMEOUT_MS,
1584+
signal: options?.signal,
15411585
});
15421586
if (options?.signal?.aborted) {
15431587
throw new Error("Request was aborted");
@@ -1738,7 +1782,9 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn {
17381782
buildOpenAISdkRequestOptions(model, options?.signal),
17391783
)) as unknown as AsyncIterable<ChatCompletionChunk>;
17401784
stream.push({ type: "start", partial: output as never });
1741-
await processOpenAICompletionsStream(responseStream, output, model, stream);
1785+
await processOpenAICompletionsStream(responseStream, output, model, stream, {
1786+
signal: options?.signal,
1787+
});
17421788
if (options?.signal?.aborted) {
17431789
throw new Error("Request was aborted");
17441790
}
@@ -1760,6 +1806,7 @@ async function processOpenAICompletionsStream(
17601806
output: MutableAssistantOutput,
17611807
model: Model<Api>,
17621808
stream: { push(event: unknown): void },
1809+
options?: { signal?: AbortSignal },
17631810
) {
17641811
const MAX_POST_TOOL_CALL_BUFFER_BYTES = 256_000;
17651812
const MAX_TOOL_CALL_ARGUMENT_BUFFER_BYTES = 256_000;
@@ -1907,8 +1954,11 @@ async function processOpenAICompletionsStream(
19071954
appendVisibleTextDelta(part);
19081955
}
19091956
};
1957+
const cooperativeScheduler = createModelStreamCooperativeScheduler(options?.signal);
19101958
for await (const rawChunk of responseStream as AsyncIterable<unknown>) {
1959+
throwIfModelStreamAborted(options?.signal);
19111960
if (!rawChunk || typeof rawChunk !== "object") {
1961+
await cooperativeScheduler.afterEvent();
19121962
continue;
19131963
}
19141964
const chunk = rawChunk as ChatCompletionChunk;
@@ -1918,6 +1968,7 @@ async function processOpenAICompletionsStream(
19181968
}
19191969
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
19201970
if (!choice) {
1971+
await cooperativeScheduler.afterEvent();
19211972
continue;
19221973
}
19231974
const choiceUsage = (choice as unknown as { usage?: ChatCompletionChunk["usage"] }).usage;
@@ -1935,6 +1986,7 @@ async function processOpenAICompletionsStream(
19351986
choice.delta ??
19361987
(choice as unknown as { message?: ChatCompletionChunk["choices"][number]["delta"] }).message;
19371988
if (!choiceDelta) {
1989+
await cooperativeScheduler.afterEvent();
19381990
continue;
19391991
}
19401992
if (choiceDelta.content) {
@@ -2026,6 +2078,7 @@ async function processOpenAICompletionsStream(
20262078
}
20272079
}
20282080
flushPendingPostToolCallDeltas();
2081+
await cooperativeScheduler.afterEvent();
20292082
}
20302083
flushDeepSeekTextFilterAtEnd();
20312084
finishCurrentBlock();

0 commit comments

Comments
 (0)