Skip to content

Commit b435f7b

Browse files
feat: select event routes by capability (#679)
* feat: select event routes by capability * docs: link capability projection changeset
1 parent 4ea9c5b commit b435f7b

18 files changed

Lines changed: 263 additions & 31 deletions

File tree

‎.changeset/event-route-requires.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'agent-bundle': minor
3+
---
4+
5+
Add event-route `config.requires` capability selection with `AB4824` and `AB4825` diagnostics for unsupported rows or incompatible selectors (#679).

‎docs/diagnostics.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,8 +1299,8 @@ resolving a provider set the author did not write.
12991299
| `AB4821` | error | A project state definition uses the reserved notice-ledger id `@agent-bundle/runtime/agent-notice-ledger/v1`; generated runtimes own that id for the co-mounted notice store. |
13001300
| `AB4822` | error | A `routes.mcpCommands.include` or `.exclude` pattern matches no eligible tool, or `include: []` explicitly selects none. Correct the pattern using an available `<server>:<tool>` identity listed by the diagnostic. |
13011301
| `AB4823` | error | An event route declares an event outside the v1 event vocabulary. |
1302-
| `AB4824` | error | An event route selects an unknown target or requires an event capability that the selected target does not support. |
1303-
| `AB4825` | error | An event route's `config.targets` is not a nonempty array of nonempty target names. |
1302+
| `AB4824` | error | An event route selects an unknown target, requires an event the selected target does not support, or declares a capability row in `config.requires` that no selected host supports. An unmet requirement names the row and every selected host considered. |
1303+
| `AB4825` | error | An event route declares both `config.targets` and `config.requires`, or either selector is not a nonempty array of nonempty target names or capability row ids. |
13041304
| `AB4826` | error | A route's static `config` calls `appResourceUri('<app>')` with a reference that matches no App route of the route's own generated server with a static `config.resourceUri`: an unknown name, another server's App (a generated server registers only its own Apps), or a reference from a non-MCP route. The message names the cause and lists the server's known App route ids; reference the App as `'<app>'`, `'<server>/<app>'`, `'app:<server>/<app>'`, or a relative module path. |
13051305
| `AB4827` | error | An MCP App route's `config.template` is ambiguous or missing: both the route-relative and the project-root-relative interpretation name different existing files, or neither exists. The message names both candidate paths; templates resolve relative to the route module, so rewrite the path as `'./<file>.html'` beside the route. |
13061306
| `AB4828` | error | A generated MCP route advertises `_meta.ui.resourceUri` of an App on its server (through `appResourceUri()` or a literal) that is not built for every target the server ships to, because the App's `config.targets` (or a config-declared App's `targets`) is narrower. Widen the App's targets or restrict `mcp.servers.<server>.targets`. |

‎packages/agent-bundle/src/adapters/capability-state.ts‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ export interface EventRouteCapabilityTableEntry {
8080
readonly state: string;
8181
}
8282

83+
const eventRouteRequirementFeatures: Readonly<Record<string, readonly string[]>> = Object.freeze({
84+
'session/start': Object.freeze(['context']),
85+
stop: Object.freeze(['deny']),
86+
'tool/after': Object.freeze(['context']),
87+
'tool/before': Object.freeze(['deny']),
88+
});
89+
90+
const eventRequirementName = (event: string): string =>
91+
`events.${event.replace(/\/(.)/gu, (_, character: string) => character.toUpperCase())}`;
92+
8393
/**
8494
* Converts a pinned host table's semantic-event rows into the shared
8595
* capability-state namespace consumed by route validation and inspect.
@@ -88,18 +98,23 @@ export const eventRouteCapabilitiesFrom = (
8898
routes: Readonly<Record<string, EventRouteCapabilityTableEntry>>,
8999
evidence: CapabilityEvidence,
90100
): Readonly<Record<string, CapabilityState>> => Object.freeze(Object.fromEntries(
91-
Object.entries(routes).sort(([left], [right]) => left.localeCompare(right)).map(([event, capability]) => {
101+
Object.entries(routes).sort(([left], [right]) => left.localeCompare(right)).flatMap(([event, capability]) => {
102+
let state: CapabilityState;
92103
switch (capability.state) {
93104
case 'supported':
94-
return [`event:${event}`, supportedCapability(evidence)];
105+
state = supportedCapability(evidence);
106+
break;
95107
case 'unavailable':
96-
return [
97-
`event:${event}`,
98-
unavailableCapability(capability.reason ?? `The pinned ${evidence.target} contract does not support ${event}.`),
99-
];
108+
state = unavailableCapability(capability.reason ?? `The pinned ${evidence.target} contract does not support ${event}.`);
109+
break;
100110
default:
101111
throw new TypeError(`Unsupported event-route capability state ${JSON.stringify(capability.state)} for ${event}.`);
102112
}
113+
const requirement = eventRequirementName(event);
114+
return [
115+
[`event:${event}`, state],
116+
...(eventRouteRequirementFeatures[event] ?? []).map((feature) => [`${requirement}.${feature}`, state] as const),
117+
];
103118
}),
104119
));
105120

‎packages/agent-bundle/src/config/normalize.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import type {
6666
} from '../core/types.ts';
6767
import { appRouteTemplatePath, resolveAppRouteTemplate } from '../routes/app-template.ts';
6868
import { eventRouteExecutionFor } from '../routes/event-execution.ts';
69+
import { targetsSatisfyingEventRequirements } from '../routes/event-requirements.ts';
6970
import { mcpRouteProtocolName } from '../routes/protocol-name.ts';
7071
import type { CompiledCliSurface } from '../routes/types.ts';
7172
import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts';
@@ -535,9 +536,14 @@ const normalizeHooks = (
535536
for (const route of discovered.routeGraph?.events ?? []) {
536537
const event = route.event!;
537538
const selected = route.config['targets'];
539+
const requires = route.config['requires'];
538540
const targets = sortedUnique(
539-
(Array.isArray(selected) ? selected.filter((target): target is string => typeof target === 'string') : targetNames)
540-
.filter((target) => targetNames.includes(target)),
541+
targetsSatisfyingEventRequirements(
542+
requires,
543+
(Array.isArray(selected) ? selected.filter((target): target is string => typeof target === 'string') : targetNames)
544+
.filter((target) => targetNames.includes(target)),
545+
registry,
546+
),
541547
);
542548
const tools = (Array.isArray(route.config['tools']) ? route.config['tools'] : [])
543549
.filter((tool): tool is CanonicalHookTool =>

‎packages/agent-bundle/src/config/validate.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from '../core/runtime.ts';
2727
import { canonicalHookEvents, isPrebuiltEntryInput, parseNativeHookToolSelector } from '../core/types.ts';
2828
import { type RouteModuleExports, scanRouteModuleExports } from '../routes/contract.ts';
29+
import { targetsSatisfyingEventRequirements } from '../routes/event-requirements.ts';
2930
import { mcpRouteProtocolName } from '../routes/protocol-name.ts';
3031
import { featureCapabilityName } from '../core/components.ts';
3132
import { isServeAppAllowCapability } from '../core/mcp-app-allow.ts';
@@ -1429,6 +1430,15 @@ const validateEventRoutes = (
14291430

14301431
for (const route of discovered.routeGraph?.events ?? []) {
14311432
const declaredTargets = route.config['targets'];
1433+
const declaredRequirements = route.config['requires'];
1434+
if (declaredTargets !== undefined && declaredRequirements !== undefined) {
1435+
diagnostics.push(sourceDiagnostic(
1436+
'AB4825',
1437+
`Event route ${route.provenance.relativePath} declares both config.targets and config.requires; choose one projection selector.`,
1438+
route.source,
1439+
));
1440+
continue;
1441+
}
14321442
if (
14331443
declaredTargets !== undefined &&
14341444
(!Array.isArray(declaredTargets) ||
@@ -1442,8 +1452,36 @@ const validateEventRoutes = (
14421452
));
14431453
continue;
14441454
}
1455+
if (
1456+
declaredRequirements !== undefined &&
1457+
(!Array.isArray(declaredRequirements) ||
1458+
declaredRequirements.length === 0 ||
1459+
declaredRequirements.some((requirement) => typeof requirement !== 'string' || requirement.trim().length === 0))
1460+
) {
1461+
diagnostics.push(sourceDiagnostic(
1462+
'AB4825',
1463+
`Event route ${route.provenance.relativePath} config.requires must be a nonempty array of capability row ids.`,
1464+
route.source,
1465+
));
1466+
continue;
1467+
}
1468+
const requirements = declaredRequirements === undefined
1469+
? []
1470+
: [...new Set(declaredRequirements as readonly string[])];
1471+
for (const requirement of requirements) {
1472+
if (selectedTargets.some((target) => registry.supports(target, requirement))) continue;
1473+
for (const target of selectedTargets) {
1474+
diagnostics.push({
1475+
code: 'AB4824',
1476+
message: `Event route ${route.provenance.relativePath} requires capability row ${requirement}, unsupported on ${target}.`,
1477+
severity: 'error',
1478+
sourcePath: route.source,
1479+
target,
1480+
});
1481+
}
1482+
}
14451483
const targets = declaredTargets === undefined
1446-
? selectedTargets
1484+
? targetsSatisfyingEventRequirements(requirements, selectedTargets, registry)
14471485
: [...new Set(declaredTargets as readonly string[])];
14481486
for (const target of targets) {
14491487
if (!registry.has(target)) {

‎packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
canonicalAgentEvents,
3131
type CanonicalAgentEvent,
3232
} from '../../routes/public.ts';
33+
import { targetsSatisfyingEventRequirements } from '../../routes/event-requirements.ts';
3334
import type { CompiledAgentRoute, CompiledRouteGraph } from '../../routes/types.ts';
3435
import type { RenderRouteContext, renderRouteEvents } from '../../test/render.ts';
3536
import type { AgentRouteModule } from '../../test/types.ts';
@@ -479,10 +480,15 @@ export class LifecycleReplayService {
479480
projectTargets: readonly string[],
480481
): Readonly<{ readonly diagnostics: readonly LifecycleDiagnostic[]; readonly targets: readonly LifecycleTarget[] }> {
481482
const configured = route.config['targets'];
483+
const requirements = route.config['requires'];
482484
const selected = expandedTargets(
483-
Array.isArray(configured)
484-
? configured.filter((target): target is string => typeof target === 'string')
485-
: projectTargets,
485+
targetsSatisfyingEventRequirements(
486+
requirements,
487+
Array.isArray(configured)
488+
? configured.filter((target): target is string => typeof target === 'string')
489+
: projectTargets,
490+
this.#registry,
491+
),
486492
);
487493
const available = expandedTargets(projectTargets);
488494
const diagnostics: LifecycleDiagnostic[] = [];
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { NormalizationTargetRegistry } from '../core/types.ts';
2+
3+
export const targetsSatisfyingEventRequirements = (
4+
requirements: unknown,
5+
targets: readonly string[],
6+
registry: Pick<NormalizationTargetRegistry, 'supports'>,
7+
): readonly string[] =>
8+
!Array.isArray(requirements)
9+
? targets
10+
: targets.filter((target) => requirements.every((requirement) =>
11+
typeof requirement === 'string' && registry.supports(target, requirement)));

‎packages/agent-bundle/src/routes/public.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,8 @@ export interface AgentEventRouteConfig {
361361
* compatibility behavior of resolving all providers; use `[]` for none.
362362
*/
363363
readonly providers?: readonly string[];
364+
/** Capability rows every projected host must support; mutually exclusive with `targets`. */
365+
readonly requires?: readonly string[];
364366
readonly runtime?: AgentEventRuntimeMode;
365367
readonly targets?: readonly string[];
366368
/** Route budget within the adapter's stricter native-host deadline. */

‎packages/agent-bundle/tests/adapter-capability-states.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,6 +1057,26 @@ it('reports the evidence-backed G10 event family matrix without inferred support
10571057
state: 'unavailable',
10581058
});
10591059
}
1060+
const requirementRows = [
1061+
'events.sessionStart.context',
1062+
'events.stop.deny',
1063+
'events.toolAfter.context',
1064+
'events.toolBefore.deny',
1065+
];
1066+
for (const target of ['claude', 'codex', 'cursor'] as const) {
1067+
for (const capability of requirementRows) {
1068+
expect(registry.get(target).capabilities[capability]).toMatchObject({
1069+
evidence: { target },
1070+
state: 'supported',
1071+
});
1072+
}
1073+
}
1074+
for (const capability of requirementRows) {
1075+
expect(registry.get('portable').capabilities[capability]).toMatchObject({
1076+
state: 'unavailable',
1077+
});
1078+
}
1079+
expect(registry.get('cursor').capabilities['events.toolBefore.context']).toBeUndefined();
10601080
});
10611081

10621082
it('reports evidence-backed installation support only for real host targets', () => {

‎packages/agent-bundle/tests/build-compose.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,35 @@ describe('composite plugin root (#555)', () => {
546546
}
547547
});
548548

549+
it('normalizes the same event projections for requires as explicit targets', async () => {
550+
const eventRouteTargets = async (selection: string): Promise<readonly string[]> => {
551+
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-event-projections-'));
552+
roots.push(root);
553+
await Promise.all([
554+
writeProjectFile(root, 'package.json', '{"name":"event-projections","type":"module","version":"1.0.0"}\n'),
555+
writeProjectFile(root, 'agent-bundle.config.ts', [
556+
'export default {',
557+
" plugin: { name: 'event-projections', version: '1.0.0' },",
558+
" targets: ['claude', 'codex', 'cursor', 'portable'],",
559+
'};',
560+
'',
561+
].join('\n')),
562+
writeProjectFile(root, 'src/events/tool/before.tsx', [
563+
`export const config = { ${selection}, runtime: 'standalone' };`,
564+
'export default async function ToolBefore() { return undefined; }',
565+
'',
566+
].join('\n')),
567+
]);
568+
const result = await validate({ root });
569+
expect(result.diagnostics).toEqual([]);
570+
return result.model?.hooks.find((hook) => hook.eventRoute?.event === 'tool/before')?.targets ?? [];
571+
};
572+
573+
expect(await eventRouteTargets("requires: ['events.toolBefore.deny']")).toEqual(
574+
await eventRouteTargets("targets: ['claude', 'codex', 'cursor']"),
575+
);
576+
});
577+
549578
it('refuses one path planned with different bytes by two selected projections (AB4103)', { timeout: 120_000 }, async () => {
550579
// A Claude-only frontmatter extension lowers the skill to different
551580
// Markdown for Claude Code than for Codex, yet both hosts read

0 commit comments

Comments
 (0)