Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/non-channel-origin-locus.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **Residents operated from WebUI/TUI/CLI/headless/API while also present in
channels:** a turn triggered from a non-channel surface no longer infers a
channel locus (home, active, or process-global last-inbound). Plain prose on
such a turn stays with the surface that triggered it; reaching a channel
requires an explicit send tool. Channel-triggered and heartbeat turns are
unchanged. Closes the case where a private WebUI reply was published to the
Discord channel that last spoke.
79 changes: 66 additions & 13 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,8 @@ export class AgentFramework {
* author sees the segment's fate one turn later. Cleared each fresh
* turn; budget restarts keep it. */
private turnProseSuppressed: Map<string, number> = new Map();
/** Subset suppressed specifically by a private non-channel turn. */
private turnPrivateProseSuppressed: Map<string, number> = new Map();
/** A tool boundary injected fresh CONVERSATIONAL input (a real message —
* not a reaction or a system marker) into the live stream. Tells
* driveStream to clear sticky explicit-send suppression before handling
Expand Down Expand Up @@ -6588,6 +6590,7 @@ export class AgentFramework {
// updates lastAnnouncedLocus so the next turn's announce-on-change
// diffs against what the agent was actually last told.
if (
this.activeTurnTriggers.get(agent.name)?.nonChannelOrigin !== true &&
(agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') &&
!shouldEndTurn && !overBudget && currentState.stream
) {
Expand Down Expand Up @@ -6858,6 +6861,9 @@ export class AgentFramework {
reason: event.type,
source,
timestamp: Date.now(),
nonChannelOrigin:
(event.type === 'external-message' && ['tui', 'cli', 'headless'].includes(source))
|| event.type === 'api:message',
});
}
}
Expand Down Expand Up @@ -8178,6 +8184,14 @@ export class AgentFramework {
// requests[0] in that mixed batch bypassed the turn lock because a
// restart existed, then treated the continuation as a fresh turn.
const trigger = budgetRestart ?? requests[0];
if (trigger.nonChannelOrigin) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Private reply can publish When a silent heartbeat is queued before a private WebUI/API message, this check sees only the heartbeat and does not separate the private request. The mixed batch also removes the heartbeat’s prose suppression. If the resident has a home or last-inbound channel, a reply informed by the private message can be published there instead of staying private.

How this was verified: The private message enters the same request batch, but the selected trigger has no private-origin flag and the mixed-batch suppression check permits channel speech delivery.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8187

Comment:
**Private reply can publish** When a silent heartbeat is queued before a private WebUI/API message, this check sees only the heartbeat and does not separate the private request. The mixed batch also removes the heartbeat’s prose suppression. If the resident has a home or last-inbound channel, a reply informed by the private message can be published there instead of staying private.

**How this was verified:** The private message enters the same request batch, but the selected trigger has no private-origin flag and the mixed-batch suppression check permits channel speech delivery.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

// A private surface wake is its own turn. Channel-bearing siblings are
// not merely stripped: requeue them so their addressed/channel context
// receives a distinct subsequent turn.
const channelSiblings = requests.filter((r) => r !== trigger && !!r.channelId);
if (channelSiblings.length > 0) this.pendingRequests.push(...channelSiblings);
requests = requests.filter((r) => r === trigger || !r.channelId);
}
// Route this turn's auto-published speech to the channel that triggered
// it (item-3 redux). A batched wake may carry several triggering channels
// (messages arrived in >1 channel while the agent was busy/idle):
Expand Down Expand Up @@ -8219,8 +8233,8 @@ export class AgentFramework {
...trigger,
suppressProse: silentOnly ? trigger?.suppressProse : undefined,
ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined,
channelId: channelReq?.channelId,
addressed: addressedReq !== undefined,
channelId: trigger.nonChannelOrigin ? undefined : channelReq?.channelId,
addressed: trigger.nonChannelOrigin ? false : addressedReq !== undefined,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
// A context-budget restart continues the same logical turn: it keeps
// the channel for routing but names no author — the restart is its
// own cause, and borrowing another request's author would be false
Expand All @@ -8238,6 +8252,16 @@ export class AgentFramework {
this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count);
}

/** Keep prose from a private non-channel turn in Chronicle/WebUI only. */
private suppressPrivateTurnProse(agentName: string, count: number): void {
if (count <= 0) return;
console.error(`[routing] ${agentName}: prose NOT auto-published (private non-channel turn)`);
this.recordProseSuppression(agentName, count);
this.turnPrivateProseSuppressed.set(
agentName, (this.turnPrivateProseSuppressed.get(agentName) ?? 0) + count,
);
}

/** Record a successful plain-prose delivery for this turn's receipt. */
private recordProseDelivery(
agentName: string,
Expand Down Expand Up @@ -8265,12 +8289,14 @@ export class AgentFramework {
* delivered nothing. Failures are already marked separately
* ([discord-send-failed]); this is the success half.
*/
private appendProseDeliveryReceipt(agent: Agent): void {
private appendProseDeliveryReceipt(agent: Agent, privateTurn = false): void {
const list = this.turnProseDeliveries.get(agent.name);
const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0;
const privateSuppressed = this.turnPrivateProseSuppressed.get(agent.name) ?? 0;
if ((!list || list.length === 0) && suppressed === 0) return;
this.turnProseDeliveries.delete(agent.name);
this.turnProseSuppressed.delete(agent.name);
this.turnPrivateProseSuppressed.delete(agent.name);
const seen = new Set<string>();
const shown: string[] = [];
for (const id of list ?? []) {
Expand All @@ -8283,12 +8309,17 @@ export class AgentFramework {
: id,
);
}
const suppressedNote =
suppressed > 0
? agent.proseRouting === 'disabled'
? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)`
: `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
: '';
const notes: string[] = [];
if (privateSuppressed > 0) {
notes.push(`${privateSuppressed} plain-speech segment(s) kept private (non-channel turn — publish only with an explicit send tool)`);
}
const otherSuppressed = Math.max(0, suppressed - privateSuppressed);
if (otherSuppressed > 0) {
notes.push(agent.proseRouting === 'disabled'
? `${otherSuppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)`
: `${otherSuppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`);
}
const suppressedNote = notes.join(' · ');
const text =
shown.length > 0
? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}`
Expand Down Expand Up @@ -8898,6 +8929,7 @@ export class AgentFramework {
this.turnEngagedChannels.delete(agent.name);
this.turnProseDeliveries.delete(agent.name);
this.turnProseSuppressed.delete(agent.name);
this.turnPrivateProseSuppressed.delete(agent.name);
this.proseHybridSuppressed.delete(agent.name);
if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
Expand All @@ -8909,7 +8941,14 @@ export class AgentFramework {
this.midTurnInputSignals.delete(agent.name);
this.turnLocusPins.delete(agent.name);
} else {
const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
// A non-channel surface is the turn's destination. Its source is
// preserved on the InferenceRequest at enqueue (applyProcessResponse),
// so fail closed BEFORE home/active/global channel resolution. WebUI
// already receives the stream; routing it elsewhere would be a leak.
const nonChannelSurfaceTurn = trigger?.nonChannelOrigin === true;
const locus = nonChannelSurfaceTurn
? null
: this.channelRegistry?.resolveLocus(agent.name) ?? null;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if (locus !== null) this.turnLocusPins.set(agent.name, locus);
else this.turnLocusPins.delete(agent.name);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
this.midTurnInputSignals.delete(agent.name);
Expand Down Expand Up @@ -9511,6 +9550,8 @@ export class AgentFramework {
console.error(
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`,
);
} else if (trigger?.nonChannelOrigin) {
this.suppressPrivateTurnProse(agent.name, roundSegments.length);
} else {
const locus = resolveTurnLocus();
console.error(
Expand Down Expand Up @@ -9877,6 +9918,8 @@ export class AgentFramework {
} catch (err) {
console.error('text-only prose delivery failed:', err);
}
} else if (trigger?.nonChannelOrigin) {
this.suppressPrivateTurnProse(agent.name, 1);
} else {
// Route to the TURN-FROZEN locus, like every other speech
// path. This dispatch runs AFTER the agent is idle, so a live
Expand Down Expand Up @@ -9959,6 +10002,8 @@ export class AgentFramework {
}
}
}
} else if (trigger?.nonChannelOrigin) {
this.suppressPrivateTurnProse(agent.name, segments.length);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} else if (silenced || segments.length === 0) {
console.error(
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
Expand Down Expand Up @@ -10000,7 +10045,7 @@ export class AgentFramework {
// segments were awaited in-loop. Locus mode only; explicit-mode
// envelopes acknowledge themselves through the prose gateway.
if (!trigger?.suppressProse && turnProseRouting !== 'explicit') {
this.appendProseDeliveryReceipt(agent);
this.appendProseDeliveryReceipt(agent, trigger?.nonChannelOrigin === true);
}

// Explicit-prose `!` continuation: a prose segment this turn asked
Expand Down Expand Up @@ -10170,7 +10215,7 @@ export class AgentFramework {
// receipt for the whole turn.
if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') {
await turnSpeechChain;
this.appendProseDeliveryReceipt(agent);
this.appendProseDeliveryReceipt(agent, trigger?.nonChannelOrigin === true);
}
return;
}
Expand Down Expand Up @@ -14368,6 +14413,10 @@ export class AgentFramework {

this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
const startTime = Date.now();
// Bind async channel_open completion to the logical turn that issued it.
// A stale completion may update next-turn active state, but must never
// rewrite a newer turn's frozen pin/privacy.
const issuingTurnToken = this.activeTurnTokens.get(agentName);

this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
.then((result) => {
Expand All @@ -14393,9 +14442,13 @@ export class AgentFramework {
if (opened) {
this.activeTriggerChannels.set(agentName, opened);
const openerAgent = this.agents.get(agentName);
if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) {
const stillIssuingTurn = issuingTurnToken !== undefined
&& this.activeTurnTokens.get(agentName) === issuingTurnToken;
if (stillIssuingTurn && openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) {
this.turnLocusPins.set(agentName, opened);
this.lastAnnouncedLocus.set(agentName, opened);
const activeTrigger = this.activeTurnTriggers.get(agentName);
if (activeTrigger?.nonChannelOrigin) activeTrigger.nonChannelOrigin = false;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
result = {
...result,
data: {
Expand Down
2 changes: 2 additions & 0 deletions src/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,4 +345,6 @@ export interface InferenceRequest {
/** Ephemeral system-position prompt for this turn only. Never written to
* Chronicle; callers must supply bounded non-secret control text. */
ephemeralSystemPrompt?: string;
/** True when a private non-channel surface requested this inference. */
nonChannelOrigin?: boolean;
}
20 changes: 20 additions & 0 deletions test/present-while-acting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,26 @@ describe('present while acting', () => {
await framework.stop();
});

it('channel_open deliberately lifts private-turn routing into the opened channel', async () => {
membrane.pushResponse(createMockResponse([
{ type: 'tool_use', id: 'c-private', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } },
] as ContentBlock[], 'tool_use'));
membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public after deliberate open.' }] as ContentBlock[]));
const framework = await createFramework();
const routed = stubChannelRegistry(framework);
const registry = (framework as unknown as { channelRegistry: Record<string, unknown> }).channelRegistry;
(registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = async () =>
({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } });
const agent = framework.getAgent('assistant')!;
await (framework as unknown as { startAgentStream(agent: unknown, trigger: unknown): Promise<void> })
.startAgentStream(agent, {
agentName: 'assistant', reason: 'external-message', source: 'tui', timestamp: Date.now(), nonChannelOrigin: true,
});
await framework.runUntilIdle();
assert.deepEqual(routed, [{ text: 'Public after deliberate open.', locus: 'discord:guild:observatory' }]);
await framework.stop();
});

it('reactions and system markers injected mid-turn do not clear send suppression', async () => {
// A reaction (`chat:reaction` tag) or a `system: true` marker is not
// conversational input: prose following an explicit send stays suppressed,
Expand Down
106 changes: 106 additions & 0 deletions test/trunk-channel-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js';
function internals(framework: AgentFramework) {
return framework as unknown as {
activeTriggerChannels: Map<string, string>;
turnLocusPins: Map<string, string>;
pendingRequests: Array<{ agentName: string; reason: string; source: string; timestamp: number; channelId?: string }>;
derivePushEventChannel(
origin: Record<string, unknown> | undefined,
Expand Down Expand Up @@ -290,6 +291,111 @@ describe('Trunk channel routing (item-3 redux)', () => {
await framework.stop();
});

it('a WebUI turn after Discord traffic pins null and routes with no guess', async () => {
membrane.pushResponse(createMockResponse([{ type: 'text', text: 'private WebUI reply' }]));
const framework = await makeFramework();
const i = internals(framework);
let resolveCalls = 0;
const routedLoci: Array<string | null> = [];
i.channelRegistry = {
resolveLocus: () => { resolveCalls++; return 'discord:guild:last-room'; },
routeSpeech: async (_agent: string, _text: string, locus: string | null) => {
routedLoci.push(locus);
return null;
},
getDescriptor: () => undefined,
sendOutgoingChunk: () => {}, sendOutgoingComplete: () => {}, sendOutgoingLifecycle: () => {},
startTyping: () => {}, stopTyping: () => {}, stopAll: () => {}, getChannelTools: () => [],
};

const scout = framework.getAgent('scout')!;
await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise<void> })
.startAgentStream(scout, {
agentName: 'scout', reason: 'external-message', source: 'tui', timestamp: Date.now(), nonChannelOrigin: true,
});
await framework.runUntilIdle();

assert.equal(resolveCalls, 0, 'non-channel origin must bypass global last-inbound');
assert.equal(i.turnLocusPins.has('scout'), false, 'WebUI turn must freeze no Discord locus');
assert.deepEqual(routedLoci, [], 'private prose is retained without calling routeSpeech');
const texts = scout.getContextManager().getAllMessages().flatMap((m) => m.content)
.filter((b): b is { type: 'text'; text: string } => b.type === 'text').map((b) => b.text);
assert.ok(texts.some((t) => t.includes('[delivered] nothing') && t.includes('kept private')));
await framework.stop();
});

it('a WebUI turn overrides even a resident home channel', async () => {
membrane.pushResponse(createMockResponse([{ type: 'text', text: 'private resident reply' }]));
const framework = await makeFramework();
const i = internals(framework);
let resolveCalls = 0;
i.channelRegistry = {
resolveLocus: () => { resolveCalls++; return 'discord:guild:resident-home'; },
routeSpeech: async () => null,
getDescriptor: () => undefined,
sendOutgoingChunk: () => {}, sendOutgoingComplete: () => {}, sendOutgoingLifecycle: () => {},
startTyping: () => {}, stopTyping: () => {}, stopAll: () => {}, getChannelTools: () => [],
};

const scout = framework.getAgent('scout')!;
await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise<void> })
.startAgentStream(scout, {
agentName: 'scout', reason: 'external-message', source: 'tui', timestamp: Date.now(), nonChannelOrigin: true,
});
await framework.runUntilIdle();

assert.equal(resolveCalls, 0, 'non-channel origin must bypass home and active resolvers too');
assert.equal(i.turnLocusPins.has('scout'), false);
await framework.stop();
});

it('a channel-triggered turn still pins its channel', async () => {
membrane.pushResponse(createMockResponse([{ type: 'text', text: 'channel reply' }]));
const framework = await makeFramework();
const i = internals(framework);
i.channelRegistry = {
resolveLocus: () => i.activeTriggerChannels.get('scout') ?? null,
routeSpeech: async () => null,
getDescriptor: () => undefined,
sendOutgoingChunk: () => {}, sendOutgoingComplete: () => {}, sendOutgoingLifecycle: () => {},
startTyping: () => {}, stopTyping: () => {}, stopAll: () => {}, getChannelTools: () => [],
};

const scout = framework.getAgent('scout')!;
await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise<void> })
.startAgentStream(scout, {
agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(),
channelId: 'discord:guild:chanA',
});
await framework.runUntilIdle();

assert.equal(i.turnLocusPins.get('scout'), 'discord:guild:chanA');
await framework.stop();
});

it('a heartbeat turn still uses the global fallback', async () => {
membrane.pushResponse(createMockResponse([{ type: 'text', text: 'heartbeat reply' }]));
const framework = await makeFramework();
const i = internals(framework);
i.channelRegistry = {
resolveLocus: () => 'discord:guild:last-room',
routeSpeech: async () => null,
getDescriptor: () => undefined,
sendOutgoingChunk: () => {}, sendOutgoingComplete: () => {}, sendOutgoingLifecycle: () => {},
startTyping: () => {}, stopTyping: () => {}, stopAll: () => {}, getChannelTools: () => [],
};

const scout = framework.getAgent('scout')!;
await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise<void> })
.startAgentStream(scout, {
agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now(),
});
await framework.runUntilIdle();

assert.equal(i.turnLocusPins.get('scout'), 'discord:guild:last-room');
await framework.stop();
});

it('startAgentStream clears the triggering channel for a no-channel (heartbeat) turn', async () => {
membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }]));
const framework = await makeFramework();
Expand Down
Loading