fix(handoff): keine Workshop-Einladung bei generischem Handoff-Fehler
This commit is contained in:
parent
696a37ac0b
commit
6e33307c7f
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user