fix(handoff): keine Workshop-Einladung bei generischem Handoff-Fehler
This commit is contained in:
parent
696a37ac0b
commit
6e33307c7f
@ -25,8 +25,7 @@ import {
|
|||||||
routineWakeupJob,
|
routineWakeupJob,
|
||||||
runContinueJob,
|
runContinueJob,
|
||||||
} from "@rakazo/adapter-kit";
|
} from "@rakazo/adapter-kit";
|
||||||
import type { RunStatus } from "@rakazo/contracts";
|
import type { MessageBlock, RunStatus } from "@rakazo/contracts";
|
||||||
import { type MessageBlock, MessageBlock as MessageBlockSchema } from "@rakazo/contracts";
|
|
||||||
import {
|
import {
|
||||||
ATTACHMENT_MAX_BYTES,
|
ATTACHMENT_MAX_BYTES,
|
||||||
BOT_DESCRIPTION_MAX_LENGTH,
|
BOT_DESCRIPTION_MAX_LENGTH,
|
||||||
@ -92,7 +91,6 @@ import {
|
|||||||
findModelCredential,
|
findModelCredential,
|
||||||
InvalidSpaceNameError,
|
InvalidSpaceNameError,
|
||||||
isTooManyDatabaseConnections,
|
isTooManyDatabaseConnections,
|
||||||
listSpaceUserBotIds,
|
|
||||||
loadRunHistoryMessages,
|
loadRunHistoryMessages,
|
||||||
type McpServer,
|
type McpServer,
|
||||||
type Prisma,
|
type Prisma,
|
||||||
@ -102,13 +100,13 @@ import {
|
|||||||
type ThreadEvents,
|
type ThreadEvents,
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
import { getLogger } from "@rakazo/logging";
|
import { getLogger } from "@rakazo/logging";
|
||||||
|
import { parse as parseShellCommand } from "shell-quote";
|
||||||
import {
|
import {
|
||||||
connectAgent,
|
connectAgent,
|
||||||
messageConnectedAgent,
|
messageConnectedAgent,
|
||||||
respondAgentConnection,
|
respondAgentConnection,
|
||||||
} from "./agent-connections.js";
|
} from "./agent-connections.js";
|
||||||
import {
|
import {
|
||||||
agentSecretScopeForRun,
|
|
||||||
decryptAgentEnvironment,
|
decryptAgentEnvironment,
|
||||||
formatAgentEnvironmentInstruction,
|
formatAgentEnvironmentInstruction,
|
||||||
redactAgentCommandResult,
|
redactAgentCommandResult,
|
||||||
@ -141,26 +139,7 @@ import {
|
|||||||
settleUncertainEffect,
|
settleUncertainEffect,
|
||||||
uncertainEffectResult,
|
uncertainEffectResult,
|
||||||
} from "./approval-effect.js";
|
} from "./approval-effect.js";
|
||||||
import {
|
import { recordEffect, completeEffect, uncertainEffectError } from "./executor-effects.js";
|
||||||
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 {
|
import {
|
||||||
computerScreenToolResult,
|
computerScreenToolResult,
|
||||||
notifyRun,
|
notifyRun,
|
||||||
@ -174,16 +153,7 @@ import {
|
|||||||
persistMessageInTransaction,
|
persistMessageInTransaction,
|
||||||
runNotificationsEnabled,
|
runNotificationsEnabled,
|
||||||
} from "./executor-computer.js";
|
} 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 {
|
import {
|
||||||
autoReviewTimeoutMs,
|
autoReviewTimeoutMs,
|
||||||
buildAutoReviewPrompt,
|
buildAutoReviewPrompt,
|
||||||
@ -235,10 +205,7 @@ import {
|
|||||||
} from "./computer-lifecycle.js";
|
} from "./computer-lifecycle.js";
|
||||||
import { withComputerScreenAvailability } from "./computer-screens.js";
|
import { withComputerScreenAvailability } from "./computer-screens.js";
|
||||||
import {
|
import {
|
||||||
assertTeamShellCommandAllowed,
|
|
||||||
assertTeamWorkspacePathAllowed,
|
|
||||||
displayBotWorkspacePath,
|
displayBotWorkspacePath,
|
||||||
filterTeamWorkspaceEntries,
|
|
||||||
resolveBotWorkspaceCwd,
|
resolveBotWorkspaceCwd,
|
||||||
resolveBotWorkspacePath,
|
resolveBotWorkspacePath,
|
||||||
teamBotWorkspaceDirectory,
|
teamBotWorkspaceDirectory,
|
||||||
@ -275,10 +242,7 @@ import {
|
|||||||
parseMcpServerToolArgs,
|
parseMcpServerToolArgs,
|
||||||
} from "./mcp-server-tool.js";
|
} from "./mcp-server-tool.js";
|
||||||
import { loadAgentMemoryContext } from "./memory-context.js";
|
import { loadAgentMemoryContext } from "./memory-context.js";
|
||||||
import {
|
import type { MemoryProviderResolver } from "./memory-provider-factory.js";
|
||||||
memoryProviderResolveRequest,
|
|
||||||
type MemoryProviderResolver,
|
|
||||||
} from "./memory-provider-factory.js";
|
|
||||||
import { selectMemoryTools } from "./memory-tools.js";
|
import { selectMemoryTools } from "./memory-tools.js";
|
||||||
import {
|
import {
|
||||||
isCatalogModelChoice,
|
isCatalogModelChoice,
|
||||||
@ -374,6 +338,7 @@ import {
|
|||||||
import { createWebProvider } from "./web-provider-factory.js";
|
import { createWebProvider } from "./web-provider-factory.js";
|
||||||
import { webFetchFromTool, webSearchFromTool } from "./web-tools.js";
|
import { webFetchFromTool, webSearchFromTool } from "./web-tools.js";
|
||||||
|
|
||||||
|
const modelCredentialLocks = new Map<string, Promise<void>>();
|
||||||
const READ_ONLY_AGENT_TOOLS = new Set([
|
const READ_ONLY_AGENT_TOOLS = new Set([
|
||||||
"computer_observe",
|
"computer_observe",
|
||||||
"list_files",
|
"list_files",
|
||||||
@ -391,7 +356,204 @@ const READ_ONLY_AGENT_TOOLS = new Set([
|
|||||||
"cloud_agent_status",
|
"cloud_agent_status",
|
||||||
]);
|
]);
|
||||||
const MAX_MODEL_FILE_BYTES = 250_000;
|
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));
|
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. */
|
/** Cap the roster so a large Space cannot flood the prompt. */
|
||||||
const BOT_DIRECTORY_LIMIT = 40;
|
const BOT_DIRECTORY_LIMIT = 40;
|
||||||
const MISSING_MODEL_MESSAGE = "Connect a model in Settings before running bots.";
|
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;
|
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 {
|
function isAuditableToolResult(value: unknown): value is {
|
||||||
kind: "agent_tool_result";
|
kind: "agent_tool_result";
|
||||||
content: unknown[];
|
content: unknown[];
|
||||||
@ -884,7 +1077,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
takeoverContinuePlan(run);
|
takeoverContinuePlan(run);
|
||||||
|
|
||||||
const fence = nextFence(run.leaseFence);
|
const fence = nextFence(run.leaseFence);
|
||||||
const priorComputerBusyRetries = parseComputerBusyRetryCount(run.error);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const leased = await deps.prisma.run.updateMany({
|
const leased = await deps.prisma.run.updateMany({
|
||||||
where: {
|
where: {
|
||||||
@ -903,7 +1095,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
leaseOwner: workerId,
|
leaseOwner: workerId,
|
||||||
leaseFence: fence,
|
leaseFence: fence,
|
||||||
leaseExpiresAt: new Date(Date.now() + 5 * 60_000),
|
leaseExpiresAt: new Date(Date.now() + 5 * 60_000),
|
||||||
error: preserveRunErrorForComputerBusyRetry(run.error),
|
error: null,
|
||||||
checkpoint: null,
|
checkpoint: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -942,18 +1134,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof ComputerBusyError)) throw error;
|
if (!(error instanceof ComputerBusyError)) throw error;
|
||||||
const requeued = await requeueComputerBusyRun(
|
await requeueComputerRun(deps, runId, workerId, fence, resumeCheckpoint, heldForTakeover);
|
||||||
deps,
|
|
||||||
runId,
|
|
||||||
workerId,
|
|
||||||
fence,
|
|
||||||
priorComputerBusyRetries,
|
|
||||||
resumeCheckpoint,
|
|
||||||
heldForTakeover,
|
|
||||||
);
|
|
||||||
if (!requeued) {
|
|
||||||
await failComputerBusyExhaustedRun(deps, run, workerId, fence);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const attempt = await deps.prisma.attempt
|
const attempt = await deps.prisma.attempt
|
||||||
@ -1039,7 +1220,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
}),
|
}),
|
||||||
findDefaultModelCredential(deps.prisma, run),
|
findDefaultModelCredential(deps.prisma, run),
|
||||||
deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }),
|
deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }),
|
||||||
deps.memoryProviders.resolve(memoryProviderResolveRequest(run.spaceId, run.userId)),
|
deps.memoryProviders.resolve(run.spaceId),
|
||||||
deps.prisma.taughtSkill.findMany({
|
deps.prisma.taughtSkill.findMany({
|
||||||
where: { botId: run.botId, spaceId: run.spaceId, status: "saved" },
|
where: { botId: run.botId, spaceId: run.spaceId, status: "saved" },
|
||||||
}),
|
}),
|
||||||
@ -1048,7 +1229,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
userId: run.userId,
|
userId: run.userId,
|
||||||
}),
|
}),
|
||||||
deps.prisma.agentSecret.findMany({
|
deps.prisma.agentSecret.findMany({
|
||||||
where: agentSecretScopeForRun(run.spaceId, run.userId),
|
where: { spaceId: run.spaceId },
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
secret: { select: { id: true, ciphertext: 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");
|
if (!bot.computer) throw new Error("Bot has no computer");
|
||||||
const storedComputer = bot.computer;
|
const storedComputer = bot.computer;
|
||||||
const computerMode = parseComputerMode(storedComputer.scope);
|
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");
|
const computer = await provisionComputer(deps, storedComputer.id, context, "bot");
|
||||||
screenRelease = { computer, context };
|
screenRelease = { computer, context };
|
||||||
scheduleComputerSleep(deps.jobs, storedComputer.id);
|
scheduleComputerSleep(deps.jobs, storedComputer.id);
|
||||||
@ -1316,7 +1488,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
context,
|
context,
|
||||||
computer,
|
computer,
|
||||||
computerMode,
|
computerMode,
|
||||||
teamBotIds: teamBotIds ?? undefined,
|
|
||||||
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -1349,11 +1520,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!source) return undefined;
|
if (!source) return undefined;
|
||||||
const parsed = MessageBlockSchema.array().safeParse(source.blocks);
|
const parsed = MessageBlock.array().safeParse(source.blocks);
|
||||||
if (!parsed.success) return undefined;
|
if (!parsed.success) return undefined;
|
||||||
const handoff = inboundHandoffForBot(parsed.data, bot.id);
|
const handoff = inboundHandoffForBot(parsed.data, bot.id);
|
||||||
if (!handoff) return undefined;
|
if (!handoff) return undefined;
|
||||||
return renderInboundHandoffWorkshopInstruction(handoff, (source.bot as { name?: string } | null)?.name);
|
return renderInboundHandoffWorkshopInstruction(handoff, source.bot?.name);
|
||||||
})();
|
})();
|
||||||
const hasMessagingIdentity = deps.messaging
|
const hasMessagingIdentity = deps.messaging
|
||||||
? await deps.messaging.hasIdentity(bot.id)
|
? await deps.messaging.hasIdentity(bot.id)
|
||||||
@ -2010,7 +2181,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (applied?.duplicate) {
|
if (applied?.duplicate) {
|
||||||
const gate = resolveDuplicateEffectGate(applied.effect as any, name);
|
const gate = resolveDuplicateEffectGate(applied.effect, name);
|
||||||
if (gate.action === "return") {
|
if (gate.action === "return") {
|
||||||
if (name === "request_secret") {
|
if (name === "request_secret") {
|
||||||
const replacementSecret = await deps.prisma.secret.findFirst({
|
const replacementSecret = await deps.prisma.secret.findFirst({
|
||||||
@ -2127,15 +2298,12 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
const requestedPath = String(args.path ?? "");
|
const requestedPath = String(args.path ?? "");
|
||||||
const entries = await deps.sandbox.listFiles(
|
const entries = await deps.sandbox.listFiles(
|
||||||
computer,
|
computer,
|
||||||
resolveStoredWorkspacePath(requestedPath),
|
resolveBotWorkspacePath(computerMode, bot.id, requestedPath),
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
const visibleEntries = teamBotIds
|
|
||||||
? filterTeamWorkspaceEntries(entries, teamBotIds)
|
|
||||||
: entries;
|
|
||||||
return {
|
return {
|
||||||
path: requestedPath,
|
path: requestedPath,
|
||||||
entries: visibleEntries.map((entry) => ({
|
entries: entries.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
path: displayBotWorkspacePath(computerMode, bot.id, requestedPath, entry.path),
|
path: displayBotWorkspacePath(computerMode, bot.id, requestedPath, entry.path),
|
||||||
})),
|
})),
|
||||||
@ -2143,7 +2311,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
}
|
}
|
||||||
if (name === "read_file") {
|
if (name === "read_file") {
|
||||||
const filePath = String(args.path ?? "");
|
const filePath = String(args.path ?? "");
|
||||||
const storedPath = resolveStoredWorkspacePath(filePath);
|
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath);
|
||||||
let bytes: Uint8Array;
|
let bytes: Uint8Array;
|
||||||
try {
|
try {
|
||||||
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
|
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
|
||||||
@ -2187,7 +2355,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
await deps.sandbox.writeFile(
|
await deps.sandbox.writeFile(
|
||||||
computer,
|
computer,
|
||||||
{
|
{
|
||||||
path: resolveStoredWorkspacePath(filePath),
|
path: resolveBotWorkspacePath(computerMode, bot.id, filePath),
|
||||||
content: new TextEncoder().encode(content),
|
content: new TextEncoder().encode(content),
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
@ -2212,7 +2380,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
if (!rows && dataPath) {
|
if (!rows && dataPath) {
|
||||||
const bytes = await deps.sandbox.readFile(
|
const bytes = await deps.sandbox.readFile(
|
||||||
computer,
|
computer,
|
||||||
resolveStoredWorkspacePath(dataPath),
|
resolveBotWorkspacePath(computerMode, bot.id, dataPath),
|
||||||
context,
|
context,
|
||||||
{ maxBytes: ATTACHMENT_MAX_BYTES },
|
{ maxBytes: ATTACHMENT_MAX_BYTES },
|
||||||
);
|
);
|
||||||
@ -2234,7 +2402,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
workspaceCheckpoint.markDirty();
|
workspaceCheckpoint.markDirty();
|
||||||
await deps.sandbox.writeFile(
|
await deps.sandbox.writeFile(
|
||||||
computer,
|
computer,
|
||||||
{ path: resolveStoredWorkspacePath(outPath), content: png },
|
{ path: resolveBotWorkspacePath(computerMode, bot.id, outPath), content: png },
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
let attached = false;
|
let attached = false;
|
||||||
@ -2288,7 +2456,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
if (!deps.artifacts) {
|
if (!deps.artifacts) {
|
||||||
return finish({ error: "artifact storage unavailable", path: filePath });
|
return finish({ error: "artifact storage unavailable", path: filePath });
|
||||||
}
|
}
|
||||||
const storedPath = resolveStoredWorkspacePath(filePath);
|
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath);
|
||||||
let bytes: Uint8Array;
|
let bytes: Uint8Array;
|
||||||
try {
|
try {
|
||||||
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
|
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
|
||||||
@ -2337,10 +2505,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
bot.id,
|
bot.id,
|
||||||
args.cwd ? String(args.cwd) : undefined,
|
args.cwd ? String(args.cwd) : undefined,
|
||||||
);
|
);
|
||||||
if (teamBotIds) {
|
|
||||||
if (cwd) assertTeamWorkspacePathAllowed(cwd, teamBotIds);
|
|
||||||
assertTeamShellCommandAllowed(command, teamBotIds);
|
|
||||||
}
|
|
||||||
workspaceCheckpoint.markDirty();
|
workspaceCheckpoint.markDirty();
|
||||||
const result = await runSandboxCommand(
|
const result = await runSandboxCommand(
|
||||||
deps.sandbox,
|
deps.sandbox,
|
||||||
@ -2378,7 +2542,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
kind: "open",
|
kind: "open",
|
||||||
path: /^https?:\/\//i.test(requestedPath)
|
path: /^https?:\/\//i.test(requestedPath)
|
||||||
? requestedPath
|
? requestedPath
|
||||||
: resolveStoredWorkspacePath(requestedPath),
|
: resolveBotWorkspacePath(computerMode, bot.id, requestedPath),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
observe: true,
|
observe: true,
|
||||||
@ -2876,7 +3040,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
}
|
}
|
||||||
const recordedEffect = await recordEffect(deps, run, name, effectKey, args);
|
const recordedEffect = await recordEffect(deps, run, name, effectKey, args);
|
||||||
if (recordedEffect?.duplicate) {
|
if (recordedEffect?.duplicate) {
|
||||||
const gate = resolveDuplicateEffectGate(recordedEffect.effect as any, name);
|
const gate = resolveDuplicateEffectGate(recordedEffect.effect, name);
|
||||||
if (gate.action === "execute") {
|
if (gate.action === "execute") {
|
||||||
const early = await claimOrReturn("approved");
|
const early = await claimOrReturn("approved");
|
||||||
if (early !== undefined) return early;
|
if (early !== undefined) return early;
|
||||||
@ -2931,7 +3095,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const recordedForAsk = await recordEffect(deps, run, name, effectKey, args);
|
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;
|
if (missingSecretAction.action === "return") return missingSecretAction.result;
|
||||||
const connectionId = args.connectionId ? String(args.connectionId) : undefined;
|
const connectionId = args.connectionId ? String(args.connectionId) : undefined;
|
||||||
if (connectionId) {
|
if (connectionId) {
|
||||||
@ -3581,7 +3745,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
context,
|
context,
|
||||||
computer,
|
computer,
|
||||||
computerMode,
|
computerMode,
|
||||||
teamBotIds: teamBotIds ?? undefined,
|
|
||||||
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -3950,7 +4113,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
await deps.sandbox.writeFile(
|
await deps.sandbox.writeFile(
|
||||||
computer,
|
computer,
|
||||||
{
|
{
|
||||||
path: resolveStoredWorkspacePath(file.path),
|
path: resolveBotWorkspacePath(computerMode, bot.id, file.path),
|
||||||
content: new TextEncoder().encode(file.content),
|
content: new TextEncoder().encode(file.content),
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
@ -4006,7 +4169,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
const workshopFallback = workshopFallbackAfterBlockedHandoff(
|
const workshopFallback = workshopFallbackAfterBlockedHandoff(
|
||||||
proseRecovery.sanitizedText,
|
proseRecovery.sanitizedText,
|
||||||
proseRecovery.blockedUpward,
|
proseRecovery.blockedUpward,
|
||||||
proseRecovery.proseToolCalls,
|
proseRecovery.handoffFailed,
|
||||||
);
|
);
|
||||||
if (proseRecovery.proseToolCalls.length > 0) {
|
if (proseRecovery.proseToolCalls.length > 0) {
|
||||||
assembled = proseRecovery.sanitizedText;
|
assembled = proseRecovery.sanitizedText;
|
||||||
@ -4161,28 +4324,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
}
|
}
|
||||||
} catch (setupError) {
|
} catch (setupError) {
|
||||||
const computerBusy = setupError instanceof ComputerBusyError;
|
const computerBusy = setupError instanceof ComputerBusyError;
|
||||||
const retryDatabaseConnections = isTooManyDatabaseConnections(setupError);
|
const retryForever = computerBusy || isTooManyDatabaseConnections(setupError);
|
||||||
if (computerBusy) {
|
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) {
|
|
||||||
// undici collapses every network failure to "fetch failed"; the cause names the
|
// undici collapses every network failure to "fetch failed"; the cause names the
|
||||||
// host and errno, which is the only part worth paging over.
|
// host and errno, which is the only part worth paging over.
|
||||||
const causeMessage =
|
const causeMessage =
|
||||||
@ -4206,18 +4349,18 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
fence,
|
fence,
|
||||||
resumeCheckpoint,
|
resumeCheckpoint,
|
||||||
heldForTakeover,
|
heldForTakeover,
|
||||||
retryDatabaseConnections ? null : "Run setup failed; retrying",
|
retryForever ? null : "Run setup failed; retrying",
|
||||||
);
|
);
|
||||||
if (released) {
|
if (released) {
|
||||||
await deps.prisma.attempt.update({
|
await deps.prisma.attempt.update({
|
||||||
where: { id: attempt.id },
|
where: { id: attempt.id },
|
||||||
data: {
|
data: {
|
||||||
status: "setup_failed",
|
status: "setup_failed",
|
||||||
error: retryDatabaseConnections ? null : "Run setup failed; retrying",
|
error: retryForever ? null : "Run setup failed; retrying",
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (retryDatabaseConnections) {
|
if (retryForever) {
|
||||||
await deps.jobs.enqueue({
|
await deps.jobs.enqueue({
|
||||||
...runContinueJob(runId),
|
...runContinueJob(runId),
|
||||||
availableAt: new Date(Date.now() + computerRetryDelay(fence)),
|
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(
|
async function runSandboxCommand(
|
||||||
sandbox: SandboxProvider,
|
sandbox: SandboxProvider,
|
||||||
computer: ComputerRef,
|
computer: ComputerRef,
|
||||||
@ -4284,12 +4474,224 @@ async function runSandboxCommand(
|
|||||||
return { stdout, stderr, code };
|
return { stdout, stderr, code };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TURN_ATTACHMENT_UNAVAILABLE, STEERING_ATTACHMENT_UNAVAILABLE, missingTurnImagesInstruction, settleSteeringAttachmentLoads } from "./executor-attachments.js";
|
/**
|
||||||
export { createRunWorkspaceCheckpoint } from "./executor-checkpoint.js";
|
* The deployment key is a bearer credential for exactly one vendor, so it is handed out
|
||||||
export { isProtectedComputerLifecycleCommand } from "./executor-shell-guard.js";
|
* only when the provider that won the resolution above is that vendor. A provider named
|
||||||
export { type ExecutorDeps } from "./executor-types.js";
|
* by deployment settings or a bot override gets no key rather than another vendor's.
|
||||||
export { completionMessageSegments, completionNotificationBody, completionMarksUnread, threadContextForRun, subagentMarksUnread } from "./executor-completion.js";
|
*/
|
||||||
export { selectRunConnections, loadCurrentTurnImages } from "./executor-utils.js";
|
function deploymentKeyFor(deps: ExecutorDeps, provider: string): string | undefined {
|
||||||
export { PAGE_BROWSER_TOOL_NAMES, filterPageBrowserTools, selectBuiltinToolsForRun } from "./executor-tools.js";
|
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 { 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 =
|
export const INBOUND_HANDOFF_WORKSHOP_FALLBACK =
|
||||||
"Ich übernehme das direkt in meiner Spur — gleich skizziere ich erste Richtungen und stelle dir gezielte Fragen.";
|
"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 = {
|
type HandoffRun = {
|
||||||
id: string;
|
id: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -50,6 +53,7 @@ export async function tryRecoverProseHandoff(
|
|||||||
handedOff: boolean;
|
handedOff: boolean;
|
||||||
sanitizedText: string;
|
sanitizedText: string;
|
||||||
blockedUpward: boolean;
|
blockedUpward: boolean;
|
||||||
|
handoffFailed: boolean;
|
||||||
proseToolCalls: ProseToolCall[];
|
proseToolCalls: ProseToolCall[];
|
||||||
}> {
|
}> {
|
||||||
const sanitized = sanitizeBotReplyText(text);
|
const sanitized = sanitizeBotReplyText(text);
|
||||||
@ -58,12 +62,14 @@ export async function tryRecoverProseHandoff(
|
|||||||
handedOff,
|
handedOff,
|
||||||
sanitizedText: sanitized.text,
|
sanitizedText: sanitized.text,
|
||||||
blockedUpward: false,
|
blockedUpward: false,
|
||||||
|
handoffFailed: false,
|
||||||
proseToolCalls: sanitized.proseToolCalls,
|
proseToolCalls: sanitized.proseToolCalls,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextHandedOff: boolean = handedOff;
|
let nextHandedOff: boolean = handedOff;
|
||||||
let blockedUpward: boolean = false;
|
let blockedUpward = false;
|
||||||
|
let handoffFailed = false;
|
||||||
for (const call of sanitized.proseToolCalls) {
|
for (const call of sanitized.proseToolCalls) {
|
||||||
if (call.name !== "handoff_to_bot") continue;
|
if (call.name !== "handoff_to_bot") continue;
|
||||||
const args = proseHandoffArgs(call);
|
const args = proseHandoffArgs(call);
|
||||||
@ -73,11 +79,15 @@ export async function tryRecoverProseHandoff(
|
|||||||
nextHandedOff = true;
|
nextHandedOff = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ("error" in result) {
|
||||||
if (
|
if (
|
||||||
"error" in result &&
|
result.error.includes("sender") ||
|
||||||
(result.error.includes("sender") || result.error.includes("you own this handed-off stage"))
|
result.error.includes("you own this handed-off stage")
|
||||||
) {
|
) {
|
||||||
blockedUpward = true;
|
blockedUpward = true;
|
||||||
|
} else {
|
||||||
|
handoffFailed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,6 +95,7 @@ export async function tryRecoverProseHandoff(
|
|||||||
handedOff: nextHandedOff,
|
handedOff: nextHandedOff,
|
||||||
sanitizedText: sanitized.text,
|
sanitizedText: sanitized.text,
|
||||||
blockedUpward,
|
blockedUpward,
|
||||||
|
handoffFailed,
|
||||||
proseToolCalls: sanitized.proseToolCalls,
|
proseToolCalls: sanitized.proseToolCalls,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -92,11 +103,10 @@ export async function tryRecoverProseHandoff(
|
|||||||
export function workshopFallbackAfterBlockedHandoff(
|
export function workshopFallbackAfterBlockedHandoff(
|
||||||
sanitizedText: string,
|
sanitizedText: string,
|
||||||
blockedUpward: boolean,
|
blockedUpward: boolean,
|
||||||
proseToolCalls: readonly ProseToolCall[],
|
handoffFailed: boolean,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (sanitizedText.trim()) return undefined;
|
if (sanitizedText.trim()) return undefined;
|
||||||
if (!blockedUpward && !proseToolCalls.some((call) => call.name === "handoff_to_bot")) {
|
if (blockedUpward) return INBOUND_HANDOFF_WORKSHOP_FALLBACK;
|
||||||
|
if (handoffFailed) return INBOUND_HANDOFF_FAILURE_FALLBACK;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return INBOUND_HANDOFF_WORKSHOP_FALLBACK;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user