fix(handoff): keine Workshop-Einladung bei generischem Handoff-Fehler

This commit is contained in:
Ingo Wagner 2026-09-22 06:22:13 +00:00
parent 696a37ac0b
commit 6e33307c7f
2 changed files with 546 additions and 134 deletions

View File

@ -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<string, Promise<void>>();
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<unknown>) {
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<Record<string, string>> = {
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<boolean> };
listConnectedPluginSlugs?: (userId: string) => Promise<string[]>;
/** 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<T extends { name: string }>(
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<void>;
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<T>(key: string, fn: () => Promise<T>): Promise<T> {
const previous = modelCredentialLocks.get(key) ?? Promise.resolve();
let release!: () => void;
const current = previous.then(
() =>
new Promise<void>((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<MessageBlock, { kind: "image" }> => 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<AgentRunRequest["currentTurnImages"]> = [];
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";

View File

@ -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;
}