Repository navigation
feat(roleplay): let DeepSeek hand pivotal turns to GLM-5.3 - #92
Conversation
Chain entries named in ROLEPLAY_HANDOFF_FROM receive an internal function, multillm_hand_off_turn. When the source calls it instead of writing, the Worker discards that attempt and the first available ROLEPLAY_HANDOFF_TO entry writes the turn from the same conversation plus a private note with the source's notes and draft. If no target writes visible text, the draft is the reply. The call never reaches the client: frames are held until visible text appears, and a stray call after real content is removed. At most one handoff runs per ROLEPLAY_HANDOFF_MIN_TURNS turns (default 2); between handoffs the tool stays in the request with tool_choice "none" so the prompt prefix stays cacheable. Callers asking for xhigh or max reasoning start on the targets. Production lets DeepSeek V4.1 Flash on OpenCode Go hand off to GLM-5.3 on OpenCode Go, then on ClinePass. With the function offered, DeepSeek wrote all 12 ordinary test turns itself and handed off 4 of 6 climax and planning turns, each with a full draft; through the Worker code a handed-off turn took about 20 s. The target keeps its configured effort: at max, GLM-5.3 spent a 4,096-token budget reasoning and wrote nothing. A reply that finishes with no visible text now starts over on the next candidate, at most twice, including when the caller sets max_tokens, and fails with a retry message if none writes anything. ROLEPLAY_EMPTY_REPLY_FALLBACK_ENABLED=false turns this off.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6b62e95f89
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return ( | ||
| settings.emptyReplyFallbackEnabled === true && | ||
| remainingLaterCandidates.length > 0 && | ||
| isEmptyRoleplayReply(input) |
There was a problem hiding this comment.
Detect empty replies even after the candidate list is exhausted
When every candidate returns an empty streamed reply, remainingLaterCandidates is empty on the final leg, so this condition disables empty-reply detection altogether. The streaming completion then reports success: true and reason: complete, sending only reasoning and a normal stop instead of the promised retry message. I reproduced this with a two-model intelligence chain where both models returned reasoning followed by stop; the non-streaming equivalent correctly reports empty_reply_failed. Separate empty-reply classification from whether another retry is available so exhaustion still fails the turn.
Useful? React with 👍 / 👎.
| const next = await openFreshLeg("handoff", handoff); | ||
| if (usableLeg(next)) { | ||
| acceptFreshLeg(next, "handoff"); | ||
| } else if (handoff.draft) { |
There was a problem hiding this comment.
Try remaining handoff targets before using the draft
For a non-streaming handoff, an HTTP-successful but empty first target immediately selects the source draft, or fails the turn when no draft exists, without trying any remaining targets. attemptRoleplayCandidates advances only on transport/HTTP failures, so the single openFreshLeg call does not establish that all targets were unusable. I reproduced this with two configured targets: the first returned empty content, the second was configured to return a valid reply, but only the first was called. Continue through the remaining candidates before choosing the draft or declaring handoff_failed.
Useful? React with 👍 / 👎.
| const next = await openFreshLeg("empty_reply"); | ||
| if (!next) break; | ||
| acceptFreshLeg(next, "empty_reply"); | ||
| toolCalls = completionToolCalls(next.payload); |
There was a problem hiding this comment.
Recheck refusal handling after an empty-reply fallback
When a non-streaming empty reply falls through to GLM-5.3 Flash, transport can inject the private refusal-control instruction, but the replacement completion is accepted here without running refusal classification again. If that model returns [[MULTILLM_ROLEPLAY_FALLBACK]], the loop exits because the sentinel is nonempty, and the sentinel is returned to the caller and treated as a successful assistant response. I reproduced this with a DeepSeek → GLM Flash → uncensored Flash chain. Reapply refusal handling to each replacement leg and refresh its refusal-fallback candidates rather than publishing the private trigger.
Useful? React with 👍 / 👎.
| bufferedCompletionFrames( | ||
| template ?? collected.template, | ||
| handoffDraft, | ||
| "stop", | ||
| ), |
There was a problem hiding this comment.
Sanitize the template used to emit a fallback draft
If the source's last choice frame contains the handoff tool delta together with finish_reason: "tool_calls" and the target request fails, this fallback passes the unfiltered collector template to bufferedCompletionFrames. That helper removes only delta.content from its terminal frame, preserving delta.tool_calls, so the supposedly private function call and all its notes and draft arguments reach the client. I reproduced this with a combined tool/finish frame followed by a target transport failure. Sanitize the template or filter the synthesized frames before enqueueing them; filtering the original source frames does not cover this output path.
Useful? React with 👍 / 👎.
| const message = first.message && typeof first.message === "object" | ||
| ? { ...first.message } | ||
| ? withoutRoleplayHandoffToolCalls({ ...first.message }) | ||
| : { role: "assistant" }; |
There was a problem hiding this comment.
Normalize the finish reason after removing the internal call
For a non-streaming source completion containing both visible text and a handoff call, handoff detection intentionally keeps the source's reply, and this helper removes its internal tool call. However, the original finish_reason: "tool_calls" is retained, leaving the client with a tool-call completion that contains no tool calls to execute. I reproduced that response shape with a source story plus the internal call; the streaming filter already changes the equivalent finish reason to stop. Apply the same normalization here when the removed handoff was the only tool call.
Useful? React with 👍 / 👎.
| if (handoffDraft) { | ||
| assistant = handoffDraft; | ||
| enqueueFrames( | ||
| controller, | ||
| bufferedCompletionFrames( |
There was a problem hiding this comment.
Validate the fallback draft against the caller's output contract
When a streamed handoff falls back to the source draft, this branch emits it directly and later treats any nonempty draft as successful, without assessing or repairing its output contract. For an unlimited Janitor turn requiring a final IMAGE PROMPT block, I reproduced a target returning 429 and a story-only draft being emitted and persisted as a successful handoff_draft, despite having no image block. Ordinary replies go through contract assessment and repair, and the non-streaming draft path also assesses the draft. Route the streaming draft through the same validation and repair process instead of unconditionally completing it.
Useful? React with 👍 / 👎.
DeepSeek V4.1 Flash stays first in the roleplay intelligence chain. For pivotal or complex turns it can now hand off to GLM-5.3, which writes the reply from DeepSeek's notes and optional draft.
How the handoff works
ROLEPLAY_HANDOFF_FROM) are offered an internal function,multillm_hand_off_turn, with a reason (deeper_reasoningorstronger_prose), notes and an optional draft.ROLEPLAY_HANDOFF_TO) then writes the turn. The source's notes and draft go in a private system note placed before the last user message.finish_reason: tool_callsbecomesstopwhen the handoff was the only call.ROLEPLAY_HANDOFF_MIN_TURNSturns (default 2). In between, the tool stays in the request withtool_choice: "none", so the prompt prefix stays cacheable.xhighormaxreasoning start on the targets.max, GLM-5.3 spent a 4,096-token budget on reasoning and wrote nothing.Empty replies
A leg that finishes with no visible text and no caller tool call now falls through to the next candidate. This is capped at two attempts per turn, and the turn fails with
empty_reply_failedif all of them are empty. Turn it off withROLEPLAY_EMPTY_REPLY_FALLBACK_ENABLED=false. The retry is skipped when the request sets fallbacknoneand when a session tier is active.Measurements (live OpenCode Go)
Tests
tests/test_roleplay_handoff_worker.mjs(14 tests) covers:<think>content, no finish frame);