Repository navigation
Retries and other continuing starts discard hybrid privacy holds and sticky destinations #314
Description
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, whichisTurnContinuationalready 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
fix(routing): a start that continues a logical turn keeps the turn's …
Merge main (e3b2c58) into fix/speaking-never-opens
Merge main (2482625) into petra/af247-fixes
fix(streaming): the outgoing stream carries only what delivery publishes
fix(routing): a code_execution script's calls count as its round's (a…
Merge main (0.22.1) into feat/dendrite: the turn-routing rework meets…