Skip to content

feat(roleplay): let DeepSeek hand pivotal turns to GLM-5.3 - #92

Merged
ALikesToCode merged 1 commit into
mainfrom
feat/roleplay-handoff
Oct 25, 2026
Merged

ALikesToCode merged 1 commit into
mainfrom
feat/roleplay-handoff

Conversation

@ALikesToCode

Copy link
Copy Markdown
Owner

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

  • Source legs (ROLEPLAY_HANDOFF_FROM) are offered an internal function, multillm_hand_off_turn, with a reason (deeper_reasoning or stronger_prose), notes and an optional draft.
  • If the source calls it without writing visible text, the Worker discards that leg. The first available target in chain order (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.
  • Clients never see the function or its call. The frame filter strips the call's deltas, and finish_reason: tool_calls becomes stop when the handoff was the only call.
  • If every target fails or writes nothing visible, the source's draft becomes the reply.
  • A session gets at most one handoff every ROLEPLAY_HANDOFF_MIN_TURNS turns (default 2). In between, 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.
  • The target keeps the configured effort. At 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_failed if all of them are empty. Turn it off with ROLEPLAY_EMPTY_REPLY_FALLBACK_ENABLED=false. The retry is skipped when the request sets fallback none and when a session tier is active.

Measurements (live OpenCode Go)

  • With the production tool description, direct probes handed off 0 of 12 ordinary scenes and 4 of 6 pivotal ones.
  • Through the Worker code, 1 of 4 pivotal turns handed off, taking about 20 s in total. DeepSeek-only turns take 4–12 s.
  • On legs where the handoff is offered, reasoning is held until visible text appears.

Tests

  • tests/test_roleplay_handoff_worker.mjs (14 tests) covers:
    • streamed and non-streamed handoffs;
    • the draft fallback;
    • the turn gap;
    • effort routing;
    • container-normalized frames (reasoning as <think> content, no finish frame);
    • the empty-reply retry and its switch;
    • the helpers and the frame filter.
  • Config, runtime flag count, secret-firewall coverage and the outbound inventory are updated.

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.
@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3b9578e4-8b28-42c8-9ebe-a4fcde3d9b83
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T07:31:25.168202Z 6b62e95 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +406 to +409
return (
settings.emptyReplyFallbackEnabled === true &&
remainingLaterCandidates.length > 0 &&
isEmptyRoleplayReply(input)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +232 to +235
const next = await openFreshLeg("handoff", handoff);
if (usableLeg(next)) {
acceptFreshLeg(next, "handoff");
} else if (handoff.draft) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +263 to +266
const next = await openFreshLeg("empty_reply");
if (!next) break;
acceptFreshLeg(next, "empty_reply");
toolCalls = completionToolCalls(next.payload);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +649 to +653
bufferedCompletionFrames(
template ?? collected.template,
handoffDraft,
"stop",
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines 57 to 59
const message = first.message && typeof first.message === "object"
? { ...first.message }
? withoutRoleplayHandoffToolCalls({ ...first.message })
: { role: "assistant" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +645 to +649
if (handoffDraft) {
assistant = handoffDraft;
enqueueFrames(
controller,
bufferedCompletionFrames(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@ALikesToCode
ALikesToCode merged commit f99e31a into main Oct 25, 2026
4 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant