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: 5 additions & 0 deletions .changeset/460-published-notices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-bundle/runtime": patch
---

Let a publisher read what became of its own notices: `(await agent()).notices.published()` returns the notices the current principal published, in every ledger state (`pending`, `attempted`, `acknowledged`, `expired`, `unavailable`, `withdrawn`) with their receipts. `publish()` records the publishing request's observed identity on the notice as `AgentNotice.publisher` (`actor`, `host`, `session`, `workspace`, and `conversation` from `request.lineage`); a reader is the publisher when it resolves the same lineage conversation, or — for a publisher recorded without lineage — when every recorded axis matches. The view records nothing on the ledger, is judged per notice under the new authorization `phase: 'published'`, discloses content under the default `internal` ceiling, and never returns another publisher's or any recipient's notices. `publisher` is an additive optional field (no state-definition version bump). `examples/worktree-proximity`'s `coordinator/status` reports the calling agent's published-notice counts by state. (#460)
16 changes: 12 additions & 4 deletions examples/worktree-proximity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,18 @@ 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
coordinator status therefore reports topology facts only and does not claim a
whole-ledger pending count.
current invocation, and `inbox()` only what is pending for the current
recipient. The coordinator status reports, beside the topology facts, what
became of the notices *the calling agent* published — `pending`,
`attempted`, `acknowledged`, and the other ledger states, counted from
`(await agent()).notices.published()`
([#460](https://github.com/scriptedalchemy/agent-bundle/issues/460)). That
view is scoped by the publisher identity the ledger recorded at publish, the
agent's lineage conversation, so a status call correlated to agent B's
conversation (Claude names the pre-tool hook's `tool_use_id` in the MCP call's
`_meta`) counts agent B's notices whatever the MCP client name, session id, or
server cwd; a call whose lineage the runtime cannot resolve counts zero. It is
never a whole-ledger count and never another agent's.

## Evidence boundary

Expand Down
53 changes: 49 additions & 4 deletions examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Agent, type JsonValue } from '@agent-bundle/runtime';
import { Agent, type AgentNoticeState, type JsonValue } from '@agent-bundle/runtime';
import { AGENT_NOTICE_STATES } from '@agent-bundle/runtime/notices';
import type { ToolConfig, ToolRouteProps } from 'agent-bundle';
import React from 'react';
import { z } from 'zod';

import { withTopology } from '../../../coordination.js';
import { withNotices, withTopology } from '../../../coordination.js';
import { ActorSchema } from '../../../state.js';

export const config = {
annotations: { readOnlyHint: true },
description: 'Show the mounted durable worktree topology, active intents, and refusals.',
description: 'Show the mounted durable worktree topology, active intents, refusals, and the state of the proximity notices this agent published.',
} satisfies ToolConfig;

export const inputSchema = z
Expand All @@ -17,10 +18,33 @@ export const inputSchema = z
})
.strict();

const noticeCount = z.number().int().nonnegative();

/**
* What became of the notices the calling agent published, counted by ledger
* state. Scoped by the publisher identity the ledger recorded at publish —
* the caller's lineage conversation — so it is this agent's own notices, never
* the whole ledger and never another agent's ([#460](https://github.com/scriptedalchemy/agent-bundle/issues/460)).
*/
export const PublishedNoticesSchema = z
.object({
acknowledged: noticeCount,
attempted: noticeCount,
expired: noticeCount,
pending: noticeCount,
reason: z.string().optional(),
state: z.enum(['available', 'unavailable']),
total: noticeCount,
unavailable: noticeCount,
withdrawn: noticeCount,
})
.strict();

export const resultSchema = z
.object({
activeActivities: z.number().int().nonnegative(),
actors: z.array(ActorSchema),
notices: PublishedNoticesSchema,
reason: z.string().optional(),
refusals: z.number().int().nonnegative(),
revision: z.number().int().nonnegative(),
Expand All @@ -29,16 +53,32 @@ export const resultSchema = z
.strict();

type StatusResult = z.output<typeof resultSchema>;
type PublishedNotices = z.output<typeof PublishedNoticesSchema>;

const emptyCounts = (): Record<AgentNoticeState, number> =>
Object.fromEntries(AGENT_NOTICE_STATES.map((state) => [state, 0])) as Record<AgentNoticeState, number>;

const publishedNotices = async (): Promise<PublishedNotices> => {
const result = await withNotices(async (notices) => notices.published());
if (result.state === 'unavailable') {
return { ...emptyCounts(), reason: result.reason, state: 'unavailable', total: 0 };
}
const counts = emptyCounts();
for (const notice of result.value) counts[notice.state] += 1;
return { ...counts, state: 'available', total: result.value.length };
};

export default async function Status({
input,
}: ToolRouteProps<typeof inputSchema>) {
const topologyResult = await withTopology(async (store) => store.read());
const notices = await publishedNotices();
let result: StatusResult;
if (topologyResult.state === 'unavailable') {
result = {
activeActivities: 0,
actors: [],
notices,
reason: topologyResult.reason,
refusals: 0,
revision: 0,
Expand All @@ -57,21 +97,26 @@ export default async function Status({
&& (activity.paths.length > 0 || activity.dependencies.length > 0),
).length,
actors,
notices,
refusals: topology.refusals.length,
revision,
state: 'available',
};
}

const noticeLine = notices.state === 'available'
? `- Published notices: ${String(notices.total)} (pending ${String(notices.pending)}, attempted ${String(notices.attempted)}, acknowledged ${String(notices.acknowledged)})`
: `- Published notices unavailable: ${notices.reason ?? 'unknown reason'}`;
const markdown = result.state === 'available'
? [
'# Worktree proximity status',
'',
`- Actors: ${String(result.actors.length)}`,
`- Active activities: ${String(result.activeActivities)}`,
`- Refused edges: ${String(result.refusals)}`,
noticeLine,
].join('\n')
: `# Worktree proximity status\n\nUnavailable: ${result.reason ?? 'unknown reason'}`;
: `# Worktree proximity status\n\nUnavailable: ${result.reason ?? 'unknown reason'}\n\n${noticeLine}`;
return (
<Agent.Result value={result as unknown as JsonValue}>
<Agent.Markdown>{markdown}</Agent.Markdown>
Expand Down
63 changes: 62 additions & 1 deletion examples/worktree-proximity/tests/route-unit/routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from '@rstest/core';
import { available, type AgentLineage, type Observed } from '@agent-bundle/runtime';
import { agent, available, runAgentRequest, type AgentLineage, type Observed } from '@agent-bundle/runtime';
import { expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test';

import BeforeTool from '../../src/events/tool/before.js';
Expand Down Expand Up @@ -485,11 +485,72 @@ describe('worktree proximity journeys', () => {
expect.objectContaining({ id: 'agent-a', worktreeRoot: worktrees.a }),
expect.objectContaining({ id: 'agent-b', worktreeRoot: worktrees.b }),
]),
// A caller with no identity published nothing: the count is honestly
// zero, not the ledger's total.
notices: expect.objectContaining({ pending: 0, state: 'available', total: 0 }),
refusals: 0,
revision: expect.any(Number),
});
});

it('reports the publishing agent its own notice states through coordinator status (#460)', async () => {
await bindActors();
await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a');
// agent-b's intent publishes the proximity notice addressed to agent-a.
await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b');

// An MCP tool call from the same agent: the client name, MCP session id,
// and server cwd all differ from the hook that published — the lineage
// conversation is what identifies the publisher.
const statusFor = (conversation: string) => renderRoute('tool:coordinator/status', {
context: {
...mounted.context(),
host: available({ name: 'claude-code' }, 'native'),
lineage: childLineage(conversation),
providers: providers(worktrees.root),
session: available({ sessionId: 'mcp-session' }, 'native'),
workspace: available({ root: worktrees.root }, 'derived'),
},
input: {},
});
const noticesOf = (conversation: string, worktreeRoot: string) => runAgentRequest({
...mounted.context(),
host: available({ name: 'claude' }, 'native'),
invocation: { id: `invocation:notices:${conversation}:${String(sequence++)}`, kind: 'tool', startedAt: '2026-09-01T20:05:00.000Z' },
lineage: childLineage(conversation),
providers: providers(worktreeRoot),
session: available({ sessionId: 'root-session' }, 'native'),
workspace: available({ root: worktreeRoot }, 'native'),
}, async () => {
const handle = (await agent()).notices!;
return { inbox: await handle.inbox(), published: await handle.published() };
});

// Pending: the publisher sees it; its own inbox does not (it is not the recipient).
const pending = await statusFor('agent-b');
expectDocument(pending).toContainMarkdown('Published notices: 1 (pending 1, attempted 0, acknowledged 0)');
expect(pending.result).toMatchObject({ notices: { pending: 1, state: 'available', total: 1 } });
const publisherBefore = await noticesOf('agent-b', worktrees.b);
expect(publisherBefore.inbox).toEqual([]);
expect(publisherBefore.published).toEqual([expect.objectContaining({ recipient: { conversation: 'agent-a' }, state: 'pending' })]);
// The recipient sees it in its inbox and published nothing.
const recipientBefore = await noticesOf('agent-a', worktrees.a);
expect(recipientBefore.inbox).toHaveLength(1);
expect(recipientBefore.published).toEqual([]);
expect((await statusFor('agent-a')).result).toMatchObject({ notices: { state: 'available', total: 0 } });

// Admitted on agent-a's next event: the publisher now reads `attempted`,
// and the recipient's inbox is empty again.
await completeIntent(worktrees.a, 'src/shared.ts', 'intent:a:after', 'agent-a', childLineage('agent-a'));
const attempted = await statusFor('agent-b');
expectDocument(attempted).toContainMarkdown('Published notices: 1 (pending 0, attempted 1, acknowledged 0)');
expect(attempted.result).toMatchObject({ notices: { attempted: 1, pending: 0, total: 1 } });
const recipientAfter = await noticesOf('agent-a', worktrees.a);
expect(recipientAfter.inbox).toEqual([]);
expect(recipientAfter.published).toEqual([]);
expect((await noticesOf('agent-b', worktrees.b)).inbox).toEqual([]);
});

it('renders state unavailability when an event module has no mounted handle', async () => {
const rendered = await renderRoute({ default: BeforeTool }, {
context: {
Expand Down
148 changes: 148 additions & 0 deletions packages/agent-bundle/tests/route-unit/published-notices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Agent, agent, type AgentLineage, type Observed } from '@agent-bundle/runtime';
import {
agentNoticeStateDefinition,
createAgentNoticeLedger,
type AgentNoticeLedger,
} from '@agent-bundle/runtime/notices';
import { createMemoryStateDriver } from '@agent-bundle/runtime/state';
import { describe, expect, it } from '@rstest/core';
import { createElement } from 'react';

import { expectDocument } from '../../src/test/matchers.ts';
import { renderRoute } from '../../src/test/render.ts';

/** Claude/Codex-shaped: every subagent under one root shares the root session id. */
const lineageOf = (conversation: string): Observed<AgentLineage> => ({
source: 'derived',
state: 'available',
value: { conversation, depth: 1, parent: 'root', resolution: 'registry', root: 'root', subagent: { id: conversation } },
});

let sequence = 0;
const render = (
module: () => Promise<unknown>,
ledger: AgentNoticeLedger,
lineage: Observed<AgentLineage>,
kind: 'event' | 'tool',
identity: { readonly host: string; readonly sessionId: string; readonly workspace: string } = {
host: 'claude',
sessionId: 'root',
workspace: '/workspace',
},
) => {
sequence += 1;
return renderRoute({ default: module as never }, {
context: {
host: { source: 'native', state: 'available', value: { name: identity.host } },
lineage,
noticeLedger: ledger,
session: { source: 'native', state: 'available', value: { sessionId: identity.sessionId } },
workspace: { source: 'native', state: 'available', value: { root: identity.workspace } },
},
...(kind === 'event'
? {
input: {
canonical: {
event: 'tool/after',
idempotencyKey: `published-notices:${String(sequence)}`,
observedAt: `2026-09-03T13:00:${String(sequence).padStart(2, '0')}.000Z`,
provenance: { host: 'claude', hostContractRevision: 'route-unit', nativeEvent: 'PostToolUse', source: 'native' },
sequence,
},
native: { hook_event_name: 'PostToolUse' },
},
kind: 'event-route' as const,
routeId: 'event:tool/after',
}
: { routeId: 'tool:coordinator/notices' }),
});
};

const Publish = (conversation: string) => async (): Promise<unknown> => {
const { notices } = await agent();
const published = await notices!.publish({
content: { root: { kind: 'text', text: `for ${conversation}` }, status: 'success', version: 1 },
priority: 'high',
recipient: { conversation },
}, { idempotencyKey: `publish:${conversation}` });
return createElement(Agent.Result, { value: { noticeId: published.notice.id } });
};

/** What a coordinator tool reads: its own publications by state, and its inbox. */
const Overview = async (): Promise<unknown> => {
const { notices } = await agent();
const [published, inbox] = await Promise.all([notices!.published(), notices!.inbox()]);
return createElement(Agent.Result, {
value: {
inbox: inbox.map((notice) => notice.id),
published: published.map((notice) => ({ id: notice.id, state: notice.state })),
},
});
};

const Receive = async (): Promise<unknown> => {
const { notices } = await agent();
return createElement(Agent.Result, { value: { delivered: (await notices!.read()).map((delivery) => delivery.notice.id) } });
};

const value = <T,>(rendered: Awaited<ReturnType<typeof renderRoute>>): T => rendered.document.value as T;

describe('publisher-scoped notice visibility (#460)', () => {
it('shows publisher A its notice as attempted after B admitted it, while B\'s inbox empties and A\'s inbox never showed it', async () => {
const driver = createMemoryStateDriver({ lifetime: 'process' });
const store = await driver.open(agentNoticeStateDefinition('process'));
const phases: string[] = [];
const ledger = createAgentNoticeLedger(store, {
authorize: (request) => {
phases.push(request.phase);
return { state: 'authorized' };
},
});
try {
const published = await render(Publish('agent-b'), ledger, lineageOf('agent-a'), 'event');
expectDocument(published).toHaveStatus('success');
const { noticeId } = value<{ noticeId: string }>(published);

// Before admission: A sees its publication pending and nothing in its inbox; B sees it only in its inbox.
expect(value(await render(Overview, ledger, lineageOf('agent-a'), 'tool'))).toEqual({
inbox: [],
published: [{ id: noticeId, state: 'pending' }],
});
expect(value(await render(Overview, ledger, lineageOf('agent-b'), 'tool'))).toEqual({
inbox: [noticeId],
published: [],
});
// A sibling under the same root, sharing host, session, and workspace, sees neither.
expect(value(await render(Overview, ledger, lineageOf('agent-c'), 'tool'))).toEqual({ inbox: [], published: [] });

// B's next event admits it.
expect(value(await render(Receive, ledger, lineageOf('agent-b'), 'event'))).toEqual({ delivered: [noticeId] });

// After admission: A reads `attempted` — here from a tool call whose host
// name, session id, and cwd differ from the publishing hook, because the
// lineage conversation identifies the publisher — and B's inbox is empty.
expect(value(await render(Overview, ledger, lineageOf('agent-a'), 'tool', {
host: 'claude-code',
sessionId: 'mcp-session-1',
workspace: '/server-cwd',
}))).toEqual({
inbox: [],
published: [{ id: noticeId, state: 'attempted' }],
});
expect(value(await render(Overview, ledger, lineageOf('agent-b'), 'tool'))).toEqual({ inbox: [], published: [] });
// Unresolved lineage is nobody's publication.
expect(value(await render(Overview, ledger, { reason: 'no-shared-runtime', state: 'unavailable' }, 'tool')))
.toEqual({ inbox: [], published: [] });

// `published` is judged per matching notice; publisher-scoped reads recorded no receipts.
expect(phases.filter((phase) => phase === 'published')).toHaveLength(2);
expect((await ledger.read()).notices[0]).toMatchObject({
attempts: [expect.objectContaining({ channel: 'next-event' })],
publisher: { conversation: 'agent-a', host: { name: 'claude' }, session: { sessionId: 'root' }, workspace: { root: '/workspace' } },
state: 'attempted',
});
} finally {
await driver.close();
}
});
});
Loading
Loading