Skip to content

Commit cd1ba09

Browse files
committed
feat: stream supervisor responses + concurrent tool calls (0.4.0)
Chat-completions now streams progressively: live tool-round progress markers as the turn runs, then the final answer — instead of blocking until the whole turn finishes. Tool calls within a round run concurrently; default maxToolRounds 8->12; round-limit message is now a calm status, not an error. Verified live: SSE chunks arrive incrementally. Removed the dead post-hoc word-streamer. 110 tests pass. (Note: build orchestration 'bun run build' has a Windows-only nested-bun quirk; sub-builds + typechecks all pass; CI builds on ubuntu.)
1 parent b971016 commit cd1ba09

11 files changed

Lines changed: 154 additions & 93 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ All notable changes to this project are documented here.
44

55
Format based on [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [0.4.0] - 2026-06-24
8+
9+
### Added
10+
11+
- **Streaming supervisor responses.** The chat-completions endpoint now streams progressively: as the turn runs its tool rounds (the slow part), live markers (`→ start_loop`, `→ loop_status`, …) appear, then the final answer streams — instead of hanging silently until the whole turn finishes and dumping it at once. Verified live: SSE `chat.completion.chunk`s arrive incrementally.
12+
13+
### Changed
14+
15+
- Supervisor **tool calls within a round run concurrently** now (was sequential), and the default **`maxToolRounds` is raised 8 → 12**. Hitting the round limit no longer reads as an error — it's a calm "I've taken several steps … ask me to continue" status. (Surfaced by the end-to-end smoke loop, where the prior limit/error was easy to hit.)
16+
17+
### Notes
18+
19+
- The end-to-end smoke loop validated the full stack live (auto-start, credential auto-detect, orchestration, the `ralph-worker` agent, and a real worker creating a file until `verify` passed). It also surfaced a footgun to address next: when no worker model is set, the worker can fall back to the `ralph-rlm/supervisor` model — set `worker.providerID`/`worker.modelID` in `ralph-provider.json`.
20+
721
## [0.3.10] - 2026-06-24
822

923
### Changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@doeixd/opencode-ralph-rlm",
3-
"version": "0.3.10",
3+
"version": "0.4.0",
44
"description": "Ralph RLM v0.2: OpenCode supervisor provider + loop engine + worker plugin",
55
"type": "module",
66
"workspaces": [

packages/engine/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@doeixd/opencode-ralph-rlm-engine",
33
"private": true,
4-
"version": "0.3.10",
4+
"version": "0.4.0",
55
"description": "Ralph RLM loop engine — verify, rollover, OpenCode SDK worker lifecycle",
66
"license": "MIT",
77
"repository": {

packages/provider/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@doeixd/opencode-ralph-rlm-provider",
33
"private": true,
4-
"version": "0.3.10",
4+
"version": "0.4.0",
55
"description": "Ralph RLM OpenAI-compatible supervisor provider (Nitro)",
66
"license": "MIT",
77
"repository": {

packages/provider/server/lib/openai-compat.ts

Lines changed: 3 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -87,55 +87,6 @@ export function encodeSseChunk(payload: Record<string, unknown>): string {
8787
return `data: ${JSON.stringify(payload)}\n\n`;
8888
}
8989

90-
export function streamCompletionText(
91-
request: OpenAIChatCompletionRequest,
92-
text: string
93-
): ReadableStream<Uint8Array> {
94-
const id = makeCompletionId();
95-
const model = request.model ?? "ralph-rlm/supervisor";
96-
const created = Math.floor(Date.now() / 1000);
97-
const encoder = new TextEncoder();
98-
99-
return new ReadableStream({
100-
start(controller) {
101-
const words = text.split(/(\s+)/);
102-
let index = 0;
103-
104-
const push = () => {
105-
if (index >= words.length) {
106-
controller.enqueue(
107-
encoder.encode(
108-
encodeSseChunk({
109-
id,
110-
object: "chat.completion.chunk",
111-
created,
112-
model,
113-
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
114-
})
115-
)
116-
);
117-
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
118-
controller.close();
119-
return;
120-
}
121-
122-
const piece = words[index] ?? "";
123-
index += 1;
124-
controller.enqueue(
125-
encoder.encode(
126-
encodeSseChunk({
127-
id,
128-
object: "chat.completion.chunk",
129-
created,
130-
model,
131-
choices: [{ index: 0, delta: { content: piece }, finish_reason: null }],
132-
})
133-
)
134-
);
135-
push();
136-
};
137-
138-
push();
139-
},
140-
});
141-
}
90+
// Streaming is now assembled in the chat-completions route (it interleaves live
91+
// tool-round progress with the final answer); the old post-hoc word-streamer was
92+
// removed in favor of that real progressive stream.

packages/provider/server/lib/supervisor-agent.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ async function runTestModeTurn(
165165

166166
async function runLlmTurn(
167167
input: SupervisorTurnInput,
168-
ctx: SupervisorToolContext
168+
ctx: SupervisorToolContext,
169+
onProgress?: (text: string) => void
169170
): Promise<SupervisorTurnResult> {
170171
const config = await loadSupervisorLlmConfig(ctx.worktree);
171172

@@ -205,23 +206,31 @@ async function runLlmTurn(
205206
})),
206207
});
207208

209+
// Surface progress to the streaming client as each round's tools run — this
210+
// is the slow part of a turn, so live markers beat a silent wait.
208211
for (const call of result.toolCalls) {
209-
const output = await executeSupervisorTool(
210-
call.name,
211-
parseToolArgs(call.arguments),
212-
ctx
213-
);
212+
onProgress?.(`_→ ${call.name}_\n`);
213+
}
214+
215+
// Tool calls within a round are independent — run them concurrently, then
216+
// append results in the model's original order.
217+
const outputs = await Promise.all(
218+
result.toolCalls.map((call) =>
219+
executeSupervisorTool(call.name, parseToolArgs(call.arguments), ctx)
220+
)
221+
);
222+
result.toolCalls.forEach((call, i) => {
214223
conversation.push({
215224
role: "tool",
216225
tool_call_id: call.id,
217-
content: output,
226+
content: outputs[i] ?? "",
218227
});
219-
}
228+
});
220229
}
221230

222231
const status = await executeSupervisorTool("loop_status", {}, ctx);
223232
return {
224-
content: `Reached tool round limit. Current status:\n${status}`,
233+
content: `I've taken several steps but haven't fully wrapped up this turn. Current status:\n${status}\n\nAsk me to continue if you'd like me to keep going.`,
225234
toolRounds,
226235
mode: "llm",
227236
};
@@ -261,3 +270,23 @@ export async function supervisorTurn(
261270
isTestMode() ? runTestModeTurn(input, ctx) : runLlmTurn(input, ctx)
262271
);
263272
}
273+
274+
/**
275+
* Streaming variant: runs the same serialized turn but calls `onProgress` with
276+
* live markers as tool rounds execute, and returns the final result (whose
277+
* `content` the caller streams to the client). Test mode has no tool rounds, so
278+
* it emits no progress — just the scripted result.
279+
*/
280+
export async function supervisorTurnStreaming(
281+
input: SupervisorTurnInput,
282+
onProgress: (text: string) => void
283+
): Promise<SupervisorTurnResult> {
284+
const ctx: SupervisorToolContext = {
285+
sessionKey: input.sessionKey,
286+
worktree: input.worktree,
287+
};
288+
289+
return withSessionTurnLock(input.sessionKey, () =>
290+
isTestMode() ? runTestModeTurn(input, ctx) : runLlmTurn(input, ctx, onProgress)
291+
);
292+
}

packages/provider/server/lib/supervisor-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export async function loadSupervisorLlmConfig(
6666
const maxToolRounds = toBoundedInt(
6767
fileConfig.supervisor?.maxToolRounds ??
6868
Number(process.env.RALPH_SUPERVISOR_MAX_TOOL_ROUNDS),
69-
8,
69+
12,
7070
1,
7171
24
7272
);

packages/provider/server/routes/api/health.get.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default defineHandler(async () => {
1616
return {
1717
healthy: true,
1818
provider: "@doeixd/opencode-ralph-rlm",
19-
version: "0.3.10",
19+
version: "0.4.0",
2020
opencode: {
2121
baseUrl: runtime.baseUrl,
2222
...opencode,

packages/provider/server/routes/v1/chat/completions.post.ts

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { defineHandler, readBody } from "nitro/h3";
22
import {
33
buildCompletionResponse,
4-
streamCompletionText,
4+
encodeSseChunk,
5+
makeCompletionId,
56
type OpenAIChatCompletionRequest,
67
} from "../../../lib/openai-compat.js";
78
import {
@@ -14,7 +15,7 @@ import {
1415
logSessionDebug,
1516
} from "../../../lib/session-debug.js";
1617
import { resolveWorktree } from "../../../lib/worktree.js";
17-
import { supervisorTurn } from "../../../lib/supervisor-agent.js";
18+
import { supervisorTurn, supervisorTurnStreaming } from "../../../lib/supervisor-agent.js";
1819

1920
export default defineHandler(async (event): Promise<Response> => {
2021
const body = await readBody<OpenAIChatCompletionRequest>(event);
@@ -53,46 +54,96 @@ export default defineHandler(async (event): Promise<Response> => {
5354
);
5455
}
5556

56-
let turn;
57-
try {
58-
turn = await supervisorTurn({
59-
sessionKey: session.sessionKey,
60-
worktree,
61-
messages: body.messages,
62-
...(body.model ? { model: body.model } : {}),
63-
});
64-
} catch (err) {
65-
const message = err instanceof Error ? err.message : String(err);
66-
return new Response(
67-
JSON.stringify({
68-
error: { message, type: "api_error" },
69-
}),
70-
{ status: 500, headers: { "content-type": "application/json" } }
71-
);
72-
}
73-
74-
const headers = {
57+
const baseHeaders = {
7558
"x-ralph-session-key": session.sessionKey,
7659
"x-ralph-session-source": session.source,
77-
"x-ralph-supervisor-mode": turn.mode,
7860
};
7961

62+
const turnInput = {
63+
sessionKey: session.sessionKey,
64+
worktree,
65+
messages: body.messages,
66+
...(body.model ? { model: body.model } : {}),
67+
};
68+
69+
// Streaming: emit live progress markers as the turn's tool rounds run (the
70+
// slow part), then stream the final answer — instead of blocking until the
71+
// whole turn finishes and dumping it at once.
8072
if (body.stream) {
81-
const stream = streamCompletionText(body, turn.content);
73+
const id = makeCompletionId();
74+
const model = body.model ?? "ralph-rlm/supervisor";
75+
const created = Math.floor(Date.now() / 1000);
76+
const encoder = new TextEncoder();
77+
const delta = (content: string) =>
78+
encoder.encode(
79+
encodeSseChunk({
80+
id,
81+
object: "chat.completion.chunk",
82+
created,
83+
model,
84+
choices: [{ index: 0, delta: { content }, finish_reason: null }],
85+
})
86+
);
87+
88+
const stream = new ReadableStream<Uint8Array>({
89+
async start(controller) {
90+
// An immediate keep-alive delta so the client shows activity at once.
91+
controller.enqueue(delta(""));
92+
try {
93+
const turn = await supervisorTurnStreaming(turnInput, (text) =>
94+
controller.enqueue(delta(text))
95+
);
96+
for (const piece of (turn.content || "Done.").split(/(\s+)/)) {
97+
if (piece) controller.enqueue(delta(piece));
98+
}
99+
} catch (err) {
100+
const message = err instanceof Error ? err.message : String(err);
101+
controller.enqueue(delta(`\n\n[error] ${message}`));
102+
}
103+
controller.enqueue(
104+
encoder.encode(
105+
encodeSseChunk({
106+
id,
107+
object: "chat.completion.chunk",
108+
created,
109+
model,
110+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
111+
})
112+
)
113+
);
114+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
115+
controller.close();
116+
},
117+
});
118+
82119
return new Response(stream, {
83120
headers: {
84-
...headers,
121+
...baseHeaders,
85122
"content-type": "text/event-stream; charset=utf-8",
86123
"cache-control": "no-cache",
87124
connection: "keep-alive",
88125
},
89126
});
90127
}
91128

129+
let turn;
130+
try {
131+
turn = await supervisorTurn(turnInput);
132+
} catch (err) {
133+
const message = err instanceof Error ? err.message : String(err);
134+
return new Response(
135+
JSON.stringify({
136+
error: { message, type: "api_error" },
137+
}),
138+
{ status: 500, headers: { "content-type": "application/json" } }
139+
);
140+
}
141+
92142
const completion = buildCompletionResponse(body, turn.content);
93143
return new Response(JSON.stringify(completion), {
94144
headers: {
95-
...headers,
145+
...baseHeaders,
146+
"x-ralph-supervisor-mode": turn.mode,
96147
"content-type": "application/json",
97148
},
98149
});

packages/provider/server/test/supervisor-agent.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import path from "node:path";
22
import { mkdtemp, cp, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
5-
import { supervisorTurn } from "../lib/supervisor-agent.js";
5+
import { supervisorTurn, supervisorTurnStreaming } from "../lib/supervisor-agent.js";
66
import { loopRegistry } from "../lib/loop-registry.js";
77
import { getOpencodeRuntime } from "../lib/runtime.js";
88
import { createProviderMockRuntime, mockSubscribe } from "./mock-runtime.js";
@@ -60,6 +60,22 @@ describe("supervisorTurn (RALPH_TEST_MODE)", () => {
6060
expect(engine?.state.attempt).toBe(1);
6161
});
6262

63+
test("supervisorTurnStreaming returns the same result and starts the loop", async () => {
64+
const progress: string[] = [];
65+
const turn = await supervisorTurnStreaming(
66+
{
67+
sessionKey: "sess-1",
68+
worktree,
69+
messages: [{ role: "user", content: "Implement marker file; tests must pass" }],
70+
},
71+
(text) => progress.push(text)
72+
);
73+
74+
expect(turn.mode).toBe("test");
75+
expect(turn.content.toLowerCase()).toContain("attempt 1");
76+
expect(loopRegistry.get("sess-1")?.state.started).toBe(true);
77+
});
78+
6379
test("returns status on status request", async () => {
6480
await supervisorTurn({
6581
sessionKey: "sess-2",

0 commit comments

Comments
 (0)