From 6e33307c7f69ae8fda69057d923c1f8bd7468197 Mon Sep 17 00:00:00 2001 From: Ingo Wagner Date: Tue, 22 Sep 2026 06:22:13 +0000 Subject: [PATCH] fix(handoff): keine Workshop-Einladung bei generischem Handoff-Fehler --- packages/adapters/src/executor.ts | 648 ++++++++++++++---- .../adapters/src/prose-handoff-recovery.ts | 32 +- 2 files changed, 546 insertions(+), 134 deletions(-) diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 1ccf4c7..99ac3a1 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -25,8 +25,7 @@ import { routineWakeupJob, runContinueJob, } from "@rakazo/adapter-kit"; -import type { RunStatus } from "@rakazo/contracts"; -import { type MessageBlock, MessageBlock as MessageBlockSchema } from "@rakazo/contracts"; +import type { MessageBlock, RunStatus } from "@rakazo/contracts"; import { ATTACHMENT_MAX_BYTES, BOT_DESCRIPTION_MAX_LENGTH, @@ -92,7 +91,6 @@ import { findModelCredential, InvalidSpaceNameError, isTooManyDatabaseConnections, - listSpaceUserBotIds, loadRunHistoryMessages, type McpServer, type Prisma, @@ -102,13 +100,13 @@ import { type ThreadEvents, } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; +import { parse as parseShellCommand } from "shell-quote"; import { connectAgent, messageConnectedAgent, respondAgentConnection, } from "./agent-connections.js"; import { - agentSecretScopeForRun, decryptAgentEnvironment, formatAgentEnvironmentInstruction, redactAgentCommandResult, @@ -141,26 +139,7 @@ import { settleUncertainEffect, uncertainEffectResult, } from "./approval-effect.js"; -import { - TURN_ATTACHMENT_UNAVAILABLE, - STEERING_ATTACHMENT_UNAVAILABLE, - missingTurnImagesInstruction, - settleSteeringAttachmentLoads, -} from "./executor-attachments.js"; -import { createRunWorkspaceCheckpoint } from "./executor-checkpoint.js"; -import { - completionMessageSegments, - completionNotificationBody, - completionMarksUnread, - threadContextForRun, - subagentMarksUnread, -} from "./executor-completion.js"; -import { - failComputerBusyExhaustedRun, - parseComputerBusyRetryCount, - preserveRunErrorForComputerBusyRetry, - requeueComputerBusyRun, -} from "./executor-computer-busy-retry.js"; +import { recordEffect, completeEffect, uncertainEffectError } from "./executor-effects.js"; import { computerScreenToolResult, notifyRun, @@ -174,16 +153,7 @@ import { persistMessageInTransaction, runNotificationsEnabled, } from "./executor-computer.js"; -import { recordEffect, completeEffect, uncertainEffectError } from "./executor-effects.js"; -import { deploymentKeyFor, resolveModelKey, withModelCredentialLock } from "./executor-model.js"; -import { isProtectedComputerLifecycleCommand } from "./executor-shell-guard.js"; -import { - PAGE_BROWSER_TOOL_NAMES, - filterPageBrowserTools, - selectBuiltinToolsForRun, -} from "./executor-tools.js"; -import { type ExecutorDeps } from "./executor-types.js"; -import { selectRunConnections, loadCurrentTurnImages } from "./executor-utils.js"; + import { autoReviewTimeoutMs, buildAutoReviewPrompt, @@ -235,10 +205,7 @@ import { } from "./computer-lifecycle.js"; import { withComputerScreenAvailability } from "./computer-screens.js"; import { - assertTeamShellCommandAllowed, - assertTeamWorkspacePathAllowed, displayBotWorkspacePath, - filterTeamWorkspaceEntries, resolveBotWorkspaceCwd, resolveBotWorkspacePath, teamBotWorkspaceDirectory, @@ -275,10 +242,7 @@ import { parseMcpServerToolArgs, } from "./mcp-server-tool.js"; import { loadAgentMemoryContext } from "./memory-context.js"; -import { - memoryProviderResolveRequest, - type MemoryProviderResolver, -} from "./memory-provider-factory.js"; +import type { MemoryProviderResolver } from "./memory-provider-factory.js"; import { selectMemoryTools } from "./memory-tools.js"; import { isCatalogModelChoice, @@ -374,6 +338,7 @@ import { import { createWebProvider } from "./web-provider-factory.js"; import { webFetchFromTool, webSearchFromTool } from "./web-tools.js"; +const modelCredentialLocks = new Map>(); const READ_ONLY_AGENT_TOOLS = new Set([ "computer_observe", "list_files", @@ -391,7 +356,204 @@ const READ_ONLY_AGENT_TOOLS = new Set([ "cloud_agent_status", ]); const MAX_MODEL_FILE_BYTES = 250_000; +const TURN_ATTACHMENT_UNAVAILABLE = + "An attachment in this message could not be loaded. Tell the user the attachment was unavailable and do not guess its contents."; +const STEERING_ATTACHMENT_UNAVAILABLE = TURN_ATTACHMENT_UNAVAILABLE; const BUILTIN_AGENT_TOOL_NAMES = new Set(builtinAgentTools.map((tool) => tool.name)); + +/** Avoid an expensive remote workspace export when a turn never touched the computer. */ +export function createRunWorkspaceCheckpoint(checkpoint: () => Promise) { + let dirty = false; + return { + markDirty() { + dirty = true; + }, + markFiles(files: readonly unknown[]) { + if (files.length > 0) dirty = true; + }, + async flush() { + if (!dirty) return false; + dirty = false; + try { + await checkpoint(); + return true; + } catch (error) { + dirty = true; + throw error; + } + }, + }; +} + +const SHELL_INTERPRETER_NAMES = /^(?:bash|sh|dash|zsh|ksh|fish)$/; +const STATIC_SHELL_EXPANSIONS: Readonly> = { + HOME: "/home/rakazo", + LOGNAME: "rakazo", + PATH: "/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + PWD: "/home/rakazo", + TMPDIR: "/tmp", + USER: "rakazo", + WORKSPACE: "/home/rakazo/workspace", + XDG_CONFIG_HOME: "/home/rakazo/.config", +}; +const SAFE_SHELL_CONTROL_OPS = new Set([ + "&&", + "||", + ";", + "|", + "&", + ">", + "<", + ">>", + ">&", + "<&", + "&>", +]); + +function shellCFlagProgram(words: string[], interpreterIndex: number): string | undefined { + for (let index = interpreterIndex + 1; index < words.length; index += 1) { + const word = words[index] ?? ""; + if (word.startsWith("--command=")) return word.slice("--command=".length); + // bash -c / -lc / -ce and fish --command: the next argument is the program string. + if (word === "--command" || /^-[^-]*c/.test(word)) return words[index + 1]; + } + return undefined; +} + +function preserveShellCommandBoundaries(command: string): string { + let quote: "'" | '"' | undefined; + let result = ""; + for (let index = 0; index < command.length; index += 1) { + const character = command[index]; + const next = command[index + 1]; + if (character === "\\" && quote !== "'") { + if (next === "\n") { + index += 1; + continue; + } + // Keep escaped characters intact; an escaped quote is not a boundary. + result += character; + if (next !== undefined) { + result += next; + index += 1; + } + continue; + } + if (character === quote) quote = undefined; + else if (!quote && (character === "'" || character === '"')) quote = character; + result += character === "\n" && !quote ? "\n;" : character; + } + return result; +} + +function tokenizeProtectedShellCommand(command: string): string[] | "dynamic" { + try { + // shell-quote treats newlines as whitespace. Preserve command boundaries for + // the dot builtin, after folding shell line continuations. Retaining the + // newline also preserves comment handling (comments remain fail-closed). + const separated = preserveShellCommandBoundaries(command); + const parsed = parseShellCommand<{ expansion: string }>( + separated, + (name) => STATIC_SHELL_EXPANSIONS[name] ?? { expansion: name }, + { splitUnquoted: true }, + ); + const words: string[] = []; + let commandPosition = true; + let redirectTarget = false; + for (const [index, entry] of parsed.entries()) { + if (typeof entry === "string") { + // Backtick fragments are not fully tokenized; treat them as dynamic. + if (entry.includes("`")) return "dynamic"; + const word = entry.toLowerCase(); + // `find .`, `git add .`, and `git -C .` use a path, not the + // executable `. script` builtin. Keep the path out of the builtin scan. + words.push(word === "." && (!commandPosition || redirectTarget) ? "./" : word); + if (redirectTarget) { + redirectTarget = false; + continue; + } + const next = parsed[index + 1]; + if ( + commandPosition && + /^\d+$/.test(word) && + typeof next === "object" && + "op" in next && + /^[<>]/.test(next.op) + ) { + // A leading file descriptor belongs to a redirect, not the command. + } else if (commandPosition && /^(?:then|do|else)$/.test(word)) { + commandPosition = true; + } else if (commandPosition && (word === "coproc" || word === "function")) return "dynamic"; + else if ( + commandPosition && + (/^(?:command|builtin|exec|time|if|elif|while|until|!|\{)$/.test(word) || + word.startsWith("-") || + /^[a-z_][a-z0-9_]*=/.test(word)) + ) { + // Shell prefixes and assignments leave the command word pending. + } else commandPosition = false; + continue; + } + if ("expansion" in entry) { + // Unknown expansions and command substitutions are resolved by bash + // after this guard runs, so their eventual value cannot be inspected. + return "dynamic"; + } + if ("op" in entry && entry.op === "glob") { + words.push(entry.pattern.toLowerCase()); + continue; + } + if ("op" in entry && SAFE_SHELL_CONTROL_OPS.has(entry.op)) { + if (["&&", "||", ";", "|", "&"].includes(entry.op)) { + commandPosition = true; + redirectTarget = false; + } else redirectTarget = true; + continue; + } + return "dynamic"; + } + return words; + } catch { + return "dynamic"; + } +} + +export function isProtectedComputerLifecycleCommand(command: string): boolean { + const words = tokenizeProtectedShellCommand(command); + if (words === "dynamic") return true; + + const commandNames = words.map((word) => word.split("/").at(-1)); + if (commandNames.some((word) => /^(?:kill|pkill|killall|xkill)$/.test(word ?? ""))) { + return true; + } + // eval/source/. can hide protected commands inside an expansion string that the + // outer tokenizer keeps as a single word (e.g. eval "pkill chromium"). + if (words.includes(".") || commandNames.some((word) => /^(?:eval|source)$/.test(word ?? ""))) { + return true; + } + if ( + commandNames.some((word) => word === "systemctl" || word === "service") && + words.some((word) => /^(?:stop|restart|kill)$/.test(word)) + ) { + return true; + } + if ( + words.some((word) => + /(?:\.browser-profiles|--user-data-dir|\/tmp\/\.x11-unix|\/tmp\/\.x\d+-lock)/.test(word), + ) + ) { + return true; + } + + for (let index = 0; index < words.length; index += 1) { + const name = words[index]?.split("/").at(-1) ?? ""; + if (!SHELL_INTERPRETER_NAMES.test(name)) continue; + const program = shellCFlagProgram(words, index); + if (program && isProtectedComputerLifecycleCommand(program)) return true; + } + return false; +} + /** Cap the roster so a large Space cannot flood the prompt. */ const BOT_DIRECTORY_LIMIT = 40; const MISSING_MODEL_MESSAGE = "Connect a model in Settings before running bots."; @@ -400,6 +562,37 @@ function runtimeFallbackModel(runtime: AgentRuntime) { return runtime.describe().capabilities.scripted ? { provider: "scripted", id: "scripted" } : null; } +export interface ExecutorDeps { + prisma: PrismaClient; + events: ThreadEvents; + runtime: AgentRuntime; + sandbox: SandboxProvider; + memory: MemoryStore; + memoryProviders: MemoryProviderResolver; + home: AgentHomeStore; + artifacts?: ArtifactStore; + connector?: ConnectorProvider; + connectors?: { managed(id: string): ManagedConnectorProvider | undefined }; + secrets: string[]; + secretStore: EncryptedSecretStore; + deploymentModelKey?: string; + dataDir?: string; + notifications?: NotificationProvider; + jobs: JobPublisher; + /** Messaging surface; absent means zero identity queries and no chat prompts. */ + messaging?: { hasIdentity(botId: string): Promise }; + listConnectedPluginSlugs?: (userId: string) => Promise; + /** Builtin web_search / web_fetch. Defaults to keyless HTTP when omitted. */ + web?: WebProvider; + /** Page browser (DOM refs) on the bot computer. Defaults to the sandbox live browser when supported. */ + browser?: BrowserProvider; + secretHttp?: RemoteTransportDependencies; + /** Remote cloud coding agents. Null/omit means tools stay uninjected. */ + cloudAgent?: CloudAgentConnection | null; + /** Aborted when createApp stop() begins so in-flight continueRun boot waits exit promptly. */ + shutdownSignal?: AbortSignal; +} + function isAuditableToolResult(value: unknown): value is { kind: "agent_tool_result"; content: unknown[]; @@ -884,7 +1077,6 @@ export function createRunExecutor(deps: ExecutorDeps) { takeoverContinuePlan(run); const fence = nextFence(run.leaseFence); - const priorComputerBusyRetries = parseComputerBusyRetryCount(run.error); const now = new Date(); const leased = await deps.prisma.run.updateMany({ where: { @@ -903,7 +1095,7 @@ export function createRunExecutor(deps: ExecutorDeps) { leaseOwner: workerId, leaseFence: fence, leaseExpiresAt: new Date(Date.now() + 5 * 60_000), - error: preserveRunErrorForComputerBusyRetry(run.error), + error: null, checkpoint: null, }, }); @@ -942,18 +1134,7 @@ export function createRunExecutor(deps: ExecutorDeps) { }); } catch (error) { if (!(error instanceof ComputerBusyError)) throw error; - const requeued = await requeueComputerBusyRun( - deps, - runId, - workerId, - fence, - priorComputerBusyRetries, - resumeCheckpoint, - heldForTakeover, - ); - if (!requeued) { - await failComputerBusyExhaustedRun(deps, run, workerId, fence); - } + await requeueComputerRun(deps, runId, workerId, fence, resumeCheckpoint, heldForTakeover); return; } const attempt = await deps.prisma.attempt @@ -1039,7 +1220,7 @@ export function createRunExecutor(deps: ExecutorDeps) { }), findDefaultModelCredential(deps.prisma, run), deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }), - deps.memoryProviders.resolve(memoryProviderResolveRequest(run.spaceId, run.userId)), + deps.memoryProviders.resolve(run.spaceId), deps.prisma.taughtSkill.findMany({ where: { botId: run.botId, spaceId: run.spaceId, status: "saved" }, }), @@ -1048,7 +1229,7 @@ export function createRunExecutor(deps: ExecutorDeps) { userId: run.userId, }), deps.prisma.agentSecret.findMany({ - where: agentSecretScopeForRun(run.spaceId, run.userId), + where: { spaceId: run.spaceId }, select: { name: true, secret: { select: { id: true, ciphertext: true } }, @@ -1291,15 +1472,6 @@ export function createRunExecutor(deps: ExecutorDeps) { if (!bot.computer) throw new Error("Bot has no computer"); const storedComputer = bot.computer; const computerMode = parseComputerMode(storedComputer.scope); - const teamBotIds = - computerMode === "team" - ? await listSpaceUserBotIds(deps.prisma, run.spaceId, run.userId) - : null; - const resolveStoredWorkspacePath = (requestedPath: string) => { - const storedPath = resolveBotWorkspacePath(computerMode, bot.id, requestedPath); - if (teamBotIds) assertTeamWorkspacePathAllowed(storedPath, teamBotIds); - return storedPath; - }; const computer = await provisionComputer(deps, storedComputer.id, context, "bot"); screenRelease = { computer, context }; scheduleComputerSleep(deps.jobs, storedComputer.id); @@ -1316,7 +1488,6 @@ export function createRunExecutor(deps: ExecutorDeps) { context, computer, computerMode, - teamBotIds: teamBotIds ?? undefined, markWorkspaceDirty: workspaceCheckpoint.markDirty, }, ) @@ -1349,11 +1520,11 @@ export function createRunExecutor(deps: ExecutorDeps) { }, }); if (!source) return undefined; - const parsed = MessageBlockSchema.array().safeParse(source.blocks); + const parsed = MessageBlock.array().safeParse(source.blocks); if (!parsed.success) return undefined; const handoff = inboundHandoffForBot(parsed.data, bot.id); if (!handoff) return undefined; - return renderInboundHandoffWorkshopInstruction(handoff, (source.bot as { name?: string } | null)?.name); + return renderInboundHandoffWorkshopInstruction(handoff, source.bot?.name); })(); const hasMessagingIdentity = deps.messaging ? await deps.messaging.hasIdentity(bot.id) @@ -2010,7 +2181,7 @@ export function createRunExecutor(deps: ExecutorDeps) { }; if (applied?.duplicate) { - const gate = resolveDuplicateEffectGate(applied.effect as any, name); + const gate = resolveDuplicateEffectGate(applied.effect, name); if (gate.action === "return") { if (name === "request_secret") { const replacementSecret = await deps.prisma.secret.findFirst({ @@ -2127,15 +2298,12 @@ export function createRunExecutor(deps: ExecutorDeps) { const requestedPath = String(args.path ?? ""); const entries = await deps.sandbox.listFiles( computer, - resolveStoredWorkspacePath(requestedPath), + resolveBotWorkspacePath(computerMode, bot.id, requestedPath), context, ); - const visibleEntries = teamBotIds - ? filterTeamWorkspaceEntries(entries, teamBotIds) - : entries; return { path: requestedPath, - entries: visibleEntries.map((entry) => ({ + entries: entries.map((entry) => ({ ...entry, path: displayBotWorkspacePath(computerMode, bot.id, requestedPath, entry.path), })), @@ -2143,7 +2311,7 @@ export function createRunExecutor(deps: ExecutorDeps) { } if (name === "read_file") { const filePath = String(args.path ?? ""); - const storedPath = resolveStoredWorkspacePath(filePath); + const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath); let bytes: Uint8Array; try { bytes = await deps.sandbox.readFile(computer, storedPath, context, { @@ -2187,7 +2355,7 @@ export function createRunExecutor(deps: ExecutorDeps) { await deps.sandbox.writeFile( computer, { - path: resolveStoredWorkspacePath(filePath), + path: resolveBotWorkspacePath(computerMode, bot.id, filePath), content: new TextEncoder().encode(content), }, context, @@ -2212,7 +2380,7 @@ export function createRunExecutor(deps: ExecutorDeps) { if (!rows && dataPath) { const bytes = await deps.sandbox.readFile( computer, - resolveStoredWorkspacePath(dataPath), + resolveBotWorkspacePath(computerMode, bot.id, dataPath), context, { maxBytes: ATTACHMENT_MAX_BYTES }, ); @@ -2234,7 +2402,7 @@ export function createRunExecutor(deps: ExecutorDeps) { workspaceCheckpoint.markDirty(); await deps.sandbox.writeFile( computer, - { path: resolveStoredWorkspacePath(outPath), content: png }, + { path: resolveBotWorkspacePath(computerMode, bot.id, outPath), content: png }, context, ); let attached = false; @@ -2288,7 +2456,7 @@ export function createRunExecutor(deps: ExecutorDeps) { if (!deps.artifacts) { return finish({ error: "artifact storage unavailable", path: filePath }); } - const storedPath = resolveStoredWorkspacePath(filePath); + const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath); let bytes: Uint8Array; try { bytes = await deps.sandbox.readFile(computer, storedPath, context, { @@ -2337,10 +2505,6 @@ export function createRunExecutor(deps: ExecutorDeps) { bot.id, args.cwd ? String(args.cwd) : undefined, ); - if (teamBotIds) { - if (cwd) assertTeamWorkspacePathAllowed(cwd, teamBotIds); - assertTeamShellCommandAllowed(command, teamBotIds); - } workspaceCheckpoint.markDirty(); const result = await runSandboxCommand( deps.sandbox, @@ -2378,7 +2542,7 @@ export function createRunExecutor(deps: ExecutorDeps) { kind: "open", path: /^https?:\/\//i.test(requestedPath) ? requestedPath - : resolveStoredWorkspacePath(requestedPath), + : resolveBotWorkspacePath(computerMode, bot.id, requestedPath), }, ], observe: true, @@ -2876,7 +3040,7 @@ export function createRunExecutor(deps: ExecutorDeps) { } const recordedEffect = await recordEffect(deps, run, name, effectKey, args); if (recordedEffect?.duplicate) { - const gate = resolveDuplicateEffectGate(recordedEffect.effect as any, name); + const gate = resolveDuplicateEffectGate(recordedEffect.effect, name); if (gate.action === "execute") { const early = await claimOrReturn("approved"); if (early !== undefined) return early; @@ -2931,7 +3095,7 @@ export function createRunExecutor(deps: ExecutorDeps) { }); } const recordedForAsk = await recordEffect(deps, run, name, effectKey, args); - const missingSecretAction = resolveMissingRunSecretAction(recordedForAsk.effect as any); + const missingSecretAction = resolveMissingRunSecretAction(recordedForAsk.effect); if (missingSecretAction.action === "return") return missingSecretAction.result; const connectionId = args.connectionId ? String(args.connectionId) : undefined; if (connectionId) { @@ -3581,7 +3745,6 @@ export function createRunExecutor(deps: ExecutorDeps) { context, computer, computerMode, - teamBotIds: teamBotIds ?? undefined, markWorkspaceDirty: workspaceCheckpoint.markDirty, }, ) @@ -3950,7 +4113,7 @@ export function createRunExecutor(deps: ExecutorDeps) { await deps.sandbox.writeFile( computer, { - path: resolveStoredWorkspacePath(file.path), + path: resolveBotWorkspacePath(computerMode, bot.id, file.path), content: new TextEncoder().encode(file.content), }, context, @@ -4006,7 +4169,7 @@ export function createRunExecutor(deps: ExecutorDeps) { const workshopFallback = workshopFallbackAfterBlockedHandoff( proseRecovery.sanitizedText, proseRecovery.blockedUpward, - proseRecovery.proseToolCalls, + proseRecovery.handoffFailed, ); if (proseRecovery.proseToolCalls.length > 0) { assembled = proseRecovery.sanitizedText; @@ -4161,28 +4324,8 @@ export function createRunExecutor(deps: ExecutorDeps) { } } catch (setupError) { const computerBusy = setupError instanceof ComputerBusyError; - const retryDatabaseConnections = isTooManyDatabaseConnections(setupError); - if (computerBusy) { - const requeued = await requeueComputerBusyRun( - deps, - runId, - workerId, - fence, - priorComputerBusyRetries, - resumeCheckpoint, - heldForTakeover, - ); - if (requeued) { - await deps.prisma.attempt.update({ - where: { id: attempt.id }, - data: { status: "setup_failed", error: null, finishedAt: new Date() }, - }); - return; - } - await failComputerBusyExhaustedRun(deps, run, workerId, fence, attempt.id); - return; - } - if (!retryDatabaseConnections) { + const retryForever = computerBusy || isTooManyDatabaseConnections(setupError); + if (!computerBusy) { // undici collapses every network failure to "fetch failed"; the cause names the // host and errno, which is the only part worth paging over. const causeMessage = @@ -4206,18 +4349,18 @@ export function createRunExecutor(deps: ExecutorDeps) { fence, resumeCheckpoint, heldForTakeover, - retryDatabaseConnections ? null : "Run setup failed; retrying", + retryForever ? null : "Run setup failed; retrying", ); if (released) { await deps.prisma.attempt.update({ where: { id: attempt.id }, data: { status: "setup_failed", - error: retryDatabaseConnections ? null : "Run setup failed; retrying", + error: retryForever ? null : "Run setup failed; retrying", finishedAt: new Date(), }, }); - if (retryDatabaseConnections) { + if (retryForever) { await deps.jobs.enqueue({ ...runContinueJob(runId), availableAt: new Date(Date.now() + computerRetryDelay(fence)), @@ -4248,6 +4391,53 @@ export function createRunExecutor(deps: ExecutorDeps) { }; } +export function selectBuiltinToolsForRun(options: { + graphicalToolsAllowed: boolean; + /** Page browser tools need a graphical computer (Chrome), not model vision. */ + pageBrowserAllowed?: boolean; + groupId: string | null; + trigger: string; + semanticMemoryEnabled: boolean; + cloudAgentEnabled?: boolean; + messagingChannelRun: boolean; +}) { + return selectCloudAgentTools( + selectMemoryTools( + filterBuiltinToolsForRun( + filterBuiltinToolsForThread( + filterPageBrowserTools( + filterImageReturningComputerTools(builtinAgentTools, options.graphicalToolsAllowed), + options.pageBrowserAllowed ?? options.graphicalToolsAllowed, + ), + options.groupId, + ), + options.trigger, + ), + options.semanticMemoryEnabled, + ), + Boolean(options.cloudAgentEnabled), + ).filter( + (tool) => + !options.messagingChannelRun || + (!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) && + !tool.name.startsWith("scratchpad_")), + ); +} + +export const PAGE_BROWSER_TOOL_NAMES = new Set([ + "browser_navigate", + "browser_snapshot", + "browser_act", +]); + +export function filterPageBrowserTools( + tools: T[], + pageBrowserAllowed: boolean, +): T[] { + if (pageBrowserAllowed) return tools; + return tools.filter((tool) => !PAGE_BROWSER_TOOL_NAMES.has(tool.name)); +} + async function runSandboxCommand( sandbox: SandboxProvider, computer: ComputerRef, @@ -4284,12 +4474,224 @@ async function runSandboxCommand( return { stdout, stderr, code }; } -export { TURN_ATTACHMENT_UNAVAILABLE, STEERING_ATTACHMENT_UNAVAILABLE, missingTurnImagesInstruction, settleSteeringAttachmentLoads } from "./executor-attachments.js"; -export { createRunWorkspaceCheckpoint } from "./executor-checkpoint.js"; -export { isProtectedComputerLifecycleCommand } from "./executor-shell-guard.js"; -export { type ExecutorDeps } from "./executor-types.js"; -export { completionMessageSegments, completionNotificationBody, completionMarksUnread, threadContextForRun, subagentMarksUnread } from "./executor-completion.js"; -export { selectRunConnections, loadCurrentTurnImages } from "./executor-utils.js"; -export { PAGE_BROWSER_TOOL_NAMES, filterPageBrowserTools, selectBuiltinToolsForRun } from "./executor-tools.js"; +/** + * The deployment key is a bearer credential for exactly one vendor, so it is handed out + * only when the provider that won the resolution above is that vendor. A provider named + * by deployment settings or a bot override gets no key rather than another vendor's. + */ +function deploymentKeyFor(deps: ExecutorDeps, provider: string): string | undefined { + if (!deps.deploymentModelKey) return undefined; + return provider === resolveDeploymentModel().provider ? deps.deploymentModelKey : undefined; +} + +async function resolveModelKey( + deps: ExecutorDeps, + userId: string, + spaceId: string, + credential: { + secretId: string; + provider: string; + defaultModel?: string | null; + supportsImages?: boolean; + } | null, + provider: string, + modelId: string, + registerSecrets?: (values: string[]) => void, +): Promise<{ + apiKey?: string; + baseUrl?: string; + reasoning?: boolean; + maxTokens?: number; + contextWindow?: number; + thinkingLevel?: AgentRunRequest["model"]["thinkingLevel"]; + acceptsImages?: boolean; + maxImagesPerPrompt?: number; + oauth?: AgentModelOAuthCredential; + persistOAuth?: (credential: AgentModelOAuthCredential) => Promise; + redact: string[]; +}> { + if (credential) { + return withModelCredentialLock(credential.secretId, async () => { + const row = await deps.prisma.secret.findFirst({ + where: { id: credential.secretId, userId, spaceId: null }, + }); + if (!row) return { apiKey: deploymentKeyFor(deps, provider), redact: [] }; + const plaintext = deps.secretStore.load(row.ciphertext, row.id); + registerSecrets?.(secretValuesToRedact(parseModelSecret(plaintext))); + const persist = async (next: string) => { + const stored = await deps.secretStore.put( + next, + { + operationId: "cred", + traceId: "cred-refresh", + spaceId, + userId, + signal: new AbortController().signal, + }, + row.id, + ); + await deps.prisma.secret.update({ + where: { id: row.id }, + data: { ciphertext: stored.ciphertext }, + }); + }; + const resolved = await resolveModelAuth(plaintext, credential.provider, { + persist, + }); + const oauth = resolved.secret.kind === "oauth" ? resolved.secret.credential : undefined; + const baseUrl = + resolved.secret.kind === "openai_compatible" ? resolved.secret.baseUrl : undefined; + const acceptsImages = + credential.provider === OPENAI_COMPATIBLE_PROVIDER_ID && + resolved.secret.kind === "openai_compatible" && + (modelIdSupportsImages(resolved.secret.visionModelIds, modelId) || + // Legacy secrets have no per-model list, so keep their existing + // capability scoped to the model saved in the space preference. + (resolved.secret.visionModelIds === undefined && + credential.supportsImages === true && + credential.defaultModel?.trim() === modelId.trim())); + return { + apiKey: resolved.apiKey, + baseUrl, + reasoning: + resolved.secret.kind === "openai_compatible" ? resolved.secret.reasoning : undefined, + maxTokens: + resolved.secret.kind === "openai_compatible" ? resolved.secret.maxTokens : undefined, + contextWindow: + resolved.secret.kind === "openai_compatible" ? resolved.secret.contextWindow : undefined, + thinkingLevel: + resolved.secret.kind === "openai_compatible" ? resolved.secret.thinkingLevel : undefined, + acceptsImages, + maxImagesPerPrompt: + resolved.secret.kind === "openai_compatible" + ? resolved.secret.maxImagesPerPrompt + : undefined, + oauth, + persistOAuth: oauth + ? async (next) => { + await withModelCredentialLock(credential.secretId, async () => { + const currentRow = await deps.prisma.secret.findFirst({ + where: { id: credential.secretId, userId, spaceId: null }, + }); + if (!currentRow) return; + const current = parseModelSecret( + deps.secretStore.load(currentRow.ciphertext, currentRow.id), + ); + if (current.kind === "oauth") { + const stored = current.credential; + if (stored.expires > next.expires) return; + if ( + stored.access === next.access && + stored.refresh === next.refresh && + stored.expires === next.expires + ) { + return; + } + } + await persist( + serializeModelSecret({ kind: "oauth", credential: toOAuthCredential(next) }), + ); + }); + } + : undefined, + redact: [...secretValuesToRedact(resolved.secret), resolved.apiKey].filter( + (value): value is string => Boolean(value), + ), + }; + }); + } + return { apiKey: deploymentKeyFor(deps, provider), redact: [] }; +} + +async function withModelCredentialLock(key: string, fn: () => Promise): Promise { + const previous = modelCredentialLocks.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = previous.then( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + modelCredentialLocks.set(key, current); + await previous; + try { + return await fn(); + } finally { + release(); + if (modelCredentialLocks.get(key) === current) modelCredentialLocks.delete(key); + } +} + +export function selectRunConnections< + T extends { connectorId: string; provider: string; status: string }, +>(rows: T[], connectedComposioProviders: string[]): T[] { + const activeKeys = new Set(connectedComposioProviders.map((provider) => `composio:${provider}`)); + return rows.filter( + (row) => + row.status !== "revoked" && + (row.status === "connected" || activeKeys.has(`${row.connectorId}:${row.provider}`)), + ); +} + +export async function loadCurrentTurnImages( + deps: ExecutorDeps, + blocks: MessageBlock[] | undefined, + context: { + operationId: string; + traceId: string; + spaceId: string; + userId: string; + botId: string; + runId: string; + signal: AbortSignal; + }, +) { + if (!deps.artifacts || !blocks?.length) return undefined; + const imageBlocks = blocks.filter( + (block): block is Extract => block.kind === "image", + ); + if (!imageBlocks.length) return undefined; + + const rows = await deps.prisma.artifact.findMany({ + where: { + id: { in: imageBlocks.map((block) => block.artifactId) }, + spaceId: context.spaceId, + userId: context.userId, + }, + }); + const byId = new Map(rows.map((row) => [row.id, row])); + const images: NonNullable = []; + + for (const block of imageBlocks) { + const row = byId.get(block.artifactId); + if (!row || !isAttachmentImageMimeType(block.mimeType)) continue; + try { + const bytes = await deps.artifacts.get(row.storageKey, context); + images.push({ + name: block.name, + mimeType: block.mimeType as "image/jpeg" | "image/png" | "image/webp" | "image/gif", + data: bytes, + }); + } catch (error) { + if (context.signal.aborted) throw error; + } + } + + return images.length ? images : undefined; +} + +// Re-export computer/lifecycle utilities from executor-computer.ts +export { + computerScreenToolResult, + notifyRun, + renewRunLease, + computerRetryDelay, + computerRunRequeueData, + writeComputerRunRequeue, + requeueComputerRun, + redactBlocks, + publishMessage, + runNotificationsEnabled, +} from "./executor-computer.js"; +// Re-export effect utilities from executor-effects.ts export { recordEffect, completeEffect, uncertainEffectError } from "./executor-effects.js"; -export { computerScreenToolResult, notifyRun, renewRunLease, computerRetryDelay, computerRunRequeueData, writeComputerRunRequeue, requeueComputerRun, redactBlocks, publishMessage, persistMessageInTransaction, runNotificationsEnabled } from "./executor-computer.js"; diff --git a/packages/adapters/src/prose-handoff-recovery.ts b/packages/adapters/src/prose-handoff-recovery.ts index 462963e..646aa36 100644 --- a/packages/adapters/src/prose-handoff-recovery.ts +++ b/packages/adapters/src/prose-handoff-recovery.ts @@ -6,6 +6,9 @@ import { handoffToGroupBot } from "./group-handoff.js"; export const INBOUND_HANDOFF_WORKSHOP_FALLBACK = "Ich übernehme das direkt in meiner Spur — gleich skizziere ich erste Richtungen und stelle dir gezielte Fragen."; +export const INBOUND_HANDOFF_FAILURE_FALLBACK = + "Die Übergabe ist gerade fehlgeschlagen — ich bleibe dabei und mache hier mit dir weiter."; + type HandoffRun = { id: string; spaceId: string; @@ -50,6 +53,7 @@ export async function tryRecoverProseHandoff( handedOff: boolean; sanitizedText: string; blockedUpward: boolean; + handoffFailed: boolean; proseToolCalls: ProseToolCall[]; }> { const sanitized = sanitizeBotReplyText(text); @@ -58,12 +62,14 @@ export async function tryRecoverProseHandoff( handedOff, sanitizedText: sanitized.text, blockedUpward: false, + handoffFailed: false, proseToolCalls: sanitized.proseToolCalls, }; } let nextHandedOff: boolean = handedOff; - let blockedUpward: boolean = false; + let blockedUpward = false; + let handoffFailed = false; for (const call of sanitized.proseToolCalls) { if (call.name !== "handoff_to_bot") continue; const args = proseHandoffArgs(call); @@ -73,11 +79,15 @@ export async function tryRecoverProseHandoff( nextHandedOff = true; continue; } - if ( - "error" in result && - (result.error.includes("sender") || result.error.includes("you own this handed-off stage")) - ) { - blockedUpward = true; + if ("error" in result) { + if ( + result.error.includes("sender") || + result.error.includes("you own this handed-off stage") + ) { + blockedUpward = true; + } else { + handoffFailed = true; + } } } @@ -85,6 +95,7 @@ export async function tryRecoverProseHandoff( handedOff: nextHandedOff, sanitizedText: sanitized.text, blockedUpward, + handoffFailed, proseToolCalls: sanitized.proseToolCalls, }; } @@ -92,11 +103,10 @@ export async function tryRecoverProseHandoff( export function workshopFallbackAfterBlockedHandoff( sanitizedText: string, blockedUpward: boolean, - proseToolCalls: readonly ProseToolCall[], + handoffFailed: boolean, ): string | undefined { if (sanitizedText.trim()) return undefined; - if (!blockedUpward && !proseToolCalls.some((call) => call.name === "handoff_to_bot")) { - return undefined; - } - return INBOUND_HANDOFF_WORKSHOP_FALLBACK; + if (blockedUpward) return INBOUND_HANDOFF_WORKSHOP_FALLBACK; + if (handoffFailed) return INBOUND_HANDOFF_FAILURE_FALLBACK; + return undefined; }