Repository navigation
feat(codemode): add opt-in scripted tool execution #399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
63eda6e
5967a9a
66cd767
9821749
716fc7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,186 @@ | ||||||||||||||||||||||||||||||||||||
| import { randomUUID } from "node:crypto"; | ||||||||||||||||||||||||||||||||||||
| import type { CodemodeResult } from "@earendil-works/pi-codemode"; | ||||||||||||||||||||||||||||||||||||
| import type { ProcessSessionManager } from "./process-sessions.js"; | ||||||||||||||||||||||||||||||||||||
| import type { CodeModeRegistry } from "./code-mode-registry.js"; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export const MAX_CODE_MODE_YIELD_MS = 12_000; | ||||||||||||||||||||||||||||||||||||
| const DEFAULT_YIELD_MS = 10_000; | ||||||||||||||||||||||||||||||||||||
| const DEFAULT_POLL_MS = 5_000; | ||||||||||||||||||||||||||||||||||||
| const MAX_SCRIPT_MS = 5 * 60_000; | ||||||||||||||||||||||||||||||||||||
| const MAX_CALLS = 32; | ||||||||||||||||||||||||||||||||||||
| const MAX_CONCURRENT_CALLS = 4; | ||||||||||||||||||||||||||||||||||||
| const COMPLETED_TTL_MS = 5 * 60_000; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| type Status = "running" | "completed" | "failed" | "cancelled"; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| interface Execution { | ||||||||||||||||||||||||||||||||||||
| id: string; | ||||||||||||||||||||||||||||||||||||
| workspaceId: string; | ||||||||||||||||||||||||||||||||||||
| status: Status; | ||||||||||||||||||||||||||||||||||||
| controller: AbortController; | ||||||||||||||||||||||||||||||||||||
| done: Promise<void>; | ||||||||||||||||||||||||||||||||||||
| result?: CodemodeResult; | ||||||||||||||||||||||||||||||||||||
| error?: string; | ||||||||||||||||||||||||||||||||||||
| startedAt: number; | ||||||||||||||||||||||||||||||||||||
| finishedAt?: number; | ||||||||||||||||||||||||||||||||||||
| processSessionIds: Set<number>; | ||||||||||||||||||||||||||||||||||||
| cleanupTimer?: NodeJS.Timeout; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export interface ExecutionSnapshot { | ||||||||||||||||||||||||||||||||||||
| execution_id: string; | ||||||||||||||||||||||||||||||||||||
| running: boolean; | ||||||||||||||||||||||||||||||||||||
| status: Status; | ||||||||||||||||||||||||||||||||||||
| result?: unknown; | ||||||||||||||||||||||||||||||||||||
| output?: string[]; | ||||||||||||||||||||||||||||||||||||
| error?: string; | ||||||||||||||||||||||||||||||||||||
| calls?: CodemodeResult["calls"]; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| function yieldWindow(ms: number | undefined, fallback: number): number { | ||||||||||||||||||||||||||||||||||||
| return ms === undefined ? fallback : Math.min(ms, MAX_CODE_MODE_YIELD_MS); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export class CodeModeExecutions { | ||||||||||||||||||||||||||||||||||||
| private readonly executions = new Map<string, Execution>(); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| constructor( | ||||||||||||||||||||||||||||||||||||
| private readonly registry: CodeModeRegistry, | ||||||||||||||||||||||||||||||||||||
| private readonly processes: ProcessSessionManager, | ||||||||||||||||||||||||||||||||||||
| ) {} | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| async start(workspaceId: string, code: string, yieldTimeMs?: number): Promise<ExecutionSnapshot> { | ||||||||||||||||||||||||||||||||||||
| const id = randomUUID(); | ||||||||||||||||||||||||||||||||||||
| const execution: Execution = { | ||||||||||||||||||||||||||||||||||||
| id, | ||||||||||||||||||||||||||||||||||||
| workspaceId, | ||||||||||||||||||||||||||||||||||||
| status: "running", | ||||||||||||||||||||||||||||||||||||
| controller: new AbortController(), | ||||||||||||||||||||||||||||||||||||
| done: Promise.resolve(), | ||||||||||||||||||||||||||||||||||||
| startedAt: performance.now(), | ||||||||||||||||||||||||||||||||||||
| processSessionIds: new Set(), | ||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||
| this.executions.set(id, execution); | ||||||||||||||||||||||||||||||||||||
| execution.done = this.run(execution, code); | ||||||||||||||||||||||||||||||||||||
| await this.wait(execution, yieldWindow(yieldTimeMs, DEFAULT_YIELD_MS)); | ||||||||||||||||||||||||||||||||||||
| return this.snapshot(execution); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| async poll(workspaceId: string, id: string, yieldTimeMs?: number): Promise<ExecutionSnapshot> { | ||||||||||||||||||||||||||||||||||||
| const execution = this.get(workspaceId, id); | ||||||||||||||||||||||||||||||||||||
| await this.wait(execution, yieldWindow(yieldTimeMs, DEFAULT_POLL_MS)); | ||||||||||||||||||||||||||||||||||||
| return this.snapshot(execution); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| cancel(workspaceId: string, id: string): ExecutionSnapshot { | ||||||||||||||||||||||||||||||||||||
| const execution = this.get(workspaceId, id); | ||||||||||||||||||||||||||||||||||||
| if (execution.status === "running") { | ||||||||||||||||||||||||||||||||||||
| execution.controller.abort(new Error("Execution cancelled by user.")); | ||||||||||||||||||||||||||||||||||||
| this.terminateProcesses(execution); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return this.snapshot(execution); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| async shutdown(): Promise<void> { | ||||||||||||||||||||||||||||||||||||
| for (const execution of this.executions.values()) { | ||||||||||||||||||||||||||||||||||||
| if (execution.cleanupTimer) clearTimeout(execution.cleanupTimer); | ||||||||||||||||||||||||||||||||||||
| if (execution.status === "running") { | ||||||||||||||||||||||||||||||||||||
| execution.controller.abort(new Error("DevSpace shutting down.")); | ||||||||||||||||||||||||||||||||||||
| this.terminateProcesses(execution); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| await Promise.allSettled([...this.executions.values()].map((execution) => execution.done)); | ||||||||||||||||||||||||||||||||||||
| this.executions.clear(); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private get(workspaceId: string, id: string): Execution { | ||||||||||||||||||||||||||||||||||||
| const execution = this.executions.get(id); | ||||||||||||||||||||||||||||||||||||
| if (!execution || execution.workspaceId !== workspaceId) { | ||||||||||||||||||||||||||||||||||||
| throw new Error("Code execution does not exist in this workspace."); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| return execution; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private snapshot(execution: Execution): ExecutionSnapshot { | ||||||||||||||||||||||||||||||||||||
| const result = execution.result; | ||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||
| execution_id: execution.id, | ||||||||||||||||||||||||||||||||||||
| running: execution.status === "running", | ||||||||||||||||||||||||||||||||||||
| status: execution.status, | ||||||||||||||||||||||||||||||||||||
| ...(result?.ok ? { result: result.value } : {}), | ||||||||||||||||||||||||||||||||||||
| ...(result ? { | ||||||||||||||||||||||||||||||||||||
| output: result.output.filter((item) => item.type === "text").map((item) => item.text), | ||||||||||||||||||||||||||||||||||||
| calls: result.calls, | ||||||||||||||||||||||||||||||||||||
| } : {}), | ||||||||||||||||||||||||||||||||||||
| ...(execution.error ? { error: execution.error } : {}), | ||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private async wait(execution: Execution, ms: number): Promise<void> { | ||||||||||||||||||||||||||||||||||||
| let timer: NodeJS.Timeout | undefined; | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| await Promise.race([ | ||||||||||||||||||||||||||||||||||||
| execution.done, | ||||||||||||||||||||||||||||||||||||
| new Promise<void>((resolve) => { timer = setTimeout(resolve, ms); }), | ||||||||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||
| if (timer) clearTimeout(timer); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private terminateProcesses(execution: Execution): void { | ||||||||||||||||||||||||||||||||||||
| for (const id of execution.processSessionIds) { | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| this.processes.terminate(execution.workspaceId, id); | ||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||
| // A process might have finished and been reaped already. | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| private async run(execution: Execution, code: string): Promise<void> { | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| // The worker and WASM runtime are loaded only when the experimental mode runs a script. | ||||||||||||||||||||||||||||||||||||
| const { CodemodeSandbox } = await import("@earendil-works/pi-codemode"); | ||||||||||||||||||||||||||||||||||||
| let calls = 0; | ||||||||||||||||||||||||||||||||||||
| let active = 0; | ||||||||||||||||||||||||||||||||||||
| const waiters: Array<() => void> = []; | ||||||||||||||||||||||||||||||||||||
| const tools = this.registry.tools(execution.workspaceId, (id) => execution.processSessionIds.add(id)) | ||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Cancelling while Track the process when it starts, or stop late-arriving sessions when the execution has already ended. This cancellation defect must be fixed before merging. Knowledge Base Used: CLI and host process management Artifacts
|
||||||||||||||||||||||||||||||||||||
| .map((tool) => ({ | ||||||||||||||||||||||||||||||||||||
| ...tool, | ||||||||||||||||||||||||||||||||||||
| execute: async (args: unknown, context: Parameters<typeof tool.execute>[1]) => { | ||||||||||||||||||||||||||||||||||||
| if (++calls > MAX_CALLS) throw new Error(`Code Mode limit exceeded: ${MAX_CALLS} tool calls.`); | ||||||||||||||||||||||||||||||||||||
| if (active >= MAX_CONCURRENT_CALLS) await new Promise<void>((resolve) => waiters.push(resolve)); | ||||||||||||||||||||||||||||||||||||
| active++; | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| if (execution.controller.signal.aborted) throw new Error("Code execution cancelled."); | ||||||||||||||||||||||||||||||||||||
| return await tool.execute(args, context); | ||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||
| active--; | ||||||||||||||||||||||||||||||||||||
| waiters.shift()?.(); | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+153
to
+160
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Fix the concurrency limiter so it holds four calls at most. The 🐛 Proposed fix--- "a/src/code-mode-executions.ts"
+++ "b/src/code-mode-executions.ts"
@@ -150,15 +150,16 @@
...tool,
execute: async (args: unknown, context: Parameters<typeof tool.execute>[1]) => {
if (++calls > MAX_CALLS) throw new Error(`Code Mode limit exceeded: ${MAX_CALLS} tool calls.`);
- if (active >= MAX_CONCURRENT_CALLS) await new Promise<void>((resolve) => waiters.push(resolve));
- active++;
+ if (active >= MAX_CONCURRENT_CALLS) await new Promise<void>((resolve) => waiters.push(resolve));
+ else active++;
try {
if (execution.controller.signal.aborted) throw new Error("Code execution cancelled.");
return await tool.execute(args, context);
} finally {
- active--;
- waiters.shift()?.();
+ const next = waiters.shift();
+ if (next) next(); // transfer the slot
+ else active--;
}
},
}));
const sandbox = new CodemodeSandbox({📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| })); | ||||||||||||||||||||||||||||||||||||
| const sandbox = new CodemodeSandbox({ | ||||||||||||||||||||||||||||||||||||
| tools, | ||||||||||||||||||||||||||||||||||||
| timeoutMs: MAX_SCRIPT_MS, | ||||||||||||||||||||||||||||||||||||
| memoryLimitBytes: 256 * 1024 * 1024, | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| execution.result = await sandbox.execute(code, { signal: execution.controller.signal }); | ||||||||||||||||||||||||||||||||||||
| execution.status = execution.controller.signal.aborted ? "cancelled" : execution.result.ok ? "completed" : "failed"; | ||||||||||||||||||||||||||||||||||||
| if (!execution.result.ok) execution.error = execution.result.error.message; | ||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||
| await sandbox.close(); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||
| execution.error = error instanceof Error ? error.message : String(error); | ||||||||||||||||||||||||||||||||||||
| execution.status = execution.controller.signal.aborted ? "cancelled" : "failed"; | ||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||
| if (execution.status !== "completed") this.terminateProcesses(execution); | ||||||||||||||||||||||||||||||||||||
| execution.finishedAt = performance.now(); | ||||||||||||||||||||||||||||||||||||
| execution.cleanupTimer = setTimeout(() => this.executions.delete(execution.id), COMPLETED_TTL_MS); | ||||||||||||||||||||||||||||||||||||
| execution.cleanupTimer.unref(); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import { | ||
| renderDeclarations, | ||
| type CodemodeTool, | ||
| } from "@earendil-works/pi-codemode"; | ||
| import * as z from "zod/v4"; | ||
| import type { McpRegistrationTarget } from "./mcp-modern-server.js"; | ||
| import { contentText } from "./tool-surfaces/shared.js"; | ||
| import type { ToolContent } from "./tool-surfaces/types.js"; | ||
|
|
||
| type Handler = (input: Record<string, unknown>, extra: { signal: AbortSignal }) => Promise<{ | ||
| isError?: boolean; | ||
| content?: ToolContent[]; | ||
| structuredContent?: Record<string, unknown>; | ||
| }>; | ||
|
|
||
| interface Entry { | ||
| name: string; | ||
| description: string; | ||
| input: z.ZodObject<z.ZodRawShape>; | ||
| output: z.ZodObject<z.ZodRawShape>; | ||
| handler: Handler; | ||
| } | ||
|
|
||
| /** Records the real MCP registrations, so Code Mode never duplicates their handlers or schemas. */ | ||
| export class CodeModeRegistry { | ||
| private readonly entries = new Map<string, Entry>(); | ||
|
|
||
| readonly registrationTarget: McpRegistrationTarget = { | ||
| registerTool: ((name: string, definition: Record<string, unknown>, handler: Handler) => { | ||
| const input = z.object(definition.inputSchema as z.ZodRawShape); | ||
| const output = z.object(definition.outputSchema as z.ZodRawShape); | ||
| this.entries.set(name, { | ||
| name, | ||
| description: definition.description as string, | ||
| input, | ||
| output, | ||
| handler, | ||
| }); | ||
| // This target collects internal registrations; nothing is registered with the MCP host. | ||
| return undefined; | ||
| }) as unknown as McpRegistrationTarget["registerTool"], | ||
| registerResource: (() => undefined) as unknown as McpRegistrationTarget["registerResource"], | ||
| }; | ||
|
|
||
| signatures(): string { | ||
| return renderDeclarations({ tools: [...this.entries.values()].map((entry) => ({ | ||
| name: entry.name, | ||
| description: entry.description, | ||
| inputSchema: z.toJSONSchema(entry.input.omit({ workspace_id: true })), | ||
| outputSchema: z.toJSONSchema(entry.output), | ||
| execute: () => undefined, | ||
| })) }); | ||
| } | ||
|
|
||
| tools(workspaceId: string, onProcessSession: (id: number) => void): CodemodeTool[] { | ||
| return [...this.entries.values()].map((entry) => ({ | ||
| name: entry.name, | ||
| description: entry.description, | ||
| inputSchema: z.toJSONSchema(entry.input.omit({ workspace_id: true })), | ||
| outputSchema: z.toJSONSchema(entry.output), | ||
| execute: async (value: unknown, { signal }) => { | ||
| if (signal.aborted) throw new Error("Code execution cancelled."); | ||
| const parsed = entry.input.safeParse({ ...(typeof value === "object" && value !== null ? value : {}), workspace_id: workspaceId }); | ||
| if (!parsed.success) throw new Error(parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")); | ||
| const response = await entry.handler(parsed.data, { signal }); | ||
| if (response.isError) throw new Error(contentText(response.content ?? []) || `${entry.name} failed`); | ||
| const result = response.structuredContent ?? { result: contentText(response.content ?? []) }; | ||
| const validated = entry.output.safeParse(result); | ||
| if (!validated.success) throw new Error(`Invalid ${entry.name} response: ${validated.error.message}`); | ||
| if (entry.name === "exec_command" && result.running === true && typeof result.session_id === "number") { | ||
| onProcessSession(result.session_id); | ||
| } | ||
| return result; | ||
| }, | ||
| })); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
start()accepts unlimited simultaneous executions. Each gets a separate worker with a 256 MiB memory allowance; the four-call limit applies only within one script. A burst ofexecutecalls withyield_time_ms: 0can keep creating workers for five minutes, slowing or exhausting the local server.As a non-blocking improvement, add a server-wide or workspace-wide execution cap and reject or queue excess starts.
Knowledge Base Used: Bound stale MCP session memory
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Artifacts
Evidence from the check
Evidence from the check
Command output from the check
Command output from the check