Skip to content
Merged
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
5 changes: 3 additions & 2 deletions changelog.d/channel-notices-reach-who-can-close.fixed.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
- The notice that a channel opened without the agent asking (an open its
own send or reply caused, or a subscription-policy admission) names
`channel_close` only to an agent that can call it there for every channel
it names, except where several residents share one slot (#291), and in
forks spawned from the primary afterwards (#235). A conversation fork can
it names, except where several residents share one slot (#291), and where
a fork's seed still carries it: in an adaptive template's summary, or in a
notice long enough for its template to shard or cut. A conversation fork can
close only its home channel, and an agent's `allowedTools` can leave
`channel_close` out. Before, such an agent
was told to use `channel_close`, and couldn't call it, or its call was
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fork-seed-primary-notices.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Where conversations are routed to per-channel forks (`FrameworkConfig.conversations`), a fork's seed no longer copies its template's channel notices, or its by-name notices of what another agent's reply did. That's each `[channels]` notice of an open, a failed open or a subscription policy's admission, whichever build stored it, and each notice telling the primary by name of another agent's failed reply or channel. They were copied into the forks spawned after them, so a fork serving one channel held which channels other conversations opened and whose replies failed, with advice judged for the primary, such as "use channel_close", which a fork can follow only for its home channel. The template keeps them. A text block that repeats one word for word is left out too, and the spawn's log line counts what was left out: `[conversation] <fork>: seeded from <template> without N thinking block(s), N channel or by-name notice(s), N unanswered tool call(s) and N orphaned tool result(s)`. A summary an adaptive template wrote while one was in its window can still mention it, a notice long enough for its template to shard or cut compiles to other text and stays, and a fork spawned before this change keeps what its seed copied.
91 changes: 72 additions & 19 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1373,27 +1373,70 @@ function releasePairedRawItem(block: ContentBlock): ContentBlock {
return rest as ContentBlock;
}

/** The text of each notice in `stored` judged for its agent as the primary:
* a channel notice (a system notice of kind `channel-notice`: an open, a
* failed open or a policy admission), and a by-name notice of another
* agent's reply or channel (a system notice with a `speaker`). A fork is
* seeded from its template, which is the primary where connectome-host
* routes conversations, and a seed that kept these would give the fork what
* other conversations did and which channels were opened, with advice
* judged for the primary: a fork closes only its home channel, and only its
* home's traffic is delivered to it. The template's tool calls that caused
* its own notices stay in the seed. `kind: 'channel-notice'` has marked
* every channel notice since the first (0.7.0), so notices stored by
* earlier builds are recognized too, whatever their wording. */
function primaryNoticeTexts(stored: readonly StoredMessage[]): Set<string> {
const texts = new Set<string>();
for (const message of stored) {
const meta = message.metadata as { system?: unknown; speaker?: unknown; kind?: unknown } | undefined;
if (meta?.system !== true) continue;
if (typeof meta.speaker !== 'string' && meta.kind !== 'channel-notice') continue;
for (const block of message.content) {
if (block.type === 'text') texts.add(block.text);
}
}
return texts;
}

/** Messages copied into a fork's seed without the text blocks whose text is
* in `texts` (primaryNoticeTexts), wherever such a block is. A message with
* no content left is left out, since a request can't carry an empty
* message. */
function withoutPrimaryNotices<M extends { content: ContentBlock[] }>(messages: readonly M[], texts: ReadonlySet<string>): M[] {
const out: M[] = [];
for (const message of messages) {
const content = message.content.filter((b) => b.type !== 'text' || !texts.has(b.text));
if (content.length > 0) out.push({ ...message, content });
}
return out;
}

/** One log line for a fork's seed that left something out, so an operator who
* sees a fork answer differently from its template has something to check:
* the template's thinking blocks, and the tool blocks the pairing pass
* dropped (calls with no result in the seed, results whose call isn't in
* it). `withoutReasoning` is the seed before that pass, `seed` after it. */
* the template's thinking blocks, its channel and by-name notices
* (primaryNoticeTexts, counting any text block that repeats one word for
* word), and the tool blocks the pairing pass dropped (calls
* with no result in the seed, results whose call isn't in it).
* `withoutReasoning` is the seed without thinking, `withoutNotices` without
* those notices too, and `seed` after the pairing pass. */
function logSeedOmissions(
fork: string,
template: string,
compiled: readonly { content: ContentBlock[] }[],
withoutReasoning: readonly { content: ContentBlock[] }[],
withoutNotices: readonly { content: ContentBlock[] }[],
seed: readonly { content: ContentBlock[] }[],
): void {
const count = (messages: readonly { content: ContentBlock[] }[], test: (b: ContentBlock) => boolean) =>
messages.reduce((n, m) => n + m.content.filter(test).length, 0);
const thinking = count(compiled, isThinkingBlock);
const calls = count(withoutReasoning, (b) => b.type === 'tool_use') - count(seed, (b) => b.type === 'tool_use');
const results = count(withoutReasoning, (b) => b.type === 'tool_result') - count(seed, (b) => b.type === 'tool_result');
if (thinking + calls + results === 0) return;
const notices = count(withoutReasoning, (b) => b.type === 'text') - count(withoutNotices, (b) => b.type === 'text');
const calls = count(withoutNotices, (b) => b.type === 'tool_use') - count(seed, (b) => b.type === 'tool_use');
const results = count(withoutNotices, (b) => b.type === 'tool_result') - count(seed, (b) => b.type === 'tool_result');
if (thinking + notices + calls + results === 0) return;
console.log(
`[conversation] ${fork}: seeded from ${template} without ${thinking} thinking block(s), ` +
`${calls} unanswered tool call(s) and ${results} orphaned tool result(s)`,
`${notices} channel or by-name notice(s), ${calls} unanswered tool call(s) and ${results} orphaned tool result(s)`,
);
}

Expand Down Expand Up @@ -12485,10 +12528,10 @@ export class AgentFramework {
/**
* Spawn a persistent per-channel conversation agent ("fork") from the
* template agent: own ContextManager under `conversations/{name}`, seeded
* with the template's compiled context without its thinking or its unpaired
* tool blocks (the SubagentModule wholesale-copy pattern; see the seeding
* comment below), registered in the event loop like any agent — but never
* primary.
* with the template's compiled context without its thinking, its channel
* and by-name notices, or its unpaired tool blocks (the SubagentModule
* wholesale-copy pattern; see the seeding comment below), registered in the
* event loop like any agent — but never primary.
*/
private async createConversationAgent(name: string, channelId: string): Promise<Agent> {
const router = this.conversationRouter!;
Expand Down Expand Up @@ -12550,6 +12593,15 @@ export class AgentFramework {
// vouch for the copies. On models that don't bind thinking to its
// prefix a copy would have been kept, so there the fork loses the
// template's reasoning.
// It leaves out, too, the template's channel notices and its by-name
// notices of other agents (primaryNoticeTexts), the channel's own next
// fork included. The compile carries no metadata, so each text block
// whose text is such a notice's in the template's store goes, wherever
// it is, a block of the template's own that repeats one word for word
// included. A summary an adaptive template wrote while a notice was in
// its window can still mention it, and so can the template's own words.
// A notice compiled to other text, as one long enough to be sharded at
// ingest or cut by a per-message cap, matches nothing and stays.
// Then context-manager's stripUnpairedToolBlocks drops each call whose
// result isn't in the seed and each result whose call isn't, keeping
// the rest of their messages (a message left with nothing becomes its
Expand All @@ -12567,8 +12619,10 @@ export class AgentFramework {
const { messages: compiled } = await template.toolResultGuard.afterAnnotations(
() => template.getContextManager().compile(undefined, undefined, { dryRun: true }));
const withoutReasoning = withoutThinking(compiled);
const seed = stripUnpairedToolBlocks(withoutReasoning);
logSeedOmissions(name, template.name, compiled, withoutReasoning, seed);
const withoutNotices = withoutPrimaryNotices(
withoutReasoning, primaryNoticeTexts(template.getContextManager().getAllMessages()));
const seed = stripUnpairedToolBlocks(withoutNotices);
logSeedOmissions(name, template.name, compiled, withoutReasoning, withoutNotices, seed);
for (const msg of seed) {
const participant = msg.participant === template.name ? name : msg.participant;
contextManager.addMessage(participant, msg.content);
Expand Down Expand Up @@ -15390,9 +15444,9 @@ export class AgentFramework {
* outranks policy) only where the agent it's addressed to can call it for
* every channel it names (canCloseChannels): a conversation fork may close
* only its home channel, and an agent's allowedTools can leave
* channel_close out. Others can read it too: the other residents, where
* several share one slot (#291), and forks spawned from the primary
* afterwards, from its window (#235). Where an
* channel_close out. The other residents can read it too, where several
* share one slot (#291); a fork's seed leaves it out (primaryNoticeTexts).
* Where an
* agent's own send opened a channel it can't close, the primary is told by
* name too, if it's registered and can close it, and the agent is told
* that it was. What an open channel delivers is its server's to say (#406),
Expand Down Expand Up @@ -15626,9 +15680,8 @@ export class AgentFramework {
* channel_open as the way to try again only where the agent it's addressed
* to can call it: a conversation fork's channel_open is refused outright
* (dispatchChannelToolCall), and an agent's allowedTools can leave it out.
* Others can read it too: the other residents, where several share one
* slot (#291), and forks spawned from the primary afterwards, from its
* window (#235).
* The other residents can read it too, where several share one slot
* (#291); a fork's seed leaves it out (primaryNoticeTexts).
* Where the channel is selected for opening, it says so and why, as
* speech's failure does, and what that means: where the host couldn't
* record putting the channel's decision back, a later delivery or
Expand Down
10 changes: 5 additions & 5 deletions test/channel-lifecycle-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2541,11 +2541,11 @@ test("where a reply's reader can't close a channel selected for opening, the pri

// The opened notice follows the reply notice's rule: channel_close is named
// only where the agent it's addressed to can call it for every channel named
// (other residents of one slot, and forks spawned from the primary later, may
// read it too: #291, #235), and where an
// agent's send opened a channel it can't close, the primary is told by name
// too, if it can close it. The reader's notice carries its inputs only where
// it was told it can close the channels, so a release passes on only that.
// (other residents of one slot may read it too, #291; a fork's seed leaves it
// out), and where an agent's send opened a channel it can't close, the
// primary is told by name too, if it can close it. The reader's notice
// carries its inputs only where it was told it can close the channels, so a
// release passes on only that.
test('the opened notice names channel_close only where the agent it\'s addressed to can call it, says what the channel delivers only to the agent that holds its traffic, and tells the primary by name where the agent whose send opened it can\'t close it', () => {
// What the channel delivers: said to the agent whose window holds its
// traffic. Where conversations are routed (router), that's a fork for its
Expand Down
Loading
Loading