Skip to content

Do not continue after terminal or aborted handoff turns #30

Description

@boadij

Summary

Downshift has two related lifecycle paths that can enqueue continuation after work should have stopped:

  1. A handoff steering message can be delivered after the premium model has already produced a terminal final response.
  2. Aborting handoff generation with Escape still causes Downshift to treat the handoff as complete, switch to economy, and enqueue the continuation message.

Both cases reopen a run after the user or model has already ended it.

Verified lifecycle

Terminal response before queued handoff

Current Downshift behavior:

  • src/downshift.ts evaluates the threshold from the context event.
  • While an agent run is active, ctx.isIdle() is false, so maybeDownshift() receives "steer" delivery.
  • requestHandoff() sets handoff: "requested", sets continueAfterHandoff: true, and calls sendUserMessage(..., { deliverAs: "steer" }).
  • After the handoff completes, completeHandoffAndSwitch() switches to economy and queues the CONTINUE_MARKER prompt as a follow-up.

Pi v0.80.6 behavior:

  • The context hook runs before each LLM call.
  • Steering messages are drained after the current assistant turn and its tool calls finish.
  • A steering message therefore becomes the next turn even when the current assistant response was a final answer with no tool calls.

The resulting ordering can be:

context threshold check
  -> queue HANDOFF_MARKER as steer
  -> premium model returns final answer
  -> Pi delivers queued HANDOFF_MARKER
  -> premium model writes handoff note
  -> Downshift switches to economy
  -> Downshift queues CONTINUE_MARKER
  -> economy model continues an already completed task

Abort during handoff generation

Pi represents an aborted assistant response with stopReason: "aborted", emits turn_end, then emits agent_end without polling steering or follow-up queues.

Downshift currently does not inspect the handoff assistant message result. handleAgentEnd() considers any handoff: "active" state completed and calls completeHandoffAndSwitch().

Because a steered handoff sets continueAfterHandoff: true, aborting handoff generation can produce this ordering:

HANDOFF_MARKER turn starts on premium
  -> user presses Escape
  -> assistant message ends with stopReason: "aborted"
  -> Pi emits agent_end
  -> Downshift marks handoff done
  -> Downshift switches to economy
  -> Downshift queues CONTINUE_MARKER as follow-up
  -> a new economy run starts despite the user's abort

The abort is therefore incorrectly treated as a successful handoff.

Reproduction

Terminal-response case

  1. Enable Downshift with handoffBeforeDownshift: true.
  2. Configure a threshold that is reached immediately before an LLM call.
  3. Run a task where that premium-model call returns a final assistant response without tool calls.
  4. Observe the session after the final response.

Aborted-handoff case

  1. Enable Downshift with handoffBeforeDownshift: true.
  2. Trigger a threshold crossing during active work so the handoff is delivered as steer.
  3. Wait until the premium model begins writing the handoff note.
  4. Press Escape to abort the handoff generation.
  5. Observe that Downshift switches to economy and sends the continuation prompt.

Current behavior

Terminal response

The final response is followed by a handoff turn and an economy continuation turn, even though the premium model already completed the task.

Aborted handoff

The aborted handoff is marked done. Downshift switches to economy and sends CONTINUE_MARKER, overriding the user's explicit request to stop.

These paths add unnecessary tokens, create misleading conversation history, and may duplicate completed work or restart intentionally aborted work.

Expected behavior

A terminal assistant response must remain terminal, and a user abort must remain an abort.

After a terminal response

  • Do not append or deliver a HANDOFF_MARKER instruction after the final response.
  • Do not generate a handoff note for completed work.
  • Do not enqueue the CONTINUE_MARKER prompt.
  • Transition to economy at a safe boundary, or record a deterministic deferred transition, without reopening the completed task.

After an aborted handoff

  • Do not treat an assistant message with stopReason: "aborted" as a completed handoff.
  • Do not enqueue CONTINUE_MARKER.
  • Do not automatically start another agent run.
  • Clear continueAfterHandoff and leave no stale pending handoff state.
  • Preserve the user's abort as the final action.
  • Use a deterministic model/position policy after the abort. Prefer remaining on premium or pausing rather than silently continuing on economy.

For genuinely continuing tool-driven work, the existing premium handoff followed by economy continuation should remain available.

Proposed direction

Move decisions that require knowledge of the assistant result out of the pre-call context hook and validate handoff completion explicitly.

A likely approach is:

  1. Use context only to record that the threshold has been reached for the active call.
  2. Decide at turn_end whether the assistant turn is continuing, terminal, errored, or aborted.
  3. If more agent work is required, queue the handoff before the next LLM call.
  4. If the original turn is terminal, switch or defer the model transition without generating a handoff or continuation.
  5. During a handoff turn, mark the handoff complete only when its assistant message finished successfully.
  6. On stopReason: "aborted" or "error", clear continuation intent and enter a stable non-continuing state.
  7. Clear pending transition state on pause, tree navigation, reload, failed model activation, and other interruption paths.

The exact implementation may differ, but it must not:

  • use ctx.isIdle() inside context as a proxy for whether the current LLM call will continue,
  • equate agent_end with successful handoff completion, or
  • override an explicit user abort by enqueueing follow-up work.

Non-goals

  • Do not remove handoff notes for active multi-turn work.
  • Do not change explicit /downshift now semantics unless required for consistent abort handling.
  • Do not change threshold comparison semantics.
  • Do not infer task completion from assistant prose.
  • Do not add prompt classification or general model-routing behavior.

Acceptance criteria

Terminal turns

  • A threshold reached before a terminal assistant response does not cause a HANDOFF_MARKER message after that response.
  • No CONTINUE_MARKER message is queued for a task that already ended with a terminal assistant response.
  • Downshift reaches a stable non-pending state after the terminal response.
  • The configured economy model becomes the next active target, either immediately at the safe terminal boundary or through a clearly defined deferred transition.

Aborted handoffs

  • Pressing Escape during handoff generation does not enqueue CONTINUE_MARKER.
  • Pressing Escape during handoff generation does not start a new economy agent run.
  • An aborted handoff is not marked as successfully completed.
  • continueAfterHandoff is cleared after abort.
  • No requested or active handoff state remains after abort.
  • The post-abort position and model behavior is explicit, deterministic, and covered by tests.
  • An errored handoff follows the same non-continuation safety rule unless a deliberately different policy is documented.

Continuing and safety paths

  • A threshold reached during continuing tool-driven work still produces one handoff and one economy continuation.
  • No duplicate handoff is generated across repeated lifecycle events.
  • Failed target activation preserves the existing pause-and-error safety behavior.
  • Reload, session restore, and tree navigation cannot restore a stale pending transition.

Test coverage

Add core and adapter-level regression coverage for these event sequences.

Terminal turn

context(threshold reached)
turn_end(final assistant message, no tool calls)
agent_end

Assert:

  • no handoff user message is sent after the final response,
  • no continuation user message is sent,
  • no pending handoff remains,
  • the final model/position state follows the defined terminal-transition behavior.

Continuing turn

context(threshold reached)
turn_end(assistant tool call + tool result)
next LLM call

Assert:

  • exactly one steering handoff is delivered before the next work call,
  • the handoff completes on premium,
  • the model switches once to economy,
  • exactly one continuation prompt is queued.

Aborted handoff

before_agent_start(HANDOFF_MARKER)
turn_end(assistant stopReason: aborted)
agent_end

Assert:

  • the handoff is not treated as complete,
  • no economy continuation message is sent,
  • no new run starts,
  • continueAfterHandoff is false,
  • the handoff state is stable and non-pending,
  • the selected post-abort model/position policy is applied exactly once.

Errored handoff

Repeat the aborted-handoff assertions with stopReason: "error".

Safety paths

Cover repeated lifecycle events, failed economy activation, reload, session restore, and session-tree navigation while a threshold transition is pending.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions