Skip to content

Commit 050e8a2

Browse files
committed
fix: lazy-load PTC dependencies to fix CI failures
- Move typescript from devDependencies to dependencies (needed at runtime for PTC static analysis) - Lazy-load PTC modules in aiService.ts using dynamic import() - Only load code_execution, quickjsRuntime, and toolBridge when PTC experiments are enabled This fixes two CI failures: 1. Integration tests failing due to prettier's dynamic imports requiring --experimental-vm-modules 2. Smoke tests failing because typescript wasn't in production bundle The import chain (aiService → code_execution → staticAnalysis/typeGenerator → typescript/prettier) now only executes when PTC is actually used.
1 parent 0a4fb0e commit 050e8a2

File tree

7 files changed

+141
-62
lines changed

7 files changed

+141
-62
lines changed

bun.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"streamdown": "1.6.10",
7171
"trpc-cli": "^0.12.1",
7272
"turndown": "^7.2.2",
73+
"typescript": "^5.1.3",
7374
"undici": "^7.16.0",
7475
"write-file-atomic": "^6.0.0",
7576
"ws": "^8.18.3",
@@ -157,7 +158,6 @@
157158
"tailwindcss": "^4.1.15",
158159
"ts-jest": "^29.4.4",
159160
"tsc-alias": "^1.8.16",
160-
"typescript": "^5.1.3",
161161
"typescript-eslint": "^8.45.0",
162162
"vite": "^7.1.11",
163163
"vite-plugin-svgr": "^4.5.0",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
"parse-duration": "^2.1.4",
103103
"posthog-node": "^5.17.0",
104104
"quickjs-emscripten": "^0.31.0",
105+
"typescript": "^5.1.3",
105106
"quickjs-emscripten-core": "^0.31.0",
106107
"rehype-harden": "^1.1.5",
107108
"rehype-sanitize": "^6.0.0",
@@ -197,7 +198,6 @@
197198
"tailwindcss": "^4.1.15",
198199
"ts-jest": "^29.4.4",
199200
"tsc-alias": "^1.8.16",
200-
"typescript": "^5.1.3",
201201
"typescript-eslint": "^8.45.0",
202202
"vite": "^7.1.11",
203203
"vite-plugin-svgr": "^4.5.0",

src/node/services/aiService.ts

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,13 @@ import type {
5757
StreamStartEvent,
5858
} from "@/common/types/stream";
5959
import { applyToolPolicy, type ToolPolicy } from "@/common/utils/tools/toolPolicy";
60-
import {
61-
createCodeExecutionTool,
62-
type PTCEventWithParent,
60+
// PTC types only - modules lazy-loaded to avoid loading typescript/prettier at startup
61+
import type {
62+
PTCEventWithParent,
63+
createCodeExecutionTool as CreateCodeExecutionToolFn,
6364
} from "@/node/services/tools/code_execution";
64-
import { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime";
65+
import type { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime";
66+
import type { ToolBridge } from "@/node/services/ptc/toolBridge";
6567
import { MockScenarioPlayer } from "./mock/mockScenarioPlayer";
6668
import { EnvHttpProxyAgent, type Dispatcher } from "undici";
6769
import { getPlanFilePath } from "@/common/utils/planStorage";
@@ -115,11 +117,38 @@ type FetchWithBunExtensions = typeof fetch & {
115117
const globalFetchWithExtras = fetch as FetchWithBunExtensions;
116118
const defaultFetchWithExtras = defaultFetchWithUnlimitedTimeout as FetchWithBunExtensions;
117119

118-
// Singleton QuickJS runtime factory for PTC (WASM module is expensive to load)
119-
let quickjsRuntimeFactory: QuickJSRuntimeFactory | null = null;
120-
function getQuickJSRuntimeFactory(): QuickJSRuntimeFactory {
121-
quickjsRuntimeFactory ??= new QuickJSRuntimeFactory();
122-
return quickjsRuntimeFactory;
120+
// Lazy-loaded PTC modules (only loaded when experiment is enabled)
121+
// This avoids loading typescript/prettier at startup which causes issues:
122+
// - Integration tests fail without --experimental-vm-modules (prettier uses dynamic imports)
123+
// - Smoke tests fail if typescript isn't in production bundle
124+
// Dynamic imports are justified: PTC pulls in ~10MB of dependencies that would slow startup.
125+
interface PTCModules {
126+
createCodeExecutionTool: typeof CreateCodeExecutionToolFn;
127+
QuickJSRuntimeFactory: typeof QuickJSRuntimeFactory;
128+
ToolBridge: typeof ToolBridge;
129+
runtimeFactory: QuickJSRuntimeFactory | null;
130+
}
131+
let ptcModules: PTCModules | null = null;
132+
133+
async function getPTCModules(): Promise<PTCModules> {
134+
if (ptcModules) return ptcModules;
135+
136+
/* eslint-disable no-restricted-syntax -- Dynamic imports required here to avoid loading
137+
~10MB of typescript/prettier/quickjs at startup (causes CI failures) */
138+
const [codeExecution, quickjs, toolBridge] = await Promise.all([
139+
import("@/node/services/tools/code_execution"),
140+
import("@/node/services/ptc/quickjsRuntime"),
141+
import("@/node/services/ptc/toolBridge"),
142+
]);
143+
/* eslint-enable no-restricted-syntax */
144+
145+
ptcModules = {
146+
createCodeExecutionTool: codeExecution.createCodeExecutionTool,
147+
QuickJSRuntimeFactory: quickjs.QuickJSRuntimeFactory,
148+
ToolBridge: toolBridge.ToolBridge,
149+
runtimeFactory: null,
150+
};
151+
return ptcModules;
123152
}
124153

125154
if (typeof globalFetchWithExtras.preconnect === "function") {
@@ -1254,6 +1283,9 @@ export class AIService extends EventEmitter {
12541283
let toolsWithPTC = allTools;
12551284
if (experiments?.programmaticToolCalling || experiments?.programmaticToolCallingExclusive) {
12561285
try {
1286+
// Lazy-load PTC modules only when experiments are enabled
1287+
const ptc = await getPTCModules();
1288+
12571289
// Create emit callback that forwards nested events to stream
12581290
// Only forward tool-call-start/end events, not console events
12591291
const emitNestedEvent = (event: PTCEventWithParent): void => {
@@ -1263,15 +1295,24 @@ export class AIService extends EventEmitter {
12631295
// Console events are not streamed (appear in final result only)
12641296
};
12651297

1266-
const codeExecutionTool = await createCodeExecutionTool(
1267-
getQuickJSRuntimeFactory(),
1268-
allTools, // All tools available inside sandbox
1298+
// ToolBridge determines which tools can be bridged into the sandbox
1299+
const toolBridge = new ptc.ToolBridge(allTools);
1300+
1301+
// Singleton runtime factory (WASM module is expensive to load)
1302+
ptc.runtimeFactory ??= new ptc.QuickJSRuntimeFactory();
1303+
1304+
const codeExecutionTool = await ptc.createCodeExecutionTool(
1305+
ptc.runtimeFactory,
1306+
toolBridge,
12691307
emitNestedEvent
12701308
);
12711309

12721310
if (experiments?.programmaticToolCallingExclusive) {
1273-
// Exclusive mode: only code_execution available
1274-
toolsWithPTC = { code_execution: codeExecutionTool };
1311+
// Exclusive mode: code_execution + non-bridgeable tools (web_search, propose_plan, etc.)
1312+
// Non-bridgeable tools can't be used from within code_execution, so they're still
1313+
// available directly to the model
1314+
const nonBridgeable = toolBridge.getNonBridgeableTools();
1315+
toolsWithPTC = { ...nonBridgeable, code_execution: codeExecutionTool };
12751316
} else {
12761317
// Supplement mode: add code_execution alongside other tools
12771318
toolsWithPTC = { ...allTools, code_execution: codeExecutionTool };

src/node/services/ptc/toolBridge.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,22 @@ const EXCLUDED_TOOLS = new Set([
2424
*/
2525
export class ToolBridge {
2626
private readonly bridgeableTools: Map<string, Tool>;
27+
private readonly nonBridgeableTools: Map<string, Tool>;
2728

2829
constructor(tools: Record<string, Tool>) {
2930
this.bridgeableTools = new Map();
31+
this.nonBridgeableTools = new Map();
3032

3133
for (const [name, tool] of Object.entries(tools)) {
32-
// Skip excluded tools
33-
if (EXCLUDED_TOOLS.has(name)) continue;
34-
35-
// Skip tools without execute function (provider-native like web_search)
36-
if (!this.hasExecute(tool)) continue;
37-
38-
this.bridgeableTools.set(name, tool);
34+
// code_execution is the tool that uses the bridge, not a candidate for bridging
35+
if (name === "code_execution") continue;
36+
37+
const isBridgeable = !EXCLUDED_TOOLS.has(name) && this.hasExecute(tool);
38+
if (isBridgeable) {
39+
this.bridgeableTools.set(name, tool);
40+
} else {
41+
this.nonBridgeableTools.set(name, tool);
42+
}
3943
}
4044
}
4145

@@ -49,6 +53,18 @@ export class ToolBridge {
4953
return Object.fromEntries(this.bridgeableTools.entries());
5054
}
5155

56+
/**
57+
* Get tools that cannot be bridged into the sandbox.
58+
* These are tools that either:
59+
* - Are explicitly excluded (UI-specific, mode-specific)
60+
* - Don't have an execute function (provider-native like web_search)
61+
*
62+
* In exclusive PTC mode, these should still be available to the model directly.
63+
*/
64+
getNonBridgeableTools(): Record<string, Tool> {
65+
return Object.fromEntries(this.nonBridgeableTools.entries());
66+
}
67+
5268
/** Register all bridgeable tools on the runtime under `mux` namespace */
5369
register(runtime: IJSRuntime, abortSignal?: AbortSignal): void {
5470
const muxObj: Record<string, (...args: unknown[]) => Promise<unknown>> = {};

src/node/services/tools/code_execution.integration.test.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createCodeExecutionTool } from "./code_execution";
1515
import { createFileReadTool } from "./file_read";
1616
import { createBashTool } from "./bash";
1717
import { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime";
18+
import { ToolBridge } from "@/node/services/ptc/toolBridge";
1819
import type { Tool, ToolCallOptions } from "ai";
1920
import type { PTCEvent, PTCExecutionResult, PTCToolCallEndEvent } from "@/node/services/ptc/types";
2021
import { createTestToolConfig, TestTempDir, getTestDeps } from "./testHelpers";
@@ -52,8 +53,10 @@ describe("code_execution integration tests", () => {
5253

5354
// Track events
5455
const events: PTCEvent[] = [];
55-
const codeExecutionTool = await createCodeExecutionTool(runtimeFactory, tools, (e) =>
56-
events.push(e)
56+
const codeExecutionTool = await createCodeExecutionTool(
57+
runtimeFactory,
58+
new ToolBridge(tools),
59+
(e) => events.push(e)
5760
);
5861

5962
// Execute code that reads the file
@@ -94,7 +97,10 @@ describe("code_execution integration tests", () => {
9497
const fileReadTool = createFileReadTool(toolConfig);
9598
const tools: Record<string, Tool> = { file_read: fileReadTool };
9699

97-
const codeExecutionTool = await createCodeExecutionTool(runtimeFactory, tools);
100+
const codeExecutionTool = await createCodeExecutionTool(
101+
runtimeFactory,
102+
new ToolBridge(tools)
103+
);
98104

99105
const code = `
100106
const result = mux.file_read({ filePath: "nonexistent.txt" });
@@ -129,8 +135,10 @@ describe("code_execution integration tests", () => {
129135
const tools: Record<string, Tool> = { bash: bashTool };
130136

131137
const events: PTCEvent[] = [];
132-
const codeExecutionTool = await createCodeExecutionTool(runtimeFactory, tools, (e) =>
133-
events.push(e)
138+
const codeExecutionTool = await createCodeExecutionTool(
139+
runtimeFactory,
140+
new ToolBridge(tools),
141+
(e) => events.push(e)
134142
);
135143

136144
// Execute a simple echo command
@@ -182,8 +190,10 @@ describe("code_execution integration tests", () => {
182190
};
183191

184192
const events: PTCEvent[] = [];
185-
const codeExecutionTool = await createCodeExecutionTool(runtimeFactory, tools, (e) =>
186-
events.push(e)
193+
const codeExecutionTool = await createCodeExecutionTool(
194+
runtimeFactory,
195+
new ToolBridge(tools),
196+
(e) => events.push(e)
187197
);
188198

189199
// Code that creates a file with bash, then reads it with file_read
@@ -252,7 +262,10 @@ describe("code_execution integration tests", () => {
252262
const fileReadTool = createFileReadTool(toolConfig);
253263
const tools: Record<string, Tool> = { file_read: fileReadTool };
254264

255-
const codeExecutionTool = await createCodeExecutionTool(runtimeFactory, tools);
265+
const codeExecutionTool = await createCodeExecutionTool(
266+
runtimeFactory,
267+
new ToolBridge(tools)
268+
);
256269

257270
// Call file_read without required filePath argument
258271
const code = `
@@ -284,8 +297,10 @@ describe("code_execution integration tests", () => {
284297
const tools: Record<string, Tool> = { throwing_tool: throwingTool };
285298

286299
const events: PTCEvent[] = [];
287-
const codeExecutionTool = await createCodeExecutionTool(runtimeFactory, tools, (e) =>
288-
events.push(e)
300+
const codeExecutionTool = await createCodeExecutionTool(
301+
runtimeFactory,
302+
new ToolBridge(tools),
303+
(e) => events.push(e)
289304
);
290305

291306
const code = `
@@ -318,8 +333,10 @@ describe("code_execution integration tests", () => {
318333
const tools: Record<string, Tool> = { file_read: fileReadTool };
319334

320335
const events: PTCEvent[] = [];
321-
const codeExecutionTool = await createCodeExecutionTool(runtimeFactory, tools, (e) =>
322-
events.push(e)
336+
const codeExecutionTool = await createCodeExecutionTool(
337+
runtimeFactory,
338+
new ToolBridge(tools),
339+
(e) => events.push(e)
323340
);
324341

325342
const code = `

0 commit comments

Comments
 (0)