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
6 changes: 6 additions & 0 deletions .changeset/458-lineage-notice-recipients.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@agent-bundle/runtime": patch

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 Badge Mark the required lineage field as breaking

Publishing @agent-bundle/runtime as a patch is unsafe because this commit makes AgentNoticePrincipal.lineage required in the public API. Existing TypeScript consumers constructing the previous four-axis principal will stop compiling, while existing JavaScript consumers of AgentNoticeLedger.openRequest() can reach recordedNoticePrincipal() and throw when it reads principal.lineage.state. Declare the runtime change as minor, or preserve compatibility by making lineage optional and treating omission as unavailable.

AGENTS.md reference: AGENTS.md:L102-L103

Useful? React with 👍 / 👎.

"agent-bundle": patch
---

Address a notice to one agent conversation or to a whole conversation tree: `AgentRecipient` gains `conversation` (matches `request.lineage.conversation` exactly) and `root` (matches every request whose `request.lineage.root` is that id), matched in conjunction with the existing `actor` / `host` / `session` / `workspace` axes at admission, inbox reads, `resources/updated` eligibility, and acknowledgement. `AgentNoticePrincipal` gains an optional `lineage`, which every generated surface (event routes, MCP tools, routed CLI, rendered scripts) now mounts; a principal built without it, or with unresolved lineage, never matches a lineage-addressed recipient and otherwise behaves exactly as before. The ledger journals only `{ conversation, root }` of the admitting lineage as an additive optional field — no state-definition version bump, journals written before the axes replay unchanged. `notices.publish()` rejects blank `conversation` / `root` with `invalid-input`. `examples/worktree-proximity` addresses its proximity notices to the other actor's conversation instead of its worktree. (#458)
5 changes: 4 additions & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@ directory. The npm package's routed CLI bin and rendered scripts use
(`<target>/bin/<name>.mjs`) derives the artifact root from the parent of its
own `bin/` directory instead, like the MCP worker. Notice authorization is deliberately permissive
in generated mounting v1 (`authorized`); recipient/principal matching remains
enforced by the ledger, while application authorization policy is deferred.
enforced by the ledger — every generated scope mounts the request's `lineage`
on the notice principal, so `recipient.conversation` / `recipient.root` are
matched against `request.lineage` on every surface — while application
authorization policy is deferred.

Each cross-request notice route is selected from the target host's pinned
`noticeDelivery` table, exposed as `TargetAdapter.noticeDelivery` /
Expand Down
37 changes: 25 additions & 12 deletions examples/worktree-proximity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ This example is intentionally not part of the newcomer path.
3. `tool/before` records current path and dependency intent.
4. The pure proximity domain compares active intents from different worktrees.
5. A conflict renders an `Agent.Context` warning with an `outcome: continue`
result and publishes a recipient-scoped notice.
6. The other actor's next event admits the pending notice, changes its
result and publishes a notice addressed to the other actor's lineage
conversation (`recipient.conversation`).
6. That actor's next event — and only that actor's, even when a sibling works
in the same worktree — admits the pending notice, changes its
evidence-backed state to `attempted`, and renders its content as context.
7. `tool/after` records an empty current intent, and `stop` marks the actor
stopped.
Expand Down Expand Up @@ -84,10 +86,12 @@ to the root; `transcript` is read from the host's own rollout file), and
is `(await agent()).lineage.root` when the runtime resolved a lineage and the
native `session_id` otherwise.
- `agent/start` records the child and its parent from `request.lineage`
(`subagent.id`, `parent`, `resolution`) when the runtime placed the start
(`conversation`, `parent`, `resolution`) when the runtime placed the start
below the root — which needs the spawning `Agent`/`Task` `tool/before` to
have passed through the same shared runtime — and from the native `agent_id`
+ `session_id` pair otherwise.
+ `session_id` pair otherwise. Either way the child's actor id is its
lineage conversation (Claude and Codex spell it `agent_id`), which is what a
directed notice targets.
- Claude and Codex put the subagent's `agent_id` on every one of its hook
payloads; Cursor gives the child a fresh `conversation_id` that only the
runtime registry can bind to its `subagentStart`. A tool or stop event
Expand Down Expand Up @@ -121,13 +125,19 @@ mounted handle, they return an unavailable result and the route renders that
reason as `Agent.Context`; there is no fallback write path.

Notice admission runs once per event invocation in the render scope.
Generated event principals in v1 mount host, session, and workspace identity,
but not actor identity. Proximity notices therefore target the recipient
worktree through `recipient.workspace.root`, while their content and dedupe
keys continue to name the target actor. Lineage-addressed delivery
(`recipient.conversation` / `recipient.root` matched against
`request.lineage`) is tracked in
[agent-bundle#458](https://github.com/scriptedalchemy/agent-bundle/issues/458).
Generated event principals mount host, session, workspace, and lineage
identity, but not actor identity (#391/#444). A proximity notice is therefore
addressed to the other actor's lineage conversation —
`recipient: { conversation }`, matched against the admitting request's
`request.lineage.conversation` — so only that agent thread admits it, even
when a sibling shares its worktree and every subagent shares the root
`session_id`. An event whose lineage the runtime could not resolve (no shared
runtime, an unplaced `agent_id`) is never the addressed agent; the notice
stays pending for the next event that is. Only the application's derived
`worktree:<root>` fallback actor, which names no conversation, is still
addressed through `recipient.workspace.root`. `recipient.root` (every
conversation under one root) is available but unused here: proximity is a
message to one peer, not to the tree.
`(await agent()).notices.read()` exposes only deliveries attempted for the
current invocation; publisher-scoped visibility is
[#460](https://github.com/scriptedalchemy/agent-bundle/issues/460). The
Expand All @@ -142,7 +152,10 @@ and exercises the documented journeys against one shared durable runtime
owner. The root integration-pool suite
`packages/agent-bundle/tests/worktree-proximity-journeys.test.ts` builds the
real artifact, invokes generated hooks as separate processes against linked
Git worktrees, and proves warning, workspace-directed delivery, replay
Git worktrees, and proves warning, conversation-directed delivery (the
spawning `Agent` `PreToolUse` opens the registry's spawn window and the
child's hook payloads carry its `agent_id`, as Claude's do; an event the
runtime cannot place under that child is not delivered to), replay
idempotency, and exact-revision restart durability through the generated MCP
server. Journey 8 has two honesty layers: the generated wrapper fails closed
on an identity-less `SubagentStart` for host contracts that require
Expand Down
41 changes: 35 additions & 6 deletions examples/worktree-proximity/src/event-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import {
type AgentDocumentNode,
type AgentLineage,
type AgentNoticeDelivery,
type AgentRecipient,
type Observed,
} from '@agent-bundle/runtime';

import type { AvailableWorktree } from './api.js';
import type { TopologyAccess } from './coordination.js';
import type { IdentityProvenance, TopologyState } from './state.js';
import type { Actor, IdentityProvenance, TopologyState } from './state.js';

/** The root actor observed at `session/start` is `session:<root conversation>`. */
export const ROOT_ACTOR_PREFIX = 'session:';
/** The application's own fallback identity for a worktree no envelope names an agent for. */
export const DERIVED_ACTOR_PREFIX = 'worktree:';

export interface EventIdentity {
readonly idempotencyKey: string;
Expand Down Expand Up @@ -37,19 +43,42 @@ export const requestLineage = async (): Promise<Observed<AgentLineage>> => (awai
* The subagent a request speaks for, when the runtime's `request.lineage`
* places it below the root. The runtime resolves the same shape on every
* host, so the route never has to know that Claude and Codex spell the child
* `agent_id` while Cursor gives it a fresh `conversation_id`. A root request
* (depth 0) is deliberately not a child; the root actor is observed at
* `session/start`.
* `agent_id` while Cursor gives it a fresh `conversation_id`: the actor id is
* the lineage `conversation`, which is also the id a directed notice targets
* through `recipient.conversation`. A root request (depth 0) is deliberately
* not a child; the root actor is observed at `session/start`.
*/
export const childFromLineage = (lineage: Observed<AgentLineage>): CarriedChild | undefined => {
if (lineage.state !== 'available' || lineage.value.depth === 0) return undefined;
return {
id: lineage.value.subagent?.id ?? lineage.value.conversation,
id: lineage.value.conversation,
parentSessionId: lineage.value.parent ?? lineage.value.root,
source: lineage.value.resolution,
};
};

/**
* Where a proximity notice for `actor` is addressed. An actor whose id is a
* lineage conversation — the root observed at `session/start`, or a child
* named by `request.lineage` or by the host's own `agent_id` (Claude and Codex
* put it on every one of the subagent's hook payloads, and the runtime
* resolves it as that agent's `conversation`) — is addressed through
* `recipient.conversation`, so only that agent thread admits the notice even
* when a sibling shares its worktree. The application's derived
* `worktree:<root>` fallback names no conversation, so the notice stays
* addressed to the worktree through `recipient.workspace.root`.
*/
export const noticeRecipientFor = (actor: Actor | undefined, worktreeRoot: string): AgentRecipient => {
if (actor === undefined || actor.provenance.id === 'derived') {
return { workspace: { root: worktreeRoot } };
}
return {
conversation: actor.kind === 'root' && actor.id.startsWith(ROOT_ACTOR_PREFIX)
? actor.id.slice(ROOT_ACTOR_PREFIX.length)
: actor.id,
};
};

/**
* The child actor one envelope carries: the runtime lineage first, then the
* host's own `agent_id` (Claude and Codex put the subagent's id on every one
Expand Down Expand Up @@ -163,7 +192,7 @@ export const actorForWorktree = async (
}

const actor: ResolvedActor = {
id: `worktree:${worktree.root}`,
id: `${DERIVED_ACTOR_PREFIX}${worktree.root}`,
source: 'derived',
};
await topology.dispatch('actorObserved', {
Expand Down
4 changes: 2 additions & 2 deletions examples/worktree-proximity/src/events/session/start.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React from 'react';

import { worktree } from '../../api.js';
import { withNotices, withTopology } from '../../coordination.js';
import { deliveryContexts, nativeString, requestLineage } from '../../event-support.js';
import { ROOT_ACTOR_PREFIX, deliveryContexts, nativeString, requestLineage } from '../../event-support.js';

export const config = {
runtime: 'shared',
Expand Down Expand Up @@ -40,7 +40,7 @@ export default async function SessionStart({
);
}

const actorId = `session:${root.id}`;
const actorId = `${ROOT_ACTOR_PREFIX}${root.id}`;
const topologyResult = await withTopology(async (topology) => {
await topology.dispatch('actorObserved', {
id: actorId,
Expand Down
13 changes: 10 additions & 3 deletions examples/worktree-proximity/src/events/tool/before.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
actorForWorktree,
deliveryContexts,
extractIntent,
noticeRecipientFor,
} from '../../event-support.js';

export const config = {
Expand Down Expand Up @@ -47,6 +48,7 @@ export default async function BeforeTool({
});
return {
actor,
actors: committed.state.actors,
conflicts: findProximity(committed.state, currentWorktree.root, {
actorId: actor.id,
dependencies: intent.dependencies,
Expand All @@ -66,6 +68,13 @@ export default async function BeforeTool({
const noticeResult = await withNotices(async (notices) => {
const deliveries = await notices.read();
for (const [index, conflict] of resolution.conflicts.entries()) {
// The other actor's conversation is the recipient: only that agent
// thread admits the notice, even when a sibling shares its worktree.
// A derived actor has no conversation, so its worktree is addressed.
const recipient = noticeRecipientFor(
resolution.actors.find((actor) => actor.id === conflict.actorId),
conflict.worktreeRoot,
);
await notices.publish({
content: {
root: {
Expand All @@ -77,9 +86,7 @@ export default async function BeforeTool({
},
dedupeKey: `proximity:${resolution.actor.id}:${conflict.actorId}:${conflict.summary}`,
priority: 'high',
recipient: {
workspace: { root: conflict.worktreeRoot },
},
recipient,
}, {
idempotencyKey: `${canonical.idempotencyKey}:notice:${String(index)}`,
});
Expand Down
110 changes: 95 additions & 15 deletions examples/worktree-proximity/tests/route-unit/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ const bindActors = async (): Promise<void> => {
);
};

// Claude and Codex carry the subagent's `agent_id` on every one of its hook
// payloads and the shared runtime resolves it as that agent's lineage
// conversation, so a child's tool events arrive with `request.lineage` set.
const recordIntent = (
actorId: 'agent-a' | 'agent-b',
root: string,
Expand All @@ -159,6 +162,7 @@ const recordIntent = (
'event:tool/before',
'tool/before',
{
agent_id: actorId,
cwd: root,
hook_event_name: 'PreToolUse',
session_id: 'root-session',
Expand All @@ -168,6 +172,30 @@ const recordIntent = (
id,
root,
actorId,
childLineage(actorId),
);

const completeIntent = (
root: string,
path: string,
id: string,
actorId?: string,
lineage?: Observed<AgentLineage>,
) => renderEvent(
'event:tool/after',
'tool/after',
{
...(actorId === undefined ? {} : { agent_id: actorId }),
cwd: root,
hook_event_name: 'PostToolUse',
session_id: 'root-session',
tool_input: { file_path: path },
tool_name: 'Edit',
},
id,
root,
actorId,
lineage,
);

beforeEach(async () => {
Expand Down Expand Up @@ -219,9 +247,11 @@ describe('worktree proximity journeys', () => {
expect(rendered.document.value).toEqual({ outcome: 'continue' });

const notices = await mounted.notices();
// The notice names the other agent's lineage conversation, not its
// worktree: only that agent thread admits it (#458).
expect(notices.notices).toEqual([
expect.objectContaining({
recipient: { workspace: { root: worktrees.a } },
recipient: { conversation: 'agent-a' },
state: 'pending',
}),
]);
Expand All @@ -232,20 +262,7 @@ describe('worktree proximity journeys', () => {
await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a');
await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b');

const delivered = await renderEvent(
'event:tool/after',
'tool/after',
{
cwd: worktrees.a,
hook_event_name: 'PostToolUse',
session_id: 'root-session',
tool_input: { file_path: 'src/shared.ts' },
tool_name: 'Edit',
},
'intent:a:after',
worktrees.a,
'agent-a',
);
const delivered = await completeIntent(worktrees.a, 'src/shared.ts', 'intent:a:after', 'agent-a', childLineage('agent-a'));

expectDocument(delivered)
.toHaveStatus('success')
Expand All @@ -259,6 +276,69 @@ describe('worktree proximity journeys', () => {
});
});

it('delivers a conversation-addressed notice to one agent even when a sibling shares its worktree (#458)', async () => {
await bindActors();
await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a');
await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b');

// agent-c works in agent-a's worktree: same host, session, and workspace
// as agent-a, so a workspace-addressed notice could not tell them apart.
const sibling = await completeIntent(worktrees.a, 'src/other.ts', 'intent:c:after', 'agent-c', childLineage('agent-c'));
expectDocument(sibling).toHaveStatus('success').toHaveNodeKinds(['result']);
// An event in that worktree whose lineage the runtime could not resolve
// is not the addressed agent either, even though the worktree binding
// attributes its intent to agent-a.
const unresolved = await completeIntent(worktrees.a, 'src/other.ts', 'intent:unresolved:after');
expectDocument(unresolved).toHaveStatus('success').toHaveNodeKinds(['result']);

expect((await mounted.notices()).notices).toEqual([expect.objectContaining({ attempts: [], state: 'pending' })]);
expect((await mounted.read()).state.actors).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'agent-a', worktreeRoot: worktrees.a }),
expect.objectContaining({ id: 'agent-c', worktreeRoot: worktrees.a }),
]));

const delivered = await completeIntent(worktrees.a, 'src/shared.ts', 'intent:a:after', 'agent-a', childLineage('agent-a'));
expectDocument(delivered)
.toHaveStatus('success')
.toContainContext('Directed proximity notice')
.toContainContext('src/shared.ts');
expect((await mounted.notices()).notices[0]).toMatchObject({
attempts: [expect.objectContaining({ invocationId: 'invocation:intent:a:after' })],
state: 'attempted',
});
});

it('addresses a derived actor through its worktree because it has no conversation', async () => {
await bindActors();
// No agent_id and no lineage in a worktree no actor is bound to: the
// application falls back to the derived `worktree:<root>` actor.
await renderEvent(
'event:tool/before',
'tool/before',
{
cwd: '/repo/.worktrees/c',
hook_event_name: 'PreToolUse',
session_id: 'root-session',
tool_input: { file_path: 'src/shared.ts' },
tool_name: 'Edit',
},
'intent:derived',
'/repo/.worktrees/c',
);
const rendered = await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a');
expectDocument(rendered).toHaveStatus('success').toContainContext('Proximity warning');

expect((await mounted.notices()).notices).toEqual([
expect.objectContaining({
recipient: { workspace: { root: '/repo/.worktrees/c' } },
state: 'pending',
}),
]);

const delivered = await completeIntent('/repo/.worktrees/c', 'src/shared.ts', 'intent:derived:after');
expectDocument(delivered).toContainContext('Directed proximity notice');
});

it('deduplicates a repeated native intent envelope (journey 7)', async () => {
await bindActors();
const replayed = eventInput(
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-bundle/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,8 @@ export interface GeneratedEventRuntimeBinding {
export interface GeneratedNoticePrincipal {
readonly actor: Observed<AgentActorIdentity>;
readonly host: Observed<AgentHostIdentity>;
/** Optional like the runtime's: absent is unavailable lineage. */
readonly lineage?: Observed<AgentLineage>;
readonly session: Observed<AgentSessionIdentity>;
readonly workspace: Observed<AgentWorkspaceIdentity>;
}
Expand Down Expand Up @@ -707,12 +709,14 @@ const installNoticeInboxSubscriptions = (
) => {
assertInboxUri(request.params.uri);
// Subscriptions are not tool calls: no pre-tool hook precedes them, so
// there is no correlation window to resolve lineage through.
// there is no correlation window to resolve lineage through — a
// subscriber therefore never matches a `conversation`/`root` recipient.
const identity = requestIdentity(context, protocol.getClientVersion()?.name, unavailable<AgentLineage>('not-provided'));
try {
await notices.subscribe({
actor: identity.actor ?? unavailable(),
host: identity.host ?? unavailable(),
lineage: identity.lineage,
session: identity.session ?? unavailable(),
workspace: identity.workspace,
});
Expand Down
Loading
Loading