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/440-441-rendered-skill-loader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

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)
9 changes: 9 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,15 @@ aliased module throws the same `AB4760` naming the compiler diagnostics and
the recovery "fix them, then rerun Rstest" — the manifest's placeholder
identity is never served as a real one.

Rendered skills (`src/skills/<name>/SKILL.tsx`) evaluate during discovery,
and the skill loader aliases the specifier to the same generated module fed
from the identity normalization stamps into the model, so a skill importing
`agent-bundle/meta` compiles under `validate`, `build`, `inspect`, dev, and
`inspectWorkbenchSurface` (#440). Only a direct `parseSkill` call without a
project identity leaves the specifier to resolve as the project resolves
`agent-bundle`; the published module then raises this diagnostic inside the
skill's `AB3003`.

| Code | Severity | Trigger | Recovery |
| --- | --- | --- | --- |
| `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. |
Expand Down
8 changes: 8 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,14 @@ route-unit level, `renderRoute`, and `invokeCli` alike — with the identity
does not use the preset must add the same alias; the `AB4760` recovery text
spells it out (see [Diagnostics](diagnostics.md#build-time-identity-outside-the-compiler-ab4760)).

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

## Prebuilt payloads — package what you compiled yourself

Some projects legitimately own their compilation — a coordinated
Expand Down
11 changes: 10 additions & 1 deletion packages/agent-bundle/src/config/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { basename, dirname, relative, resolve } from 'node:path';

import fastGlob from 'fast-glob';

import { projectMeta } from '../build/meta.ts';
import { isErrno } from '../core/errors.ts';
import { isInside } from '../core/paths.ts';
import { isRecord } from '../core/strict-json.ts';
Expand All @@ -11,6 +12,7 @@ import { compileRouteGraph, isEmptyRouteGraph } from '../routes/graph.ts';
import type { CompiledRouteGraph } from '../routes/types.ts';
import { parseCommand, type CommandDocument } from './command.ts';
import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts';
import { declaredPluginIdentity } from './plugin-identity.ts';
import { isRenderedSkillSourceName } from './rendered-skill.ts';
import { parseRule, type RuleDocument } from './rule.ts';
import { parseSkill, type SkillDocument } from './skill.ts';
Expand Down Expand Up @@ -253,6 +255,13 @@ export const discoverProject = async (
): Promise<DiscoveredProject> => {
const projectRoot = resolve(root);
const rules = await readProjectIgnoreRules(projectRoot);
// Rendered skills evaluate during discovery, before normalization stamps
// the same identity into the model; `agent-bundle/meta` serves it to them
// here so a skill documents the version its plugin ships (#440). A config
// without a usable `plugin.name` is the validator's AB4000, not a crash
// here, and such a skill gets no identity rather than a fabricated one.
const identity = declaredPluginIdentity(projectRoot, config as Readonly<Record<string, unknown>>);
const meta = identity === undefined ? undefined : projectMeta(identity);
const configuredSkills = config.skills;
const conventionalSources = (await fastGlob('src/skills/*/SKILL.{md,ts,tsx}', {
absolute: true,
Expand Down Expand Up @@ -346,7 +355,7 @@ export const discoverProject = async (
...(discoveredRules.length === 0 ? {} : { rules: discoveredRules }),
...(shadowedConventionalSkills.length === 0 ? {} : { shadowedConventionalSkills }),
skills: await Promise.all(
skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules)),
skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules, meta === undefined ? {} : { meta })),
),
...(state === undefined ? {} : { state }),
};
Expand Down
32 changes: 8 additions & 24 deletions packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import {
parseRuntimeVersion,
satisfiesGeneratedRuntimeFloor,
} from '../core/runtime.ts';
import { developmentFallbackVersion, snapshotPackageIdentity } from '../core/project-context.ts';
import { isRecord } from '../core/strict-json.ts';
import { conventionalEntryAt } from './conventional-entry.ts';
import { pluginIdentity } from './plugin-identity.ts';
import {
canonicalHookEvents,
isPrebuiltEntryInput,
Expand Down Expand Up @@ -1201,22 +1201,6 @@ const normalizeRules = (
};
});

/**
* The one plugin version every surface agrees on (issue #94 stage 3): an
* authored `plugin.version` still wins so a legacy declaration never changes
* meaning mid-migration (a disagreement with package.json is the AB4008
* warning), an omitted one derives the release version from package.json,
* and a project with neither carries the development fallback that
* `agent-bundle build` refuses to package (AB4013).
*/
const resolvePluginVersion = (
authored: unknown,
packageVersion: string | undefined,
): string =>
(typeof authored === 'string' && authored.trim().length > 0 ? authored : undefined)
?? packageVersion
?? developmentFallbackVersion;

/** Selects the generated-executable floor; invalid raises fall back to the default the validator rejected. */
const normalizeRuntime = (loaded: LoadedConfig): NormalizedRuntime => {
const node = loaded.config.runtime?.node;
Expand Down Expand Up @@ -1272,9 +1256,9 @@ export const normalizeProject = async (
const logo = normalizePluginLogo(loaded);
// The npm package axes are derived, never authored in config: package.json
// is authoritative for release identity (issue #94), while plugin.version
// remains the host-facing declared version during the migration.
const packageIdentity = snapshotPackageIdentity(loaded.context.projectRoot);
const version = resolvePluginVersion(loaded.config.plugin.version, packageIdentity.packageVersion);
// remains the host-facing declared version during the migration. The same
// derivation serves `agent-bundle/meta` to rendered skills at discovery.
const identity = pluginIdentity(loaded.context.projectRoot, loaded.config);
const hostBins = await normalizeHostBins(loaded, targetNames, registry);
const hostOutputStyles = await normalizeHostPayloadDirectories(
loaded,
Expand Down Expand Up @@ -1323,11 +1307,11 @@ export const normalizeProject = async (
...(typeof description === 'string' ? { description } : {}),
id: `plugin:${loaded.config.plugin.name}`,
...(logo === undefined ? {} : { logo }),
name: loaded.config.plugin.name,
...(packageIdentity.packageName === undefined ? {} : { packageName: packageIdentity.packageName }),
...(packageIdentity.packageVersion === undefined ? {} : { packageVersion: packageIdentity.packageVersion }),
name: identity.name,
...(identity.packageName === undefined ? {} : { packageName: identity.packageName }),
...(identity.packageVersion === undefined ? {} : { packageVersion: identity.packageVersion }),
provenance: configProvenance,
version,
version: identity.version,
},
mcpApps: normalizeMcpApps(loaded, discovered, mcpServers),
mcpServers,
Expand Down
61 changes: 61 additions & 0 deletions packages/agent-bundle/src/config/plugin-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { ProjectMetaSource } from '../build/meta.ts';
import { developmentFallbackVersion, snapshotPackageIdentity } from '../core/project-context.ts';
import { isRecord } from '../core/strict-json.ts';
import type { AgentBundleConfig } from '../core/types.ts';

/**
* The one plugin version every surface agrees on (issue #94 stage 3): an
* authored `plugin.version` still wins so a legacy declaration never changes
* meaning mid-migration (a disagreement with package.json is the AB4008
* warning), an omitted one derives the release version from package.json,
* and a project with neither carries the development fallback that
* `agent-bundle build` refuses to package (AB4013).
*/
export const resolvePluginVersion = (
authored: unknown,
packageVersion: string | undefined,
): string =>
(typeof authored === 'string' && authored.trim().length > 0 ? authored : undefined)
?? packageVersion
?? developmentFallbackVersion;

/**
* The plugin identity axes a project carries before normalization: the
* host-native slug from `plugin.name`, the npm axes derived from
* `<root>/package.json` (never authored in config), and the resolved plugin
* version. `normalizeProject` stamps exactly these into `model.metadata`, and
* the rendered-skill loader serves them as `agent-bundle/meta` while
* discovery evaluates `SKILL.tsx` — one derivation, so a skill that prints
* the plugin version prints the one the artifact manifest reports.
*/
export const pluginIdentity = (
projectRoot: string,
config: Pick<AgentBundleConfig, 'plugin'>,
): ProjectMetaSource => {
const packageIdentity = snapshotPackageIdentity(projectRoot);
return Object.freeze({
name: config.plugin.name,
packageName: packageIdentity.packageName,
packageVersion: packageIdentity.packageVersion,
version: resolvePluginVersion(config.plugin.version, packageIdentity.packageVersion),
});
};

/**
* {@link pluginIdentity} for a configuration that has not been validated yet
* (discovery runs before `validateSource`): undefined when `plugin.name` is
* not a nonempty string, so a malformed `plugin` block stays the validator's
* `AB4000` to report rather than a crash here, and no fabricated identity is
* ever served in its place.
*/
export const declaredPluginIdentity = (
projectRoot: string,
config: Readonly<Record<string, unknown>>,
): ProjectMetaSource | undefined => {
const plugin = config.plugin;
if (!isRecord(plugin) || typeof plugin.name !== 'string' || plugin.name.trim().length === 0) return undefined;
// `resolvePluginVersion` already treats a non-string `version` as absent.
return pluginIdentity(projectRoot, {
plugin: { name: plugin.name, ...(typeof plugin.version === 'string' ? { version: plugin.version } : {}) },
});
};
85 changes: 82 additions & 3 deletions packages/agent-bundle/src/config/rendered-skill.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { existsSync, statSync } from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

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

import { generatedMetaModuleSource, metaModuleSpecifier } from '../build/meta.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { errorMessage } from '../core/errors.ts';
import { isPlainRecord } from '../core/strict-json.ts';
import type { AgentBundleMeta } from '../meta.ts';
import { MarkdownRenderError, renderElementToMarkdown } from './render-markdown.ts';

/**
Expand Down Expand Up @@ -52,16 +56,89 @@ const failure = (code: string, message: string, sourcePath: string): RenderedSki
status: 'failed',
});

/** What the loader serves to a rendered skill module beyond the project's own code. */
export interface RenderedSkillLoaderOptions {
/**
* The project identity `agent-bundle/meta` resolves to while the module
* evaluates — the same constants the compiler stamps into every built
* surface. Without it the specifier resolves to the published module,
* which throws `AB4760` (no compiler, no identity).
*/
readonly meta?: AgentBundleMeta;
}

/**
* The element factory a rendered skill's JSX compiles against. It builds the
* plain `{ type, props, key }` objects `renderElementToMarkdown` walks, with
* React's fragment symbol, and never touches React: the consumer's `react`
* resolves by process condition (`--conditions=react-server` in the
* route-unit pool selects the server build) while jiti resolves package
* subpaths without those conditions, so binding `react/jsx-runtime` from
* one build to `react` from the other throws inside React (#441). The skill
* renderer needs no React internals at all, so it does not resolve any.
*/
const jsxRuntimeModuleSource = [
'// Generated by agent-bundle for one rendered-skill evaluation. Do not edit.',
"const elementType = Symbol.for('react.transitional.element');",
"export const Fragment = Symbol.for('react.fragment');",
'const element = (type, props, key) => ({',
' $$typeof: elementType,',
' key: key === undefined ? null : String(key),',
' props,',
' ref: null,',
' type,',
'});',
'export const jsx = element;',
'export const jsxs = element;',
'export const jsxDEV = element;',
'',
].join('\n');

const reactJsxRuntimeSpecifiers = ['react/jsx-runtime', 'react/jsx-dev-runtime'] as const;

/**
* Writes the generated modules one evaluation aliases into a private
* temporary directory and returns jiti's alias record. Nothing is written
* under the project: discovery runs during `validate`, which never touches
* the tree it validates.
*/
const writeLoaderModules = async (
directory: string,
options: RenderedSkillLoaderOptions,
): Promise<Record<string, string>> => {
const jsxRuntimePath = join(directory, 'jsx-runtime.mjs');
const writes = [writeFile(jsxRuntimePath, jsxRuntimeModuleSource, 'utf8')];
const alias: Record<string, string> = Object.fromEntries(
reactJsxRuntimeSpecifiers.map((specifier) => [specifier, jsxRuntimePath]),
);
if (options.meta !== undefined) {
const metaPath = join(directory, 'meta.mjs');
writes.push(writeFile(metaPath, generatedMetaModuleSource(options.meta), 'utf8'));
alias[metaModuleSpecifier] = metaPath;
}
await Promise.all(writes);
return alias;
};

/**
* Loads and compiles one rendered skill source to its Markdown document. The
* module executes through the same jiti pipeline that already runs consumer
* TypeScript at config-load time, with the automatic JSX runtime resolved
* from the consumer project.
* TypeScript at config-load time. Its JSX compiles against the loader's own
* element factory rather than the consumer's `react/jsx-runtime`, and
* `agent-bundle/meta` resolves to the project identity when the caller
* supplies one; project code the skill imports resolves from the project as
* usual.
*/
export const compileRenderedSkill = async (source: string): Promise<RenderedSkillCompilation> => {
export const compileRenderedSkill = async (
source: string,
options: RenderedSkillLoaderOptions = {},
): Promise<RenderedSkillCompilation> => {
let moduleExports: Record<string, unknown>;
const loaderDirectory = await mkdtemp(join(tmpdir(), 'agent-bundle-rendered-skill-'));
try {
const alias = await writeLoaderModules(loaderDirectory, options);
const jiti = createJiti(source, {
alias,
interopDefault: true,
jsx: { runtime: 'automatic' },
moduleCache: false,
Expand All @@ -70,6 +147,8 @@ export const compileRenderedSkill = async (source: string): Promise<RenderedSkil
moduleExports = await jiti.import<Record<string, unknown>>(source);
} catch (error) {
return failure('AB3003', `Rendered Skill module failed to load: ${errorMessage(error)}`, source);
} finally {
await rm(loaderDirectory, { force: true, recursive: true });
}

const component = moduleExports.default;
Expand Down
10 changes: 8 additions & 2 deletions packages/agent-bundle/src/config/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ import {
import {
compileRenderedSkill,
isRenderedSkillSourceName,
type RenderedSkillLoaderOptions,
renderedSkillSourceAt,
} from './rendered-skill.ts';
import { parseSkillMarkdown } from './skill-references.ts';

/** What a rendered skill module observes while it evaluates; see {@link RenderedSkillLoaderOptions}. */
export type ParseSkillOptions = RenderedSkillLoaderOptions;

export interface SkillResource {
bytes: number;
relativePath: string;
Expand Down Expand Up @@ -124,8 +128,9 @@ const parseRenderedSkill = async (
dir: string,
renderedSource: string,
resources: SkillResource[],
options: ParseSkillOptions,
): Promise<SkillDocument> => {
const compiled = await compileRenderedSkill(renderedSource);
const compiled = await compileRenderedSkill(renderedSource, options);
if (compiled.status === 'failed') {
return {
body: '',
Expand Down Expand Up @@ -158,6 +163,7 @@ export const parseSkill = async (
projectRoot?: string,
/** Reuses the caller's compiled ignore rules; discovery parses many skills under one root. */
projectIgnoreRules?: Ignore,
options: ParseSkillOptions = {},
): Promise<SkillDocument> => {
const dir = resolve(skillDir);
const source = join(dir, 'SKILL.md');
Expand All @@ -171,7 +177,7 @@ export const parseSkill = async (
markdown = await readFile(source, 'utf8');
} catch (error: unknown) {
if (renderedSource !== undefined && isErrno(error, 'ENOENT')) {
return parseRenderedSkill(dir, renderedSource, resources);
return parseRenderedSkill(dir, renderedSource, resources, options);
}
return {
body: '',
Expand Down
Loading
Loading