Skip to content

Commit 6ee4380

Browse files
committed
feat(sdk,ai-chat): skills runtime subpath + ai-chat browser test bridge
Two coupled changes that together unblock end-to-end browser smoke testing of TriggerChatTransport in the ai-chat reference and, more broadly, let Next.js + Webpack client bundles pull types from @trigger.dev/sdk/ai without hitting node: imports. @trigger.dev/sdk - New subpath @trigger.dev/sdk/ai/skills-runtime (src/v3/agentSkillsRuntime.ts) owns the node-only skill tool impls: runBashInSkill (node:child_process) + readFileInSkill (node:fs/promises, node:path, with path-traversal guard). - ai.ts drops the top-level node:child_process / node:fs/promises / node:path imports. The auto-injected loadSkill / readFile / bash tools in createAgentSkillTools() load the runtime via a computed-string dynamic import (let path = "./agentSkillsRuntime.js"; await import(path)) — webpack can't statically trace the expression so it drops the dependency from the client graph. Worker runtimes resolve the relative import normally, so bash + readFile keep working end-to-end on the server. - Why it matters: even type-only imports from @trigger.dev/sdk/ai (for example CompactionChunkData or the full tool-set type chain that derives ChatUiMessage via InferUITools) trigger webpack to trace ai.js. Pre-split, that trace hit node:child_process and failed the client build with UnhandledSchemeError. With the split, ai.ts's top-level graph is pure — no node: at the top — so type consumers compile cleanly. references/ai-chat - components/chat.tsx extends the window.__chat test bridge with session-era state (session / sessionId / lastEventId) and generic waiters (waitForStatus + waitForMessage + waitForFirstAssistantText) with configurable timeouts and clean rejection. A driver (Chrome DevTools MCP, Playwright, etc.) can now exercise the chat end-to-end through eval'd JS: await window.__chat.send("hi"); const t = await window.__chat.waitForFirstAssistantText(); No more click-driven smokes. Existing steerOnToolCall + steerAfterDelay / queueAfterDelay / promote helpers stay. - Inlines a local structural CompactionChunkData type so chat.tsx doesn't pull from @trigger.dev/sdk/ai for a single type assertion. Defensive — the subpath split fixes the underlying build issue, this just keeps the chat.tsx module graph minimal. - Fixes a stale ChatSessionState shape in the DebugPanel session prop type (sessionId is now optional, runId optional). Known limitation (not in this commit) Running the live UI smoke still requires a working chat-agent backend for ai-chat, which depends on isolated-vm having a prebuilt darwin-arm64 binary for node 20.20.0. On this machine `pnpm rebuild isolated-vm` fails (node-gyp toolchain issue), which is orthogonal to the session migration. Bridge infrastructure is validated (all keys mount on window.__chat; SDK tests 86/86 pass); exercising the send->stream->stop flow end-to-end against the ai-chat agent is blocked on the native build.
1 parent 6c58e37 commit 6ee4380

5 files changed

Lines changed: 310 additions & 103 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Split the skill-runtime primitives (`bash` + `readFile` tool implementations, backed by `node:child_process` + `node:fs/promises`) out of `@trigger.dev/sdk/ai` into a new `@trigger.dev/sdk/ai/skills-runtime` subpath. Fixes client-bundle build errors (`UnhandledSchemeError: Reading from "node:child_process"…`) that hit Next.js + Webpack when a browser page imports types from `@trigger.dev/sdk/ai` (for example `ChatUiMessage` via a shared tools file). The chat-agent factory now loads the runtime lazily via a computed-string dynamic import, so server workers still get full skill support without any caller changes.

packages/trigger-sdk/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
".": "./src/v3/index.ts",
2626
"./v3": "./src/v3/index.ts",
2727
"./ai": "./src/v3/ai.ts",
28+
"./ai/skills-runtime": "./src/v3/agentSkillsRuntime.ts",
2829
"./ai/test": "./src/v3/test/index.ts",
2930
"./chat": "./src/v3/chat.ts",
3031
"./chat/react": "./src/v3/chat-react.ts"
@@ -142,6 +143,17 @@
142143
"default": "./dist/commonjs/v3/ai.js"
143144
}
144145
},
146+
"./ai/skills-runtime": {
147+
"import": {
148+
"@triggerdotdev/source": "./src/v3/agentSkillsRuntime.ts",
149+
"types": "./dist/esm/v3/agentSkillsRuntime.d.ts",
150+
"default": "./dist/esm/v3/agentSkillsRuntime.js"
151+
},
152+
"require": {
153+
"types": "./dist/commonjs/v3/agentSkillsRuntime.d.ts",
154+
"default": "./dist/commonjs/v3/agentSkillsRuntime.js"
155+
}
156+
},
145157
"./ai/test": {
146158
"import": {
147159
"@triggerdotdev/source": "./src/v3/test/index.ts",
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { spawn } from "node:child_process";
2+
import * as fs from "node:fs/promises";
3+
import * as nodePath from "node:path";
4+
5+
/**
6+
* Server-only runtime for the auto-injected skill tools
7+
* (`loadSkill` / `readFile` / `bash`) that `chat.agent({ skills })`
8+
* wires up. Split off from `./ai.ts` so the chat-agent surface in
9+
* `@trigger.dev/sdk/ai` stays importable from client bundles —
10+
* Next.js + Webpack reject top-level `node:*` imports anywhere in a
11+
* client graph, even when a consumer only pulls in types.
12+
*
13+
* The SDK's `ai.ts` loads this module via a computed-string dynamic
14+
* import inside each tool's `execute` — webpack treats the
15+
* expression as an unknown dependency and skips static tracing, so
16+
* the node-only symbols here never surface in a client build. The
17+
* module resolves fine at runtime on a server worker because the
18+
* relative path (`./agentSkillsRuntime.js`) lands next to `ai.js` in
19+
* the emitted dist.
20+
*
21+
* Public subpath: `@trigger.dev/sdk/ai/skills-runtime`. Customers
22+
* who want to eagerly bundle the runtime server-side (e.g. warming
23+
* it on worker bootstrap) can import from there.
24+
*/
25+
26+
const DEFAULT_BASH_OUTPUT_BYTES = 64 * 1024;
27+
const DEFAULT_READ_FILE_BYTES = 1024 * 1024;
28+
29+
export type BashSkillInput = {
30+
/** Absolute path to the skill's root (used as `cwd`). */
31+
skillPath: string;
32+
/** The bash command to run. */
33+
command: string;
34+
/** Optional abort signal forwarded to `spawn()`. */
35+
abortSignal?: AbortSignal;
36+
};
37+
38+
export type BashSkillResult =
39+
| { exitCode: number | null; stdout: string; stderr: string }
40+
| { error: string };
41+
42+
export type ReadFileInSkillInput = {
43+
/** Absolute path to the skill's root — the relative path must resolve inside it. */
44+
skillPath: string;
45+
/** Relative path the tool caller supplied. */
46+
relativePath: string;
47+
};
48+
49+
export type ReadFileInSkillResult = { content: string } | { error: string };
50+
51+
function truncate(s: string, limit: number): string {
52+
if (s.length <= limit) return s;
53+
return s.slice(0, limit) + `\n…[truncated ${s.length - limit} bytes]`;
54+
}
55+
56+
/**
57+
* Path-traversal guard: confirm `relative` resolves inside `root`.
58+
* Throws if it escapes via `..` or an absolute prefix. Returns the
59+
* absolute resolved path.
60+
*/
61+
function safeJoinInside(root: string, relative: string): string {
62+
if (nodePath.isAbsolute(relative)) {
63+
throw new Error(`Path must be relative to the skill directory: ${relative}`);
64+
}
65+
const resolved = nodePath.resolve(root, relative);
66+
const normalized = nodePath.resolve(root) + nodePath.sep;
67+
if (resolved !== nodePath.resolve(root) && !resolved.startsWith(normalized)) {
68+
throw new Error(`Path escapes the skill directory: ${relative}`);
69+
}
70+
return resolved;
71+
}
72+
73+
export async function readFileInSkill({
74+
skillPath,
75+
relativePath,
76+
}: ReadFileInSkillInput): Promise<ReadFileInSkillResult> {
77+
let absolute: string;
78+
try {
79+
absolute = safeJoinInside(skillPath, relativePath);
80+
} catch (err) {
81+
return { error: (err as Error).message };
82+
}
83+
try {
84+
const content = await fs.readFile(absolute, "utf8");
85+
return { content: truncate(content, DEFAULT_READ_FILE_BYTES) };
86+
} catch (err) {
87+
return { error: (err as Error).message };
88+
}
89+
}
90+
91+
export async function runBashInSkill({
92+
skillPath,
93+
command,
94+
abortSignal,
95+
}: BashSkillInput): Promise<BashSkillResult> {
96+
return new Promise<BashSkillResult>((resolvePromise) => {
97+
let child;
98+
try {
99+
child = spawn("bash", ["-c", command], {
100+
cwd: skillPath,
101+
signal: abortSignal,
102+
});
103+
} catch (err) {
104+
resolvePromise({ error: (err as Error).message });
105+
return;
106+
}
107+
108+
let stdout = "";
109+
let stderr = "";
110+
child.stdout?.on("data", (chunk: Buffer | string) => {
111+
stdout += chunk.toString();
112+
});
113+
child.stderr?.on("data", (chunk: Buffer | string) => {
114+
stderr += chunk.toString();
115+
});
116+
child.once("close", (code: number | null) => {
117+
resolvePromise({
118+
exitCode: code,
119+
stdout: truncate(stdout, DEFAULT_BASH_OUTPUT_BYTES),
120+
stderr: truncate(stderr, DEFAULT_BASH_OUTPUT_BYTES),
121+
});
122+
});
123+
child.once("error", (err: Error) => {
124+
resolvePromise({ error: err.message });
125+
});
126+
});
127+
}

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 37 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,29 @@ import { locals } from "./locals.js";
5757
import { metadata } from "./metadata.js";
5858
import type { ResolvedPrompt } from "./prompt.js";
5959
import type { ResolvedSkill } from "./skill.js";
60-
import { spawn } from "node:child_process";
61-
import * as fs from "node:fs/promises";
62-
import * as nodePath from "node:path";
60+
// Bash-skill runtime lives in `./agentSkillsRuntime.ts` (exposed as
61+
// the `@trigger.dev/sdk/ai/skills-runtime` subpath) so `ai.ts`'s
62+
// top-level graph stays free of `node:*` imports. The chat-agent
63+
// surface in `@trigger.dev/sdk/ai` exports types like
64+
// `ChatUiMessage` / `CompactionChunkData` that frontend code
65+
// sometimes imports; Next.js + Webpack reject top-level node
66+
// builtins in the client graph even when only the type is used.
67+
//
68+
// The load path uses a computed-string variable so bundlers skip
69+
// static tracing — webpack emits a "Critical dependency" info-level
70+
// warning and defers resolution to runtime. On a server worker the
71+
// relative path lands next to `ai.js` in the emitted dist; on a
72+
// client bundle the bash tool is never invoked so the dynamic
73+
// import never fires.
74+
type BashRuntimeModule = typeof import("./agentSkillsRuntime.js");
75+
76+
let cachedBashRuntime: BashRuntimeModule | undefined;
77+
async function loadAgentSkillsRuntime(): Promise<BashRuntimeModule> {
78+
if (cachedBashRuntime) return cachedBashRuntime;
79+
const modulePath: string = "./agentSkillsRuntime.js";
80+
cachedBashRuntime = (await import(modulePath)) as BashRuntimeModule;
81+
return cachedBashRuntime;
82+
}
6383
import { streams } from "./streams.js";
6484
import {
6585
sessions,
@@ -2231,9 +2251,6 @@ function getChatPrompt(): ChatPromptValue {
22312251
/** @internal */
22322252
const chatSkillsKey = locals.create<ResolvedSkill[]>("chat.skills");
22332253

2234-
/** Limits applied by the auto-injected `loadSkill` / `readFile` / `bash` tools. */
2235-
const DEFAULT_READ_FILE_BYTES = 1024 * 1024; // 1 MB
2236-
const DEFAULT_BASH_OUTPUT_BYTES = 64 * 1024; // 64 KB
22372254

22382255
/**
22392256
* Store resolved skills for the current run. Call from any hook
@@ -2264,33 +2281,11 @@ function buildSkillsSystemPrompt(skills: ResolvedSkill[]): string {
22642281
].join("\n");
22652282
}
22662283

2267-
function truncate(s: string, limit: number): string {
2268-
if (s.length <= limit) return s;
2269-
return s.slice(0, limit) + `\n…[truncated ${s.length - limit} bytes]`;
2270-
}
2271-
22722284
/** Resolve a skill by its frontmatter `name`. */
22732285
function findSkillByName(skills: ResolvedSkill[], name: string): ResolvedSkill | undefined {
22742286
return skills.find((s) => s.frontmatter.name === name);
22752287
}
22762288

2277-
/**
2278-
* Check that `candidate` resolves inside `root` — guards against path
2279-
* traversal via `..` or absolute paths. Returns the resolved path or
2280-
* throws.
2281-
*/
2282-
function safeJoinInside(root: string, relative: string): string {
2283-
if (nodePath.isAbsolute(relative)) {
2284-
throw new Error(`Path must be relative to the skill directory: ${relative}`);
2285-
}
2286-
const resolved = nodePath.resolve(root, relative);
2287-
const normalized = nodePath.resolve(root) + nodePath.sep;
2288-
if (resolved !== nodePath.resolve(root) && !resolved.startsWith(normalized)) {
2289-
throw new Error(`Path escapes the skill directory: ${relative}`);
2290-
}
2291-
return resolved;
2292-
}
2293-
22942289
/**
22952290
* Build the three tools we auto-inject into `streamText` when skills are
22962291
* set: `loadSkill`, `readFile`, `bash`. Scoped per-skill by name.
@@ -2351,15 +2346,12 @@ export function buildSkillTools(skills: ResolvedSkill[]): Record<string, Tool> {
23512346
if (!skill) {
23522347
return { error: `Skill "${skillName}" not found.` };
23532348
}
2354-
let absolute: string;
2355-
try {
2356-
absolute = safeJoinInside(skill.path, relPath);
2357-
} catch (err) {
2358-
return { error: (err as Error).message };
2359-
}
23602349
try {
2361-
const content = await fs.readFile(absolute, "utf8");
2362-
return { content: truncate(content, DEFAULT_READ_FILE_BYTES) };
2350+
const { readFileInSkill } = await loadAgentSkillsRuntime();
2351+
return await readFileInSkill({
2352+
skillPath: skill.path,
2353+
relativePath: relPath,
2354+
});
23632355
} catch (err) {
23642356
return { error: (err as Error).message };
23652357
}
@@ -2389,41 +2381,16 @@ export function buildSkillTools(skills: ResolvedSkill[]): Record<string, Tool> {
23892381
if (!skill) {
23902382
return { error: `Skill "${skillName}" not found.` };
23912383
}
2392-
return await new Promise<{
2393-
exitCode: number | null;
2394-
stdout: string;
2395-
stderr: string;
2396-
} | { error: string }>((resolvePromise) => {
2397-
let child;
2398-
try {
2399-
child = spawn("bash", ["-c", command], {
2400-
cwd: skill.path,
2401-
signal: abortSignal,
2402-
});
2403-
} catch (err) {
2404-
resolvePromise({ error: (err as Error).message });
2405-
return;
2406-
}
2407-
2408-
let stdout = "";
2409-
let stderr = "";
2410-
child.stdout?.on("data", (chunk: Buffer | string) => {
2411-
stdout += chunk.toString();
2412-
});
2413-
child.stderr?.on("data", (chunk: Buffer | string) => {
2414-
stderr += chunk.toString();
2415-
});
2416-
child.once("close", (code: number | null) => {
2417-
resolvePromise({
2418-
exitCode: code,
2419-
stdout: truncate(stdout, DEFAULT_BASH_OUTPUT_BYTES),
2420-
stderr: truncate(stderr, DEFAULT_BASH_OUTPUT_BYTES),
2421-
});
2422-
});
2423-
child.once("error", (err: Error) => {
2424-
resolvePromise({ error: err.message });
2384+
try {
2385+
const { runBashInSkill } = await loadAgentSkillsRuntime();
2386+
return await runBashInSkill({
2387+
skillPath: skill.path,
2388+
command,
2389+
abortSignal,
24252390
});
2426-
});
2391+
} catch (err) {
2392+
return { error: (err as Error).message };
2393+
}
24272394
},
24282395
});
24292396

0 commit comments

Comments
 (0)