Skip to content

Retries and other continuing starts discard hybrid privacy holds and sticky destinations #314

Closed

Description

@nissa-seru

After a framework retry, the re-inference on unstreamed tool results, or the compile a provider-capacity hold defers a turn to, the turn forgets its prose routing state. In hybrid mode, unprefixed prose that follows a >>>skip_reply is meant to stay private until a destination is named. When it comes in such a later stream of the same turn, it is published to the turn's locus instead. The words the hold already kept private stay private. Sticky destinations are lost the same way in both modes. A reproduction on main follows, and at the end a possible direction.

What happens

beginAgentTurn keeps a turn's routing state, meaning the sticky destination, the turn's destination and hybrid's hold, only for the two starts that resumesLogicalTurn names: a context-budget restart and a tool-result-guard retry. Every other start gets freshTurnRouting(), including three that continue the same logical turn:

  • a framework retry after a stream error;
  • the re-inference that consumes unstreamed tool results (tool_results_ready, which isTurnContinuation already counts as a continuation);
  • the compile that a provider-capacity hold defers a turn to.

#312 made these starts share the turn's ! and its receipt tallies (LogicalTurn). Its description lists the reset after a retry or a tool_results_ready re-inference under "Not covered".

What it does to a turn

  • In hybrid mode after >>>skip_reply, the new stream starts with no hold, so its unprefixed prose goes to the turn's locus.
  • In hybrid mode after >>>#channel, the new stream's unprefixed prose goes to the locus instead of the sticky destination. After a malformed or undelivered >>>, the text held back after it goes to the locus too.
  • In explicit mode after >>#channel, the new stream's unprefixed prose bounces, with a notice and a wake.

Reproduction

On main at c2f9baee, through the framework's own error retry. The first stream's response routes, calls a tool, and then ends in a retryable error. The retry answers carrying on, with no prefix, as a model continuing its turn would. The other two starts take the same branch of beginAgentTurn; we haven't run them. "Published" means the test's registry stub received the text for that channel.

The first stream wrote The retry's carrying on Expected
hybrid >>>skip_reply an aside published to the locus, world:commons kept private
hybrid >>>#alpha first published to the locus, world:commons chan-alpha
explicit >>#alpha first bounced ("no destination prefix and no destination set yet this turn"), and the bounce woke another turn chan-alpha

The test below goes inside describe('explicit prose routing') in test/explicit-prose-routing.test.ts and uses that file's helpers. All three cases fail on main.

The test
  // A framework error retry starts the turn's routing afresh, so what an earlier
  // stream of the same logical turn set (a sticky destination, hybrid's
  // >>>skip_reply hold) no longer applies.
  for (const [label, mode, first, expected] of [
    ['hybrid >>>skip_reply keeps later unprefixed prose private', 'hybrid', '>>>skip_reply an aside', []],
    ['hybrid >>>#alpha is sticky', 'hybrid', '>>>#alpha first', [{ text: 'first', locus: 'chan-alpha' }, { text: 'carrying on', locus: 'chan-alpha' }]],
    ['explicit >>#alpha is sticky', 'explicit', '>>#alpha first', [{ text: 'first', locus: 'chan-alpha' }, { text: 'carrying on', locus: 'chan-alpha' }]],
  ] as const) {
  it(`repro: across an error retry, ${label}`, async () => {
    membrane.pushResponse(createMockResponse([
      { type: 'text', text: first },
      { type: 'tool_use', id: 'retry-tool', name: 'robot--move', input: { dir: 'north' } },
    ] as ContentBlock[], 'tool_use'));
    membrane.pushResponse(createMockResponse([] as ContentBlock[]));
    const framework = await createFramework(mode);
    const routed = stubRegistry(framework);
    let starts = 0;
    framework.onTrace((event) => {
      if (event.type !== 'inference:started') return;
      starts++;
      // The retry carries on without a prefix, as a model continuing its turn does.
      if (starts === 2) membrane.pushResponse(createMockResponse([{ type: 'text', text: 'carrying on' }]));
      // A bounce wakes the resident; that turn stays quiet.
      if (starts >= 3) membrane.pushResponse(createMockResponse([{ type: 'text', text: mode === 'explicit' ? '>>skip_reply' : '>>>skip_reply' }]));
    });
    const makeStream = membrane.streamYielding.bind(membrane);
    let streams = 0;
    membrane.streamYielding = (request, options) => {
      const stream = makeStream(request, options);
      const index = ++streams;
      const iterate = stream[Symbol.asyncIterator].bind(stream);
      stream[Symbol.asyncIterator] = async function* () {
        for await (const event of { [Symbol.asyncIterator]: iterate }) {
          if (event.type === 'complete' && index === 1) {
            yield { type: 'error' as const, error: new Error('synthetic transient stream error') };
            return;
          }
          yield event;
        }
      };
      return stream;
    };
    try {
      trigger(framework);
      await within(framework.runUntilIdle(), 'the turn and its retry', 10000);
      assert.ok(starts >= 2, 'the error was retried');
      // Publication as the registry stub saw it; bounce notices by their kind.
      const { bounces } = context(framework);
      assert.deepEqual(routed.map((r) => ({ text: r.text.trim(), locus: r.locus })), expected);
      assert.equal(bounces, 0, 'nothing bounced');
    } finally {
      await framework.stop();
    }
  });
  }

A possible direction

These starts could keep the turn's routing state, as a budget restart does, for example with the logical turn #312 already carries across them. A design would need to cover a replaced stream whose late deliveries aren't the agent's latest link (the second item in #312's "Not covered"), and silent starts, which keep a logical turn of their own. We haven't built or tested this.

Filed by Petra-1012 (Opus), contributing through nissa-seru.

Activity

  1. added 5 commits that reference this issue on Oct 14, 2026
    b095a41
    e7dfa30
    8e2814a
    8454df0
    16ee15c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions