Skip to content

Commit 9f61247

Browse files
fix(skills): serve agent-bundle/meta to rendered skills and compile their JSX without the consumer react runtime (#527)
* fix(skills): serve agent-bundle/meta to rendered skills and compile their JSX against the loader's element factory Rendered SKILL.tsx modules evaluate through jiti during discovery, outside the compiler's specifier replacement, so 'agent-bundle/meta' fell through to the published module and threw AB4760 inside AB3003 (#440). The loader now aliases the specifier to the same generated identity module the build stamps, fed from one pluginIdentity() derivation normalization also uses for model.metadata. The skill's JSX compiled against the consumer's react/jsx-runtime, which jiti resolves without the process's --conditions while React's own inner require('react') honours them; under the route-unit pool's react-server condition the client jsx runtime met the server React and threw (recentlyCreatedOwnerStacks) so inspectWorkbenchSurface failed with AB3005 (#441). The loader now aliases react/jsx-runtime and react/jsx-dev-runtime to its own plain element factory: the Markdown renderer never needed React. Fixes #440 Fixes #441 * chore: reference PR number in changeset * fix(config): keep a malformed plugin block the validator's AB4000 when discovery derives the skill identity Review: discovery dereferenced config.plugin.name before validateSource ran, turning {} or plugin: null into a generic AB7000. declaredPluginIdentity() returns undefined for an unusable plugin.name; the rendered skill then gets no identity (AB3003) and validation reports AB4000 as before.
1 parent e4e960a commit 9f61247

13 files changed

Lines changed: 395 additions & 41 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
Let a rendered Skill (`src/skills/<name>/SKILL.tsx`) import `agent-bundle/meta` and evaluate independently of the process's `react` resolution. The skill loader now aliases `agent-bundle/meta` to the same generated identity module the compiler stamps into every built surface — `{ name, packageName, packageVersion, version }` derived from `plugin.name`, `package.json`, and the resolved plugin version — under `validate`, `build`, `inspect`, dev, the Workbench's source Skill documents, and `inspectWorkbenchSurface`, instead of failing with `AB3003` wrapping `AB4760`. The skill's JSX compiles against the loader's own element factory rather than the project's `react/jsx-runtime`, so `inspectWorkbenchSurface` no longer fails with `AB3005` (`recentlyCreatedOwnerStacks`) on a project with a rendered skill when the test runs under the `react-server` condition the `agentBundleRstest()` route-unit pool sets. Fixes #440 and #441 (#527)

‎docs/diagnostics.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,15 @@ aliased module throws the same `AB4760` naming the compiler diagnostics and
439439
the recovery "fix them, then rerun Rstest" — the manifest's placeholder
440440
identity is never served as a real one.
441441

442+
Rendered skills (`src/skills/<name>/SKILL.tsx`) evaluate during discovery,
443+
and the skill loader aliases the specifier to the same generated module fed
444+
from the identity normalization stamps into the model, so a skill importing
445+
`agent-bundle/meta` compiles under `validate`, `build`, `inspect`, dev, and
446+
`inspectWorkbenchSurface` (#440). Only a direct `parseSkill` call without a
447+
project identity leaves the specifier to resolve as the project resolves
448+
`agent-bundle`; the published module then raises this diagnostic inside the
449+
skill's `AB3003`.
450+
442451
| Code | Severity | Trigger | Recovery |
443452
| --- | --- | --- | --- |
444453
| `AB4760` | error | A module evaluated the published `agent-bundle/meta` outside a surface Agent Bundle compiles — typically a unit test pool not built from the Rstest preset, or a hand-run script importing plugin source. | Run the test under `agentBundleRstest()` or `agentBundleBrowserRstest()` from `agent-bundle/rstest` (pass `include` to cover a plain unit pool), or compile the surface with `agent-bundle build`. In a custom test runner, alias `agent-bundle/meta` (`resolve.alias`, exact match) to a module with the named exports `{ name, packageName, packageVersion, version, meta }` — `meta` the frozen object of the other four, exported as both the named binding and the default export — computed from the project's `agent-bundle.config.ts` plugin name and `package.json` version; the `.agent-bundle/test/meta.mjs` module `agentBundleRstest()` writes is that module. |

‎docs/entry-conventions.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,14 @@ route-unit level, `renderRoute`, and `invokeCli` alike — with the identity
914914
does not use the preset must add the same alias; the `AB4760` recovery text
915915
spells it out (see [Diagnostics](diagnostics.md#build-time-identity-outside-the-compiler-ab4760)).
916916

917+
Rendered skills are not outside it either (#440): `src/skills/<name>/SKILL.tsx`
918+
evaluates during discovery, before any bundle exists, and the skill loader
919+
aliases the specifier to the same generated module fed from the identity
920+
normalization is about to stamp into the model (`plugin.name`, the
921+
`package.json` axes, the resolved version). A skill that prints `version`
922+
prints the one its artifact manifest reports, under `validate`, `build`,
923+
`inspect`, dev, the Workbench's source documents, and `inspectWorkbenchSurface`.
924+
917925
## Prebuilt payloads — package what you compiled yourself
918926

919927
Some projects legitimately own their compilation — a coordinated

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { basename, dirname, relative, resolve } from 'node:path';
33

44
import fastGlob from 'fast-glob';
55

6+
import { projectMeta } from '../build/meta.ts';
67
import { isErrno } from '../core/errors.ts';
78
import { isInside } from '../core/paths.ts';
89
import { isRecord } from '../core/strict-json.ts';
@@ -11,6 +12,7 @@ import { compileRouteGraph, isEmptyRouteGraph } from '../routes/graph.ts';
1112
import type { CompiledRouteGraph } from '../routes/types.ts';
1213
import { parseCommand, type CommandDocument } from './command.ts';
1314
import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts';
15+
import { declaredPluginIdentity } from './plugin-identity.ts';
1416
import { isRenderedSkillSourceName } from './rendered-skill.ts';
1517
import { parseRule, type RuleDocument } from './rule.ts';
1618
import { parseSkill, type SkillDocument } from './skill.ts';
@@ -253,6 +255,13 @@ export const discoverProject = async (
253255
): Promise<DiscoveredProject> => {
254256
const projectRoot = resolve(root);
255257
const rules = await readProjectIgnoreRules(projectRoot);
258+
// Rendered skills evaluate during discovery, before normalization stamps
259+
// the same identity into the model; `agent-bundle/meta` serves it to them
260+
// here so a skill documents the version its plugin ships (#440). A config
261+
// without a usable `plugin.name` is the validator's AB4000, not a crash
262+
// here, and such a skill gets no identity rather than a fabricated one.
263+
const identity = declaredPluginIdentity(projectRoot, config as Readonly<Record<string, unknown>>);
264+
const meta = identity === undefined ? undefined : projectMeta(identity);
256265
const configuredSkills = config.skills;
257266
const conventionalSources = (await fastGlob('src/skills/*/SKILL.{md,ts,tsx}', {
258267
absolute: true,
@@ -346,7 +355,7 @@ export const discoverProject = async (
346355
...(discoveredRules.length === 0 ? {} : { rules: discoveredRules }),
347356
...(shadowedConventionalSkills.length === 0 ? {} : { shadowedConventionalSkills }),
348357
skills: await Promise.all(
349-
skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules)),
358+
skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules, meta === undefined ? {} : { meta })),
350359
),
351360
...(state === undefined ? {} : { state }),
352361
};

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

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {
1515
parseRuntimeVersion,
1616
satisfiesGeneratedRuntimeFloor,
1717
} from '../core/runtime.ts';
18-
import { developmentFallbackVersion, snapshotPackageIdentity } from '../core/project-context.ts';
1918
import { isRecord } from '../core/strict-json.ts';
2019
import { conventionalEntryAt } from './conventional-entry.ts';
20+
import { pluginIdentity } from './plugin-identity.ts';
2121
import {
2222
canonicalHookEvents,
2323
isPrebuiltEntryInput,
@@ -1201,22 +1201,6 @@ const normalizeRules = (
12011201
};
12021202
});
12031203

1204-
/**
1205-
* The one plugin version every surface agrees on (issue #94 stage 3): an
1206-
* authored `plugin.version` still wins so a legacy declaration never changes
1207-
* meaning mid-migration (a disagreement with package.json is the AB4008
1208-
* warning), an omitted one derives the release version from package.json,
1209-
* and a project with neither carries the development fallback that
1210-
* `agent-bundle build` refuses to package (AB4013).
1211-
*/
1212-
const resolvePluginVersion = (
1213-
authored: unknown,
1214-
packageVersion: string | undefined,
1215-
): string =>
1216-
(typeof authored === 'string' && authored.trim().length > 0 ? authored : undefined)
1217-
?? packageVersion
1218-
?? developmentFallbackVersion;
1219-
12201204
/** Selects the generated-executable floor; invalid raises fall back to the default the validator rejected. */
12211205
const normalizeRuntime = (loaded: LoadedConfig): NormalizedRuntime => {
12221206
const node = loaded.config.runtime?.node;
@@ -1272,9 +1256,9 @@ export const normalizeProject = async (
12721256
const logo = normalizePluginLogo(loaded);
12731257
// The npm package axes are derived, never authored in config: package.json
12741258
// is authoritative for release identity (issue #94), while plugin.version
1275-
// remains the host-facing declared version during the migration.
1276-
const packageIdentity = snapshotPackageIdentity(loaded.context.projectRoot);
1277-
const version = resolvePluginVersion(loaded.config.plugin.version, packageIdentity.packageVersion);
1259+
// remains the host-facing declared version during the migration. The same
1260+
// derivation serves `agent-bundle/meta` to rendered skills at discovery.
1261+
const identity = pluginIdentity(loaded.context.projectRoot, loaded.config);
12781262
const hostBins = await normalizeHostBins(loaded, targetNames, registry);
12791263
const hostOutputStyles = await normalizeHostPayloadDirectories(
12801264
loaded,
@@ -1323,11 +1307,11 @@ export const normalizeProject = async (
13231307
...(typeof description === 'string' ? { description } : {}),
13241308
id: `plugin:${loaded.config.plugin.name}`,
13251309
...(logo === undefined ? {} : { logo }),
1326-
name: loaded.config.plugin.name,
1327-
...(packageIdentity.packageName === undefined ? {} : { packageName: packageIdentity.packageName }),
1328-
...(packageIdentity.packageVersion === undefined ? {} : { packageVersion: packageIdentity.packageVersion }),
1310+
name: identity.name,
1311+
...(identity.packageName === undefined ? {} : { packageName: identity.packageName }),
1312+
...(identity.packageVersion === undefined ? {} : { packageVersion: identity.packageVersion }),
13291313
provenance: configProvenance,
1330-
version,
1314+
version: identity.version,
13311315
},
13321316
mcpApps: normalizeMcpApps(loaded, discovered, mcpServers),
13331317
mcpServers,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { ProjectMetaSource } from '../build/meta.ts';
2+
import { developmentFallbackVersion, snapshotPackageIdentity } from '../core/project-context.ts';
3+
import { isRecord } from '../core/strict-json.ts';
4+
import type { AgentBundleConfig } from '../core/types.ts';
5+
6+
/**
7+
* The one plugin version every surface agrees on (issue #94 stage 3): an
8+
* authored `plugin.version` still wins so a legacy declaration never changes
9+
* meaning mid-migration (a disagreement with package.json is the AB4008
10+
* warning), an omitted one derives the release version from package.json,
11+
* and a project with neither carries the development fallback that
12+
* `agent-bundle build` refuses to package (AB4013).
13+
*/
14+
export const resolvePluginVersion = (
15+
authored: unknown,
16+
packageVersion: string | undefined,
17+
): string =>
18+
(typeof authored === 'string' && authored.trim().length > 0 ? authored : undefined)
19+
?? packageVersion
20+
?? developmentFallbackVersion;
21+
22+
/**
23+
* The plugin identity axes a project carries before normalization: the
24+
* host-native slug from `plugin.name`, the npm axes derived from
25+
* `<root>/package.json` (never authored in config), and the resolved plugin
26+
* version. `normalizeProject` stamps exactly these into `model.metadata`, and
27+
* the rendered-skill loader serves them as `agent-bundle/meta` while
28+
* discovery evaluates `SKILL.tsx` — one derivation, so a skill that prints
29+
* the plugin version prints the one the artifact manifest reports.
30+
*/
31+
export const pluginIdentity = (
32+
projectRoot: string,
33+
config: Pick<AgentBundleConfig, 'plugin'>,
34+
): ProjectMetaSource => {
35+
const packageIdentity = snapshotPackageIdentity(projectRoot);
36+
return Object.freeze({
37+
name: config.plugin.name,
38+
packageName: packageIdentity.packageName,
39+
packageVersion: packageIdentity.packageVersion,
40+
version: resolvePluginVersion(config.plugin.version, packageIdentity.packageVersion),
41+
});
42+
};
43+
44+
/**
45+
* {@link pluginIdentity} for a configuration that has not been validated yet
46+
* (discovery runs before `validateSource`): undefined when `plugin.name` is
47+
* not a nonempty string, so a malformed `plugin` block stays the validator's
48+
* `AB4000` to report rather than a crash here, and no fabricated identity is
49+
* ever served in its place.
50+
*/
51+
export const declaredPluginIdentity = (
52+
projectRoot: string,
53+
config: Readonly<Record<string, unknown>>,
54+
): ProjectMetaSource | undefined => {
55+
const plugin = config.plugin;
56+
if (!isRecord(plugin) || typeof plugin.name !== 'string' || plugin.name.trim().length === 0) return undefined;
57+
// `resolvePluginVersion` already treats a non-string `version` as absent.
58+
return pluginIdentity(projectRoot, {
59+
plugin: { name: plugin.name, ...(typeof plugin.version === 'string' ? { version: plugin.version } : {}) },
60+
});
61+
};

‎packages/agent-bundle/src/config/rendered-skill.ts‎

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { existsSync, statSync } from 'node:fs';
2+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3+
import { tmpdir } from 'node:os';
24
import { join } from 'node:path';
35

46
import { createJiti } from 'jiti';
57
import { stringify as stringifyYaml } from 'yaml';
68

9+
import { generatedMetaModuleSource, metaModuleSpecifier } from '../build/meta.ts';
710
import type { Diagnostic } from '../core/diagnostics.ts';
811
import { errorMessage } from '../core/errors.ts';
912
import { isPlainRecord } from '../core/strict-json.ts';
13+
import type { AgentBundleMeta } from '../meta.ts';
1014
import { MarkdownRenderError, renderElementToMarkdown } from './render-markdown.ts';
1115

1216
/**
@@ -52,16 +56,89 @@ const failure = (code: string, message: string, sourcePath: string): RenderedSki
5256
status: 'failed',
5357
});
5458

59+
/** What the loader serves to a rendered skill module beyond the project's own code. */
60+
export interface RenderedSkillLoaderOptions {
61+
/**
62+
* The project identity `agent-bundle/meta` resolves to while the module
63+
* evaluates — the same constants the compiler stamps into every built
64+
* surface. Without it the specifier resolves to the published module,
65+
* which throws `AB4760` (no compiler, no identity).
66+
*/
67+
readonly meta?: AgentBundleMeta;
68+
}
69+
70+
/**
71+
* The element factory a rendered skill's JSX compiles against. It builds the
72+
* plain `{ type, props, key }` objects `renderElementToMarkdown` walks, with
73+
* React's fragment symbol, and never touches React: the consumer's `react`
74+
* resolves by process condition (`--conditions=react-server` in the
75+
* route-unit pool selects the server build) while jiti resolves package
76+
* subpaths without those conditions, so binding `react/jsx-runtime` from
77+
* one build to `react` from the other throws inside React (#441). The skill
78+
* renderer needs no React internals at all, so it does not resolve any.
79+
*/
80+
const jsxRuntimeModuleSource = [
81+
'// Generated by agent-bundle for one rendered-skill evaluation. Do not edit.',
82+
"const elementType = Symbol.for('react.transitional.element');",
83+
"export const Fragment = Symbol.for('react.fragment');",
84+
'const element = (type, props, key) => ({',
85+
' $$typeof: elementType,',
86+
' key: key === undefined ? null : String(key),',
87+
' props,',
88+
' ref: null,',
89+
' type,',
90+
'});',
91+
'export const jsx = element;',
92+
'export const jsxs = element;',
93+
'export const jsxDEV = element;',
94+
'',
95+
].join('\n');
96+
97+
const reactJsxRuntimeSpecifiers = ['react/jsx-runtime', 'react/jsx-dev-runtime'] as const;
98+
99+
/**
100+
* Writes the generated modules one evaluation aliases into a private
101+
* temporary directory and returns jiti's alias record. Nothing is written
102+
* under the project: discovery runs during `validate`, which never touches
103+
* the tree it validates.
104+
*/
105+
const writeLoaderModules = async (
106+
directory: string,
107+
options: RenderedSkillLoaderOptions,
108+
): Promise<Record<string, string>> => {
109+
const jsxRuntimePath = join(directory, 'jsx-runtime.mjs');
110+
const writes = [writeFile(jsxRuntimePath, jsxRuntimeModuleSource, 'utf8')];
111+
const alias: Record<string, string> = Object.fromEntries(
112+
reactJsxRuntimeSpecifiers.map((specifier) => [specifier, jsxRuntimePath]),
113+
);
114+
if (options.meta !== undefined) {
115+
const metaPath = join(directory, 'meta.mjs');
116+
writes.push(writeFile(metaPath, generatedMetaModuleSource(options.meta), 'utf8'));
117+
alias[metaModuleSpecifier] = metaPath;
118+
}
119+
await Promise.all(writes);
120+
return alias;
121+
};
122+
55123
/**
56124
* Loads and compiles one rendered skill source to its Markdown document. The
57125
* module executes through the same jiti pipeline that already runs consumer
58-
* TypeScript at config-load time, with the automatic JSX runtime resolved
59-
* from the consumer project.
126+
* TypeScript at config-load time. Its JSX compiles against the loader's own
127+
* element factory rather than the consumer's `react/jsx-runtime`, and
128+
* `agent-bundle/meta` resolves to the project identity when the caller
129+
* supplies one; project code the skill imports resolves from the project as
130+
* usual.
60131
*/
61-
export const compileRenderedSkill = async (source: string): Promise<RenderedSkillCompilation> => {
132+
export const compileRenderedSkill = async (
133+
source: string,
134+
options: RenderedSkillLoaderOptions = {},
135+
): Promise<RenderedSkillCompilation> => {
62136
let moduleExports: Record<string, unknown>;
137+
const loaderDirectory = await mkdtemp(join(tmpdir(), 'agent-bundle-rendered-skill-'));
63138
try {
139+
const alias = await writeLoaderModules(loaderDirectory, options);
64140
const jiti = createJiti(source, {
141+
alias,
65142
interopDefault: true,
66143
jsx: { runtime: 'automatic' },
67144
moduleCache: false,
@@ -70,6 +147,8 @@ export const compileRenderedSkill = async (source: string): Promise<RenderedSkil
70147
moduleExports = await jiti.import<Record<string, unknown>>(source);
71148
} catch (error) {
72149
return failure('AB3003', `Rendered Skill module failed to load: ${errorMessage(error)}`, source);
150+
} finally {
151+
await rm(loaderDirectory, { force: true, recursive: true });
73152
}
74153

75154
const component = moduleExports.default;

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ import {
1414
import {
1515
compileRenderedSkill,
1616
isRenderedSkillSourceName,
17+
type RenderedSkillLoaderOptions,
1718
renderedSkillSourceAt,
1819
} from './rendered-skill.ts';
1920
import { parseSkillMarkdown } from './skill-references.ts';
2021

22+
/** What a rendered skill module observes while it evaluates; see {@link RenderedSkillLoaderOptions}. */
23+
export type ParseSkillOptions = RenderedSkillLoaderOptions;
24+
2125
export interface SkillResource {
2226
bytes: number;
2327
relativePath: string;
@@ -124,8 +128,9 @@ const parseRenderedSkill = async (
124128
dir: string,
125129
renderedSource: string,
126130
resources: SkillResource[],
131+
options: ParseSkillOptions,
127132
): Promise<SkillDocument> => {
128-
const compiled = await compileRenderedSkill(renderedSource);
133+
const compiled = await compileRenderedSkill(renderedSource, options);
129134
if (compiled.status === 'failed') {
130135
return {
131136
body: '',
@@ -158,6 +163,7 @@ export const parseSkill = async (
158163
projectRoot?: string,
159164
/** Reuses the caller's compiled ignore rules; discovery parses many skills under one root. */
160165
projectIgnoreRules?: Ignore,
166+
options: ParseSkillOptions = {},
161167
): Promise<SkillDocument> => {
162168
const dir = resolve(skillDir);
163169
const source = join(dir, 'SKILL.md');
@@ -171,7 +177,7 @@ export const parseSkill = async (
171177
markdown = await readFile(source, 'utf8');
172178
} catch (error: unknown) {
173179
if (renderedSource !== undefined && isErrno(error, 'ENOENT')) {
174-
return parseRenderedSkill(dir, renderedSource, resources);
180+
return parseRenderedSkill(dir, renderedSource, resources, options);
175181
}
176182
return {
177183
body: '',

0 commit comments

Comments
 (0)