diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index ace1485e..ca12234a 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -109,6 +109,12 @@ answer segmentation. The Workflow controller owns admission, execution identity, at-most-once fence, and ownership leases. The shell retains only Durable Object identity, cancellation, status, and dependency wiring. +An explicit app-builder mode remains authoritative. On a projectless first run, a narrowly +matched imperative such as “build a website” or “create a mobile app” also enters the matching +app-builder path before model execution. That high-confidence fallback materializes the project, +scaffolds its canonical workspace, and registers the managed preview even when the selected model +would otherwise attempt generic shell work and finish without a Computer target. + AgentRun keeps one compact exact SQLite shape for run identity, replay parts, and coordination state. Dormant objects are reconciled transactionally on activation; target detection checks column order, affinity, nullability, primary keys, and diff --git a/apps/agent-worker/src/durable-objects/agent-run-path.ts b/apps/agent-worker/src/durable-objects/agent-run-path.ts index 5eeec505..48aa87f0 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-path.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-path.ts @@ -33,7 +33,9 @@ type ProjectBoundAgentRunPathOptions = AgentRunPathOptions & { export async function executeAgentRunPath( options: AgentRunPathOptions, ): Promise<"completed" | "continue"> { - if (isAppBuilderMode(options.input.projectMode)) { + const appBuilderMode = appBuilderModeForRun(options.input); + if (appBuilderMode) { + options.input.projectMode = appBuilderMode; await options.workspaceResolver(); return executeAppBuilderPath({ ...options, @@ -92,6 +94,27 @@ function requireProjectBinding(input: StartRunInput): ProjectBoundStartRunInput return input as ProjectBoundStartRunInput; } -function isAppBuilderMode(mode: StartRunInput["projectMode"]): boolean { +function isAppBuilderMode( + mode: StartRunInput["projectMode"], +): mode is "app-builder" | "app-builder-mobile" { return mode === "app-builder" || mode === "app-builder-mobile"; } + +const IMPERATIVE_BUILD_PATTERN = + /^(?:please\s+)?(?:(?:can|could|would)\s+you\s+)?(?:build|create|make|design|develop|implement|code|scaffold|redesign|clone)\b/iu; +const MOBILE_APP_PATTERN = /\b(?:mobile app|expo|react native|ios app|android app|iphone app)\b/iu; +const WEB_APP_PATTERN = + /\b(?:web ?app|website|web ?site|landing ?page|home ?page|web ?page|dashboard|next\.?js|frontend|front-end|saas)\b/iu; + +function appBuilderModeForRun(input: StartRunInput): "app-builder" | "app-builder-mobile" | null { + if (isAppBuilderMode(input.projectMode)) { + return input.projectMode; + } + if (!input.isFirstRun || input.projectId || !IMPERATIVE_BUILD_PATTERN.test(input.messageText)) { + return null; + } + if (MOBILE_APP_PATTERN.test(input.messageText)) { + return "app-builder-mobile"; + } + return WEB_APP_PATTERN.test(input.messageText) ? "app-builder" : null; +} diff --git a/apps/agent-worker/src/durable-objects/agent-run-workspace.ts b/apps/agent-worker/src/durable-objects/agent-run-workspace.ts index 5e5a4a5b..f7590f51 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-workspace.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-workspace.ts @@ -77,6 +77,9 @@ async function materializeWorkspaceProject(input: WorkspaceResolverInput) { materializeThreadProject( tx, { + ...(input.input.projectMode === "general" + ? {} + : { projectMode: input.input.projectMode }), threadId: toThreadId(input.input.threadId), userId, }, diff --git a/packages/agent-core/README.md b/packages/agent-core/README.md index 51c2eb2d..289fb695 100644 --- a/packages/agent-core/README.md +++ b/packages/agent-core/README.md @@ -36,7 +36,10 @@ project's allocated port when necessary, and is distinct from generic background process tools so idle recovery always has a canonical process record. Browser-only runs use the account sandbox without materializing a persistent project; workspace-backed file, shell, document, chart, or artifact work resolves the thread's -project lazily when durable project storage is actually needed. +project lazily when durable project storage is actually needed. Code tools expose `/workspace` +as a virtual project root and remap path references inside argv, shell payloads, and inline code +to the canonical project folder. Projectless calculations and environment probes run from `/tmp`, +so a weaker model cannot accidentally leave durable files outside a project. ## Code Checks diff --git a/packages/agent-core/src/mastra/tool-defs/code-tools.ts b/packages/agent-core/src/mastra/tool-defs/code-tools.ts index f013c9cb..df3f41dc 100644 --- a/packages/agent-core/src/mastra/tool-defs/code-tools.ts +++ b/packages/agent-core/src/mastra/tool-defs/code-tools.ts @@ -42,6 +42,7 @@ import { WriteFileInputSchema, WriteFileOutputSchema, } from "../../tools/code"; +import { containsWorkspaceReference } from "../../tools/code/workspace-paths"; import { codeRuntimeFromContext, workspaceRuntimeFromContext } from "./tool-runtime-context"; import { StartDevServerInputSchema, StartDevServerOutputSchema } from "./tool-schemas"; @@ -52,8 +53,12 @@ export const mastraRunCode = createTool({ inputSchema: RunCodeInputSchema, outputSchema: RunCodeOutputSchema, execute: async (input, context) => { - const runtimeContext = codeRuntimeFromContext(context); const parsedInput = RunCodeInputSchema.parse(input); + const baseRuntime = codeRuntimeFromContext(context); + const runtimeContext = + baseRuntime.workspaceDir || containsWorkspaceReference(parsedInput.code) + ? await workspaceRuntimeFromContext(context) + : baseRuntime; const output = await executeRunCode(parsedInput, runtimeContext); return RunCodeOutputSchema.parse(output); }, @@ -69,9 +74,11 @@ export const mastraShellExec = createTool({ const parsedInput = ShellExecInputSchema.parse(input); const baseRuntime = codeRuntimeFromContext(context); const runtimeContext = - baseRuntime.workspaceDir || parsedInput.cwd + baseRuntime.workspaceDir || + parsedInput.cwd || + parsedInput.command.some(containsWorkspaceReference) ? await workspaceRuntimeFromContext(context) - : { ...baseRuntime, workspaceDir: "/workspace" }; + : baseRuntime; return executeShellExec(parsedInput, runtimeContext); }, }); diff --git a/packages/agent-core/src/tools/code/preview.ts b/packages/agent-core/src/tools/code/preview.ts index 6f92145e..64e51600 100644 --- a/packages/agent-core/src/tools/code/preview.ts +++ b/packages/agent-core/src/tools/code/preview.ts @@ -5,7 +5,11 @@ import { type SandboxStartProcessInput, } from "@cheatcode/sandbox-contracts"; import { z } from "zod"; -import { resolveProjectWorkspacePath, WorkspacePathSchema } from "./workspace-paths"; +import { + remapProjectWorkspaceReferences, + resolveProjectWorkspacePath, + WorkspacePathSchema, +} from "./workspace-paths"; const StartDevServerInputSchema = z.strictObject({ command: z.array(z.string().min(1).max(8_192)).min(1).max(128), @@ -76,7 +80,10 @@ export async function prepareStartDevServer( const isExpo = isExpoStartCommand(parsedInput.command); const isMobile = isExpo || parsedInput.isMobile; const port = await allocateDevServerPort(runtimeContext, slug, isMobile); - const command = remapRequestedDevServerPort(parsedInput.command, parsedInput.port, port); + const workspaceCommand = parsedInput.command.map((argument) => + remapProjectWorkspaceReferences(argument, runtimeContext.workspaceDir), + ); + const command = remapRequestedDevServerPort(workspaceCommand, parsedInput.port, port); return { mayUseNetwork: isExpo, port, diff --git a/packages/agent-core/src/tools/code/run-code.ts b/packages/agent-core/src/tools/code/run-code.ts index c150ae25..581849b5 100644 --- a/packages/agent-core/src/tools/code/run-code.ts +++ b/packages/agent-core/src/tools/code/run-code.ts @@ -1,7 +1,7 @@ import { APIError } from "@cheatcode/observability"; import type { CodeRuntimeContextFor } from "@cheatcode/sandbox-contracts"; import { z } from "zod"; -import { resolveProjectWorkspacePath } from "./workspace-paths"; +import { remapProjectWorkspaceReferences, resolveProjectWorkspacePath } from "./workspace-paths"; export const RunCodeInputSchema = z.strictObject({ language: z @@ -25,10 +25,11 @@ export async function executeRunCode( runtimeContext: CodeRuntimeContextFor<"runCode">, ): Promise { const parsedInput = RunCodeInputSchema.parse(input); + const workspaceDir = runtimeContext.workspaceDir; const result = await runtimeContext.sandbox.runCode({ language: parsedInput.language, - code: parsedInput.code, - cwd: resolveProjectWorkspacePath(undefined, runtimeContext.workspaceDir), + code: remapProjectWorkspaceReferences(parsedInput.code, workspaceDir), + cwd: workspaceDir ? resolveProjectWorkspacePath(undefined, workspaceDir) : "/tmp", }); const output = { diff --git a/packages/agent-core/src/tools/code/shell.ts b/packages/agent-core/src/tools/code/shell.ts index 4c4b2cad..9329287b 100644 --- a/packages/agent-core/src/tools/code/shell.ts +++ b/packages/agent-core/src/tools/code/shell.ts @@ -4,7 +4,11 @@ import { EnvironmentVariablesSchema, } from "@cheatcode/sandbox-contracts"; import { z } from "zod"; -import { resolveProjectWorkspacePath, WorkspacePathSchema } from "./workspace-paths"; +import { + remapProjectWorkspaceReferences, + resolveProjectWorkspacePath, + WorkspacePathSchema, +} from "./workspace-paths"; export const ShellExecInputSchema = z.strictObject({ command: z @@ -97,9 +101,13 @@ export async function executeShellExec( runtimeContext: CodeRuntimeContextFor<"exec">, ): Promise { const parsedInput = ShellExecInputSchema.parse(input); + const workspaceDir = runtimeContext.workspaceDir; const result = await runtimeContext.sandbox.exec({ - command: parsedInput.command, - cwd: resolveProjectWorkspacePath(parsedInput.cwd, runtimeContext.workspaceDir), + command: remapCommandWorkspaceReferences(parsedInput.command, workspaceDir), + cwd: + workspaceDir || parsedInput.cwd + ? resolveProjectWorkspacePath(parsedInput.cwd, workspaceDir) + : "/tmp", ...(parsedInput.env ? { env: parsedInput.env } : {}), ...(parsedInput.timeoutMs ? { timeoutMs: parsedInput.timeoutMs } : {}), }); @@ -130,7 +138,7 @@ export async function executeShellStartProcess( : undefined; return ShellProcessOutputSchema.parse( await runtimeContext.sandbox.startProcess({ - command: parsedInput.command, + command: remapCommandWorkspaceReferences(parsedInput.command, runtimeContext.workspaceDir), cwd: resolveProjectWorkspacePath(parsedInput.cwd, runtimeContext.workspaceDir), ...(parsedInput.env ? { env: parsedInput.env } : {}), keepAliveTimeoutMs: parsedInput.keepAliveTimeoutMs, @@ -162,9 +170,20 @@ export async function executeShellTerminal( const parsedInput = ShellTerminalInputSchema.parse(input); return ShellExecOutputSchema.parse( await runtimeContext.sandbox.exec({ - command: ["sh", "-lc", parsedInput.command], + command: [ + "sh", + "-lc", + remapProjectWorkspaceReferences(parsedInput.command, runtimeContext.workspaceDir), + ], cwd: resolveProjectWorkspacePath(parsedInput.cwd, runtimeContext.workspaceDir), timeoutMs: parsedInput.timeoutMs, }), ); } + +function remapCommandWorkspaceReferences( + command: readonly string[], + workspaceDir: string | undefined, +): string[] { + return command.map((argument) => remapProjectWorkspaceReferences(argument, workspaceDir)); +} diff --git a/packages/agent-core/src/tools/code/workspace-paths.ts b/packages/agent-core/src/tools/code/workspace-paths.ts index 1cb35a75..c3828459 100644 --- a/packages/agent-core/src/tools/code/workspace-paths.ts +++ b/packages/agent-core/src/tools/code/workspace-paths.ts @@ -48,7 +48,7 @@ export function resolveProjectWorkspacePath( }); } const requested = canonicalWorkspacePath(value ?? projectRoot); - const resolved = requested === "/workspace" ? projectRoot : requested; + const resolved = resolveVirtualWorkspacePath(requested, projectRoot); if (resolved !== projectRoot && !resolved.startsWith(`${projectRoot}/`)) { throw new APIError( 400, @@ -63,6 +63,55 @@ export function resolveProjectWorkspacePath( return resolved; } +/** + * Rewrites the virtual `/workspace` namespace inside a command or inline program to the active + * project's real folder. References that already use the real project root remain unchanged. + */ +export function remapProjectWorkspaceReferences( + value: string, + workspaceDir: string | undefined, +): string { + const projectRoot = canonicalWorkspacePath(workspaceDir ?? "/workspace"); + if (projectRoot === "/workspace") { + return value; + } + return value.replace(WORKSPACE_REFERENCE_PATTERN, (reference, offset: number) => + isProjectRootReference(value, offset, projectRoot) ? reference : projectRoot, + ); +} + +export function containsWorkspaceReference(value: string): boolean { + WORKSPACE_REFERENCE_PATTERN.lastIndex = 0; + return WORKSPACE_REFERENCE_PATTERN.test(value); +} + +const WORKSPACE_REFERENCE_PATTERN = /(? number, ): Promise { return db.transaction(async (tx) => { @@ -226,7 +226,7 @@ export async function materializeThreadProject( async function materializeThreadProjectInLockedTx( db: Database, - input: { threadId: ThreadId; userId: UserId }, + input: { projectMode?: ProjectMode; threadId: ThreadId; userId: UserId }, maxActiveProjects: number, ): Promise { const [locked] = await db @@ -267,7 +267,7 @@ async function materializeThreadProjectInLockedTx( } const intent: ThreadLaunchIntent = locked.launchIntent ?? {}; const project = await createProject(db, { - mode: intent.mode ?? "general", + mode: input.projectMode ?? intent.mode ?? "general", name: projectNameFromTitle(locked.title), userId: input.userId, ...(intent.defaultModel ? { defaultModel: intent.defaultModel } : {}), diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index e434f0fd..f4c825fa 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -33,7 +33,7 @@ export const GitHubRepoUrlSchema = z const PROJECT_MODES = ["app-builder", "app-builder-mobile", "general"] as const; export const ProjectModeSchema = z.enum(PROJECT_MODES); -/** Explicit one-run product modes. These are UI intent, never inferred from prompt text. */ +/** Product modes selected by UI intent or a high-confidence first-run build imperative. */ const RUN_INTENTS = ["skill-creator"] as const; export const RunIntentSchema = z.enum(RUN_INTENTS);