Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
30befb4
feat(runtime): author rich Markdown blocks as JSX via rsc-markdown-st…
ScriptedAlchemy Sep 3, 2026
edb61de
Merge remote-tracking branch 'origin/main' into feat/rsc-markdown-stream
ScriptedAlchemy Sep 3, 2026
2649475
Merge remote-tracking branch 'origin/main' into feat/rsc-markdown-stream
ScriptedAlchemy Sep 3, 2026
85c573b
Pin rsc-markdown-stream fidelity fixes and prove Markdown output end …
ScriptedAlchemy Sep 3, 2026
b1898f3
Merge remote-tracking branch 'origin/main' into feat/rsc-markdown-stream
ScriptedAlchemy Sep 4, 2026
281e795
Bundle rsc-markdown-stream instead of installing it from git
ScriptedAlchemy Sep 4, 2026
d6dd545
Document MarkdownContent and the Markdown renderer exports
ScriptedAlchemy Sep 4, 2026
2d02ee1
Merge remote-tracking branch 'origin/main' into feat/rsc-markdown-stream
ScriptedAlchemy Sep 4, 2026
1308935
Vendor rsc-markdown-stream as a workspace package
ScriptedAlchemy Sep 4, 2026
13fb8e7
Follow linked dependencies transitively in bundle provenance
ScriptedAlchemy Sep 4, 2026
c7cbe7b
Pack rsc-markdown-stream for the tarball consumer proofs
ScriptedAlchemy Sep 4, 2026
f0fd07f
Merge remote-tracking branch 'origin/main' into feat/rsc-markdown-stream
ScriptedAlchemy Sep 4, 2026
fd52901
Resolve rsc-markdown-stream to its source declarations for TypeDoc
ScriptedAlchemy Sep 4, 2026
9507db0
Merge remote-tracking branch 'origin/main' into feat/rsc-markdown-stream
ScriptedAlchemy Sep 4, 2026
c599f78
fix(build): resolve linked dependency roots like Node and never ignor…
ScriptedAlchemy Sep 4, 2026
3ed48c6
fix(build): exempt only the project root from ignored dependency roots
ScriptedAlchemy Sep 4, 2026
3e4b52d
Merge branch 'main' into feat/rsc-markdown-stream
ScriptedAlchemy Sep 4, 2026
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
1 change: 1 addition & 0 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ versions; `changeset publish` (when enabled) publishes the result.
| ----------------------- | ------------------------------ | ---------------------------------------- |
| `agent-bundle` | `packages/agent-bundle` | publishable |
| `@agent-bundle/runtime` | `packages/rsc-runtime` | publishable |
| `rsc-markdown-stream` | `packages/rsc-markdown-stream` | publishable |
| `create-agent-bundle` | `packages/create-agent-bundle` | publishable |
| `agent-bundle-workbench`| `packages/workbench` | private, ignored |
| `@agent-bundle-example/*`, `@agent-bundle/rsc-agent-runtime-demo` | `examples/*` | private, ignored |
Expand Down
19 changes: 19 additions & 0 deletions .changeset/jsx-markdown-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@agent-bundle/runtime": patch
"rsc-markdown-stream": patch
"agent-bundle": patch
---

Add the async `MarkdownContent` component and the `renderToMarkdown` /
`renderToMarkdownStream` exports to `@agent-bundle/runtime`, so routes author
rich Markdown blocks — headings, lists, GFM tables, task lists, nested async
components, escaped text — as JSX lowered into `Agent.Markdown` instead of
hand-concatenated strings. The renderer behind them, `rsc-markdown-stream`, is
now a package of this repository and is published from it (it was previously
only installable from its git URL), so `@agent-bundle/runtime` depends on it
by version. `agent-bundle build` now follows symlinked (workspace) dependencies
transitively when attributing bundle provenance, resolving each one the way
Node does, so a project whose linked dependency links another package —
including one hoisted to an ancestor `node_modules` — no longer fails with
`AB5000`, and a dependency that links back onto the project never hides the
project's own sources from provenance. (#344)
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@
## Changesets

- Every PR that changes a publishable package (`packages/agent-bundle`,
`packages/rsc-runtime`, `packages/create-agent-bundle` — anything except
`tests/**`) must include exactly one changeset: `pnpm changeset` or a
`packages/rsc-runtime`, `packages/rsc-markdown-stream`,
`packages/create-agent-bundle` — anything except `tests/**`) must include
exactly one changeset: `pnpm changeset` or a
hand-written `.changeset/<slug>.md`. Private packages (`packages/workbench`,
`examples/*`, `website`) are ignored and never named in a changeset.
- Pre-1.0 semver: `minor` = breaking, `patch` = everything else (features
Expand Down
6 changes: 6 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ license and notices, which are preserved unmodified alongside that material:
src/mcp/APP-RENDERER-LICENSE, which the agent-bundle package ships under
dist/workbench/.

- The rsc-markdown-stream package (packages/rsc-markdown-stream) was
imported from https://github.com/ScriptedAlchemy/rsc-markdown-stream at
commit eba2ea0b930493b80b9f4f9bb2c582041b0a3f47, where it was
distributed under the MIT License. That notice is preserved as
UPSTREAM-LICENSE in the package and its published tarball.

The repository's vendored reference checkouts under repos/ are read-only
reference material, retain their own upstream licenses, and are not part of
any published package.
23 changes: 19 additions & 4 deletions docs/preview-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ release owner must resolve the repository-wide `"access": "restricted"`
policy for `agent-bundle`, which does not currently override it with
`publishConfig.access`. Until then
pkg.pr.new is the release channel. Every CI package-preview run publishes real,
installable tarballs of all three publishable workspace packages to [pkg.pr.new](https://pkg.pr.new)
installable tarballs of all four publishable workspace packages (`agent-bundle`,
`@agent-bundle/runtime`, `rsc-markdown-stream`, `create-agent-bundle`) to
[pkg.pr.new](https://pkg.pr.new)
— a free continuous-release registry keyed by commit SHA and pull request.
These are the packages to install until a first npm release is cut.

Expand All @@ -24,6 +26,15 @@ npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@1
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@1
```

`@agent-bundle/runtime` depends on `rsc-markdown-stream`, the Markdown
renderer behind `MarkdownContent`; its preview tarball points that dependency
at the renderer's own preview of the same commit, so npm fetches it without a
separate install. Install the renderer directly only to use it on its own:

```sh
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/rsc-markdown-stream@1
```

The `create-agent-bundle` scaffolder is published to the same channel and is
meant to be run rather than installed:

Expand All @@ -46,6 +57,7 @@ work), which is the right form for lockfiles and reproducible setups:
```sh
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@5685521
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@5685521
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/rsc-markdown-stream@5685521
```

pnpm and yarn accept the same URLs (`pnpm add <url>`, `yarn add agent-bundle@<url>`).
Expand All @@ -55,8 +67,11 @@ Previews carry the version string `0.0.0-preview-<sha>`, and the publish
package to that exact preview version inside the preview tarballs. Today that
is the optional `@agent-bundle/runtime` peer declared by `agent-bundle`
(`@agent-bundle/runtime` itself no longer declares an `agent-bundle` peer;
its peers are `react`, `react-dom`, and `@rspack/core`). Installing both
packages from the same sha therefore works with stock npm — no
its peers are `react`, `react-dom`, and `@rspack/core`). A regular
`dependencies` entry that names a sibling workspace package is rewritten to
that sibling's same-sha tarball URL: `@agent-bundle/runtime`'s
`rsc-markdown-stream` dependency resolves to the renderer preview of the same
commit. Installing both packages from the same sha therefore works with stock npm — no
`--legacy-peer-deps` needed. Mixing two different shas fails with `ERESOLVE`
by design; use one sha (or one PR number) for both URLs. Previews published
before the peer rewrite landed (PR #46, fixing #45) still carry the original
Expand All @@ -83,7 +98,7 @@ installable artifacts.
`.github/workflows/package-preview.yml` runs
`pnpm preview:publish` (`pkg-pr-new publish --previewVersion --peerDeps
--no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime'
'./packages/create-agent-bundle'`)
'./packages/rsc-markdown-stream' './packages/create-agent-bundle'`)
after a full build, on every pull request and on every push to `main`. Runs for
`main` pushes use a per-commit concurrency group, so overlapping pushes
cannot cancel one another and every `main` commit has an installable
Expand Down
23 changes: 22 additions & 1 deletion examples/audiobook-curator/src/components/library-analysis.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { stat } from 'node:fs/promises';

import { Agent } from '@agent-bundle/runtime';
import { Agent, MarkdownContent } from '@agent-bundle/runtime';
import React from 'react';

import type { LibraryAuditReceipt } from '../library.ts';
Expand All @@ -21,6 +21,26 @@ interface MeasuredFile {
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : 'File metadata is unavailable.';

/** JSX-authored GFM table lowered to Markdown by the runtime's renderer. */
const MeasuredFilesTable = ({ measured }: { readonly measured: readonly MeasuredFile[] }) => (
<MarkdownContent>
<table>
<thead>
<tr><th>File</th><th>Bytes</th><th>Status</th></tr>
</thead>
<tbody>
{measured.map((file) => (
<tr key={file.path}>
<td>{file.path}</td>
<td>{file.bytes === undefined ? '' : String(file.bytes)}</td>
<td>{file.error ?? 'measured'}</td>
</tr>
))}
</tbody>
</table>
</MarkdownContent>
);

const measureFiles = async (
files: readonly string[],
signal: AbortSignal,
Expand Down Expand Up @@ -62,6 +82,7 @@ export const LibraryAnalysis = async ({ receipt, signal }: LibraryAnalysisProps)
{ label: 'Measured files', value: available.length },
{ label: 'Reclaimable bytes', value: reclaimableBytes },
]} />
<MeasuredFilesTable measured={measured} />
{unavailable.length > 0
? (
<>
Expand Down
22 changes: 17 additions & 5 deletions examples/audiobook-curator/src/components/primitives.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Agent } from '@agent-bundle/runtime';
import { Agent, MarkdownContent } from '@agent-bundle/runtime';
import React from 'react';

export interface Field {
Expand All @@ -10,18 +10,30 @@ export interface DataListProps {
readonly fields: readonly Field[];
}

const singleLine = (value: Field['value']): string => String(value).replaceAll(/\s*\n\s*/gu, ' ');

export const DataList = ({ fields }: DataListProps) => (
<Agent.Markdown>
{fields.map(({ label, value }) => `- **${label}:** ${String(value).replaceAll(/\s*\n\s*/gu, ' ')}`).join('\n')}
</Agent.Markdown>
<MarkdownContent>
<ul>
{fields.map(({ label, value }) => (
<li key={label}>
<strong>{label}:</strong> {singleLine(value)}
</li>
))}
</ul>
</MarkdownContent>
);

export interface FileListProps {
readonly files: readonly string[];
}

export const FileList = ({ files }: FileListProps) => (
<Agent.Markdown>{files.map((file) => `- ${file}`).join('\n')}</Agent.Markdown>
<MarkdownContent>
<ul>
{files.map((file) => <li key={file}>{file}</li>)}
</ul>
</MarkdownContent>
);

export interface CalloutProps {
Expand Down
4 changes: 4 additions & 0 deletions examples/audiobook-curator/tests/route-unit/streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ it('streams library analysis after the audit shell while preserving the canonica
&& documentText(document).includes('"kind":"progress"'))).toBe(true);
expectDocument(rendered)
.toContainMarkdown('**Reclaimable bytes:** 4')
// The JSX-authored measured-files table lowers to one GFM table block.
.toContainMarkdown('| File | Bytes | Status |')
.toContainMarkdown(`| ${join(library, 'Shared title.flac')} | 15 | measured |`)
.toContainMarkdown(`| ${join(library, 'Shared title.mp3')} | 4 | measured |`)
.toContainMarkdown(`- ${join(library, 'Shared title.flac')}\n- ${join(library, 'Shared title.mp3')}`)
.toContainContext('Duplicate candidate group')
.toHaveValue(rendered.result);
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"packageManager": "pnpm@11.23.0",
"scripts": {
"build": "pnpm --filter @agent-bundle/runtime build && pnpm --filter agent-bundle build && pnpm --filter create-agent-bundle build",
"build": "pnpm --filter rsc-markdown-stream build && pnpm --filter @agent-bundle/runtime build && pnpm --filter agent-bundle build && pnpm --filter create-agent-bundle build",
"test": "pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration",
"test:unit": "rstest --config rstest.unit.config.ts",
"test:route-unit": "rstest --config rstest.route-unit.config.ts",
Expand All @@ -21,7 +21,7 @@
"test:watch": "rstest --config rstest.config.ts --watch",
"lint": "rslint .",
"bench:hook-cold-start": "node scripts/measure-hook-cold-start.mjs",
"typecheck": "tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json",
"typecheck": "tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json && tsc --project packages/rsc-markdown-stream/tsconfig.json",
"check": "pnpm build && pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration:run && pnpm lint && pnpm typecheck",
"check:local-ci": "node scripts/local-ci.mjs",
"check:host-cli": "node scripts/host-cli-pins.mjs verify",
Expand All @@ -46,7 +46,7 @@
"changeset": "changeset",
"version-packages": "changeset version",
"release": "pnpm check:release && changeset publish",
"preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime' './packages/create-agent-bundle'",
"preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime' './packages/rsc-markdown-stream' './packages/create-agent-bundle'",
"pack:dry-run": "pnpm build && npm pack ./packages/agent-bundle --dry-run --json",
"lint:release": "attw --pack --profile esm-only packages/agent-bundle",
"check:release": "pnpm pack:dry-run && pnpm lint:release && pnpm test:packed:release",
Expand Down
120 changes: 99 additions & 21 deletions packages/agent-bundle/src/build/rslib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import { pluginReact } from '@rsbuild/plugin-react';
import { createRslib, mergeRslibConfig, rspack, type LibConfig, type Rspack } from '@rslib/core';
import { readFile, realpath } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { createRequire } from 'node:module';
import { dirname, join, resolve, sep } from 'node:path';

import { sha256Hex } from '../core/digest.ts';
import { isErrno } from '../core/errors.ts';
import { exists } from '../core/paths.ts';
import { isRecord } from '../core/strict-json.ts';
import type { AgentBundleToolsConfig } from '../core/types.ts';
import type { AgentBundleMeta } from '../meta.ts';
Expand Down Expand Up @@ -290,31 +292,107 @@ const assertNoResidualReservedImports = async (
}));
};

const declaredDependencyRoots = async (cwd: string): Promise<readonly string[]> => {
let bytes: string;
const projectDependencyFields = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const;
/** What a dependency's bundle can pull in: its devDependencies never ship. */
const runtimeDependencyFields = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const;

const declaredDependencyNames = (manifest: Record<string, unknown>, fields: readonly string[]): readonly string[] => {
const names = new Set<string>();
for (const field of fields) {
const dependencies = manifest[field];
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue;
for (const name of Object.keys(dependencies)) {
if (/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/iu.test(name)) names.add(name);
}
}
return [...names];
};

const readManifest = async (packageRoot: string): Promise<Record<string, unknown> | undefined> => {
try {
bytes = await readFile(resolve(cwd, 'package.json'), 'utf8');
return JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8')) as Record<string, unknown>;
} catch (error) {
if (isErrno(error, 'ENOENT')) return Object.freeze([]);
if (isErrno(error, 'ENOENT')) return undefined;
throw error;
}
const manifest = JSON.parse(bytes) as Record<string, unknown>;
const names = new Set<string>();
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
const dependencies = manifest[field];
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue;
for (const name of Object.keys(dependencies)) names.add(name);
};

const isBeneathNodeModules = (path: string): boolean => path.split(sep).includes('node_modules');

/**
* The manifest of dependency `name` as Node resolves it from `packageRoot`,
* which honours hoisting: npm, Yarn, and pnpm with a hoist pattern place a
* workspace dependency in an ancestor `node_modules`, where Rspack finds it
* too. A package whose `exports` map hides `package.json` makes that lookup
* throw, so the same ancestor walk is then performed by hand.
*/
const dependencyManifestPath = async (packageRoot: string, name: string): Promise<string | undefined> => {
try {
return createRequire(join(packageRoot, 'package.json')).resolve(`${name}/package.json`);
} catch (error) {
if (isErrno(error, 'MODULE_NOT_FOUND')) return undefined;
if (!isErrno(error, 'ERR_PACKAGE_PATH_NOT_EXPORTED')) throw error;
}
const roots = await Promise.all([...names].map(async (name) => {
if (!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/iu.test(name)) return undefined;
try {
return await realpath(resolve(cwd, 'node_modules', ...name.split('/')));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}));
return Object.freeze(roots.filter((root): root is string => root !== undefined));
let directory = packageRoot;
while (true) {
const candidate = join(directory, 'node_modules', ...name.split('/'), 'package.json');
if (await exists(candidate)) return candidate;
const parent = dirname(directory);
if (parent === directory) return undefined;
directory = parent;
}
};

/**
* The project root as Rspack records it, so a dependency link back onto the
* project compares equal. A directory that does not exist has no manifest and
* therefore no dependency roots; it is kept as given rather than failing here.
*/
const canonicalProjectRoot = async (cwd: string): Promise<string> => {
const root = resolve(cwd);
try {
return await realpath(root);
} catch (error) {
if (isErrno(error, 'ENOENT')) return root;
throw error;
}
};

/**
* Real roots of the project's declared dependencies, followed transitively
* through each linked dependency's own runtime dependencies. Provenance already
* discards modules beneath a `node_modules` directory, but pnpm links workspace
* packages by symlink and Rspack records their modules at real paths, which
* carry no such segment: `@agent-bundle/runtime` resolved to
* `packages/rsc-runtime` must be excluded by root, and so must the workspace
* packages *it* depends on (`rsc-markdown-stream`), which the project never
* declares itself. Registry packages resolve beneath `node_modules`, so their
* trees are never walked. The project itself is never a root: a dependency
* cycle back onto it (A → B → A) must not turn every authored module into an
* ignored one. Only the root itself is exempt — a dependency linked from
* inside the project (`<project>/packages/dep`, `file:./vendor/dep`) is still
* a dependency and is excluded like any other.
*/
const declaredDependencyRoots = async (cwd: string): Promise<readonly string[]> => {
const projectRoot = await canonicalProjectRoot(cwd);
const roots = new Set<string>();
const visited = new Set<string>();
const visit = async (packageRoot: string, fields: readonly string[]): Promise<void> => {
if (visited.has(packageRoot)) return;
visited.add(packageRoot);
const manifest = await readManifest(packageRoot);
if (manifest === undefined) return;
await Promise.all(declaredDependencyNames(manifest, fields).map(async (name) => {
const manifestPath = await dependencyManifestPath(packageRoot, name);
if (manifestPath === undefined) return;
const root = await realpath(dirname(manifestPath));
if (root === projectRoot) return;
roots.add(root);
if (!isBeneathNodeModules(root)) await visit(root, runtimeDependencyFields);
}));
};
await visit(projectRoot, projectDependencyFields);
return Object.freeze([...roots].sort((left, right) => left.localeCompare(right)));
};

interface InspectedBundlerConfig {
Expand Down
Loading
Loading