feat: autopilot that keeps working on an order until it is done (#144)
An autopilot per thread continues the user's order run after run, with no time limit. Each run reports a checkpoint; after three runs without progress or three failed runs it pauses with Continue and Stop. Start, pause, stop and finish show as quiet lines in the conversation. Approvals during an autopilot run are parked as suggestions, and the temporary approval rules end with the autopilot. Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
63578bb645
commit
460eede226
79
apps/api/src/autopilot.ts
Normal file
79
apps/api/src/autopilot.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import type { JobPublisher, SandboxProvider } from "@rakazo/adapter-kit";
|
||||
import {
|
||||
autopilotView,
|
||||
endAutopilot,
|
||||
findOpenAutopilot,
|
||||
kickAutopilot,
|
||||
resumeAutopilot,
|
||||
startAutopilot,
|
||||
} from "@rakazo/adapters";
|
||||
import type { Actor, Autopilot } from "@rakazo/contracts";
|
||||
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||
import { IsolationError } from "@rakazo/db";
|
||||
import type { ThreadTarget } from "./thread-target.js";
|
||||
import { groupDefaultBotId, stopThreadRuns } from "./thread-target.js";
|
||||
|
||||
type AutopilotDeps = {
|
||||
prisma: PrismaClient;
|
||||
events: ThreadEvents;
|
||||
jobs: JobPublisher;
|
||||
sandbox: SandboxProvider;
|
||||
};
|
||||
|
||||
function owner(actor: Actor, target: ThreadTarget) {
|
||||
const botId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
|
||||
if (!botId) throw new IsolationError();
|
||||
return { spaceId: actor.spaceId, userId: actor.userId, botId, threadId: target.threadId };
|
||||
}
|
||||
|
||||
export async function getThreadAutopilot(
|
||||
deps: Pick<AutopilotDeps, "prisma">,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
): Promise<Autopilot | null> {
|
||||
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
|
||||
return open ? autopilotView(open) : null;
|
||||
}
|
||||
|
||||
/** Start from the composer: the first run begins at once. */
|
||||
export async function startThreadAutopilot(
|
||||
deps: AutopilotDeps,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
input: { goal: string; focus?: string; until?: string },
|
||||
): Promise<Autopilot> {
|
||||
const started = await startAutopilot(deps, owner(actor, target), {
|
||||
goal: input.goal,
|
||||
focus: input.focus,
|
||||
until: input.until ? new Date(input.until) : undefined,
|
||||
});
|
||||
if ("error" in started) throw new ORPCError("CONFLICT", { message: started.error });
|
||||
await kickAutopilot(deps, started.autopilot.id);
|
||||
return autopilotView(started.autopilot);
|
||||
}
|
||||
|
||||
/** Stop at once: end the autopilot first so no run starts another, then stop what runs. */
|
||||
export async function stopThreadAutopilot(
|
||||
deps: AutopilotDeps,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
): Promise<{ ok: true }> {
|
||||
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
|
||||
if (open && (await endAutopilot(deps, open.id, { status: "stopped" }))) {
|
||||
await stopThreadRuns(deps, actor, target);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function resumeThreadAutopilot(
|
||||
deps: AutopilotDeps,
|
||||
actor: Actor,
|
||||
target: ThreadTarget,
|
||||
): Promise<{ ok: true }> {
|
||||
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
|
||||
if (open?.status !== "paused" || !(await resumeAutopilot(deps, open.id))) {
|
||||
throw new ORPCError("CONFLICT", { message: "This autopilot is not paused." });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@ -171,6 +171,12 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec
|
||||
import { createAgentSkillsService } from "./agent-skills.js";
|
||||
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
||||
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
||||
import {
|
||||
getThreadAutopilot,
|
||||
resumeThreadAutopilot,
|
||||
startThreadAutopilot,
|
||||
stopThreadAutopilot,
|
||||
} from "./autopilot.js";
|
||||
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
|
||||
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
||||
import { createCoachService } from "./coach.js";
|
||||
@ -1885,6 +1891,8 @@ export function createRouter(deps: RouterDeps) {
|
||||
}),
|
||||
stop: authed.threads.stop.handler(async ({ context, input }) => {
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
// Stopping the thread also stops its autopilot, or the next run would start right away.
|
||||
await stopThreadAutopilot(deps, context.actor, target);
|
||||
await stopThreadRuns(deps, context.actor, target);
|
||||
return { ok: true as const };
|
||||
}),
|
||||
@ -5142,6 +5150,30 @@ export function createRouter(deps: RouterDeps) {
|
||||
items: await listInbox(deps.prisma, context.actor),
|
||||
})),
|
||||
},
|
||||
autopilot: {
|
||||
get: authed.autopilot.get.handler(async ({ context, input }) => {
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
return getThreadAutopilot(deps, context.actor, target);
|
||||
}),
|
||||
start: authed.autopilot.start.handler(async ({ context, input }) => {
|
||||
if ((await modelSetup(deps, context.actor)).needsModel) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
|
||||
}
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
return startThreadAutopilot(deps, context.actor, target, input);
|
||||
}),
|
||||
stop: authed.autopilot.stop.handler(async ({ context, input }) => {
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
return stopThreadAutopilot(deps, context.actor, target);
|
||||
}),
|
||||
resume: authed.autopilot.resume.handler(async ({ context, input }) => {
|
||||
if ((await modelSetup(deps, context.actor)).needsModel) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
|
||||
}
|
||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||
return resumeThreadAutopilot(deps, context.actor, target);
|
||||
}),
|
||||
},
|
||||
voice: {
|
||||
catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()),
|
||||
status: authed.voice.status.handler(async ({ context }) => {
|
||||
|
||||
@ -64,6 +64,8 @@ import { useReducedMotion } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppConnectCard } from "../components/AppConnectCard";
|
||||
import { AskActions } from "../components/AskActions";
|
||||
import type { AutopilotAction } from "../components/autopilot-line";
|
||||
import { AutopilotLine } from "../components/autopilot-line";
|
||||
import { BotAvatar } from "../components/bot-avatar";
|
||||
import {
|
||||
MarkdownArtifactPreview,
|
||||
@ -1247,6 +1249,18 @@ function Thread() {
|
||||
[botId, groupId],
|
||||
);
|
||||
|
||||
const actOnAutopilot = useCallback(
|
||||
async (action: AutopilotAction) => {
|
||||
const targetBotId = botId;
|
||||
const targetGroupId = groupId;
|
||||
if (!targetBotId && !targetGroupId) return;
|
||||
const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! };
|
||||
await rpc(action === "stop" ? "autopilot/stop" : "autopilot/resume", target);
|
||||
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh();
|
||||
},
|
||||
[botId, groupId],
|
||||
);
|
||||
|
||||
const openBot = useCallback(
|
||||
(id: string, botName: string) =>
|
||||
router.push({ pathname: "/thread", params: { botId: id, name: botName } }),
|
||||
@ -1497,6 +1511,7 @@ function Thread() {
|
||||
canAnswer={message.id === answerableAskMessageId}
|
||||
onAnswer={answerMessage}
|
||||
onCardAct={actOnCard}
|
||||
onAutopilot={actOnAutopilot}
|
||||
onOpenBot={openBot}
|
||||
onPreviewMarkdown={setMarkdownPreview}
|
||||
actionProps={actionProps}
|
||||
@ -2372,6 +2387,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
canAnswer,
|
||||
onAnswer,
|
||||
onCardAct,
|
||||
onAutopilot,
|
||||
onOpenBot,
|
||||
onPreviewMarkdown,
|
||||
actionProps,
|
||||
@ -2386,6 +2402,7 @@ const MessageBubble = memo(function MessageBubble({
|
||||
canAnswer: boolean;
|
||||
onAnswer: (message: MobileMessage, answer: string) => Promise<void>;
|
||||
onCardAct: (message: MobileMessage, cardId: string, act: CardAct) => Promise<void>;
|
||||
onAutopilot: (action: AutopilotAction) => Promise<void>;
|
||||
onOpenBot: (botId: string, name: string) => void;
|
||||
onPreviewMarkdown: (target: MarkdownArtifactPreviewTarget) => void;
|
||||
actionProps: MessageActionProps;
|
||||
@ -2449,7 +2466,11 @@ const MessageBubble = memo(function MessageBubble({
|
||||
return (
|
||||
<View style={{ width: "100%" }}>
|
||||
{message.blocks.map((block, index) =>
|
||||
block.kind === "event" ? <RoutineEventLine key={index} block={block} /> : null,
|
||||
block.kind === "event" ? (
|
||||
<RoutineEventLine key={index} block={block} />
|
||||
) : block.kind === "autopilot" ? (
|
||||
<AutopilotLine key={index} block={block} onAction={onAutopilot} />
|
||||
) : null,
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
125
apps/mobile/components/autopilot-line.tsx
Normal file
125
apps/mobile/components/autopilot-line.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import type { AutopilotBlock } from "@rakazo/contracts";
|
||||
import { formatAutopilotDuration } from "@rakazo/core";
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { dateLocaleForUi, useI18n } from "../lib/i18n";
|
||||
import { useMobileTokens } from "../lib/native";
|
||||
import { NativeSymbol } from "./native-symbol";
|
||||
|
||||
export type AutopilotAction = "stop" | "resume";
|
||||
|
||||
const ICONS = {
|
||||
"autopilot.started": { ios: "play.fill", android: "play" },
|
||||
"autopilot.paused": { ios: "pause.fill", android: "pause" },
|
||||
"autopilot.stopped": { ios: "stop.fill", android: "stop" },
|
||||
"autopilot.finished": { ios: "checkmark.circle", android: "checkmark-circle-outline" },
|
||||
} as const;
|
||||
|
||||
/** One quiet line when the autopilot starts, pauses, stops or finishes. */
|
||||
export function AutopilotLine({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: AutopilotBlock;
|
||||
onAction?: (action: AutopilotAction) => Promise<void>;
|
||||
}) {
|
||||
const tokens = useMobileTokens();
|
||||
const { t, locale } = useI18n();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const open = !block.ended && Boolean(onAction);
|
||||
const muted = { color: tokens.mutedForeground, fontSize: 13.5 };
|
||||
|
||||
async function act(action: AutopilotAction) {
|
||||
if (!onAction) return;
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onAction(action);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let label = t("Autopilot");
|
||||
if (block.event === "autopilot.started") {
|
||||
parts.push(block.goal);
|
||||
if (block.until) {
|
||||
const until = new Intl.DateTimeFormat(dateLocaleForUi(locale), {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(block.until));
|
||||
parts.push(t("until {until}", { until }));
|
||||
}
|
||||
if (block.focus) parts.push(block.focus);
|
||||
if (block.allowed?.length) {
|
||||
parts.push(t("allowed: {allowed}", { allowed: block.allowed.join(", ") }));
|
||||
}
|
||||
} else if (block.event === "autopilot.paused") {
|
||||
label = t("Autopilot paused");
|
||||
if (block.text) parts.push(block.text);
|
||||
} else {
|
||||
label = block.event === "autopilot.stopped" ? t("Autopilot stopped") : t("Autopilot finished");
|
||||
if (block.durationMs !== undefined) parts.push(formatAutopilotDuration(block.durationMs));
|
||||
}
|
||||
const icon = ICONS[block.event];
|
||||
const link = (text: string, action: AutopilotAction) => (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
hitSlop={10}
|
||||
disabled={busy}
|
||||
onPress={() => void act(action)}
|
||||
style={{ opacity: busy ? 0.5 : 1 }}
|
||||
>
|
||||
<Text style={[muted, { fontWeight: "600" }]}>{text}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ width: "100%", paddingVertical: 4, alignItems: "center", gap: 4 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
columnGap: 6,
|
||||
rowGap: 2,
|
||||
}}
|
||||
>
|
||||
<NativeSymbol
|
||||
ios={icon.ios}
|
||||
android={icon.android}
|
||||
size={13}
|
||||
color={tokens.mutedForeground}
|
||||
/>
|
||||
<Text style={[muted, { color: tokens.foreground, fontWeight: "500" }]}>{label}</Text>
|
||||
{parts.map((part, index) => (
|
||||
<Text key={index} style={muted}>
|
||||
· {part}
|
||||
</Text>
|
||||
))}
|
||||
{open && block.event === "autopilot.paused" ? (
|
||||
<>
|
||||
<Text style={muted}>·</Text>
|
||||
{link(t("Continue"), "resume")}
|
||||
</>
|
||||
) : null}
|
||||
{open && (block.event === "autopilot.started" || block.event === "autopilot.paused") ? (
|
||||
<>
|
||||
<Text style={muted}>·</Text>
|
||||
{link(failed ? t("Try again") : t("Stop"), "stop")}
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
{block.event === "autopilot.finished" && block.text ? (
|
||||
<Text style={{ color: tokens.foreground, fontSize: 14, textAlign: "center" }}>
|
||||
{block.text}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -902,6 +902,7 @@ export function blockText(message: MobileMessage) {
|
||||
}
|
||||
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
|
||||
if (block.kind === "event") return block.name;
|
||||
if (block.kind === "autopilot") return block.text ?? block.goal;
|
||||
if (block.kind === "card") {
|
||||
const header = block.sections?.find((part) => part.type === "header");
|
||||
return header?.type === "header"
|
||||
|
||||
@ -689,4 +689,10 @@ export const RU_MESSAGES: Record<string, string> = {
|
||||
Accept: "Принять",
|
||||
Dismissed: "Отклонено",
|
||||
Expired: "Истекло",
|
||||
Autopilot: "Автопилот",
|
||||
"Autopilot finished": "Автопилот завершён",
|
||||
"Autopilot paused": "Автопилот на паузе",
|
||||
"Autopilot stopped": "Автопилот остановлен",
|
||||
"allowed: {allowed}": "разрешено: {allowed}",
|
||||
"until {until}": "до {until}",
|
||||
};
|
||||
|
||||
@ -673,4 +673,10 @@ export const ZH_MESSAGES: Record<string, string> = {
|
||||
Accept: "接受",
|
||||
Dismissed: "已忽略",
|
||||
Expired: "已过期",
|
||||
Autopilot: "自动驾驶",
|
||||
"Autopilot finished": "自动驾驶已完成",
|
||||
"Autopilot paused": "自动驾驶已暂停",
|
||||
"Autopilot stopped": "自动驾驶已停止",
|
||||
"allowed: {allowed}": "已允许:{allowed}",
|
||||
"until {until}": "直到 {until}",
|
||||
};
|
||||
|
||||
@ -8,7 +8,8 @@ export function isCenteredAgentEvent(blocks: readonly MessageBlock[]): boolean {
|
||||
block.kind === "bot_message_sent" ||
|
||||
block.kind === "bot_message_received" ||
|
||||
block.kind === "channel_message" ||
|
||||
block.kind === "event",
|
||||
block.kind === "event" ||
|
||||
block.kind === "autopilot",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
62
apps/web/e2e/autopilot.spec.ts
Normal file
62
apps/web/e2e/autopilot.spec.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers";
|
||||
|
||||
async function useAppearance(page: Page, appearance: "light" | "dark") {
|
||||
const settings = await openUserSettings(page);
|
||||
await settings.getByTestId(`ui-appearance-${appearance}`).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", appearance);
|
||||
await settings.getByRole("button", { name: "Close user settings" }).click();
|
||||
await expect(settings).toBeHidden();
|
||||
}
|
||||
|
||||
test("the autopilot works step by step until the order is done", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `autopilot-${stamp}@rakazo.test`, "password12", "Autopilot");
|
||||
await completeOnboarding(page);
|
||||
|
||||
await page.getByPlaceholder(/Message/).fill("Work through the night on the tickets.");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const start = page.locator('[data-testid="autopilot-line"][data-event="autopilot.started"]');
|
||||
await expect(start).toBeVisible({ timeout: 60_000 });
|
||||
await expect(start).toContainText("Tidy up the tickets");
|
||||
await expect(start).toContainText("Only tidy, change nothing else");
|
||||
|
||||
const finished = page.locator('[data-testid="autopilot-line"][data-event="autopilot.finished"]');
|
||||
await expect(finished).toBeVisible({ timeout: 120_000 });
|
||||
await expect(finished).toContainText("Tidied 5 tickets; nothing waits on you.");
|
||||
// Once it is over, the start line has no Stop any more.
|
||||
await expect(start.getByRole("button", { name: "Stop" })).toHaveCount(0);
|
||||
|
||||
await useAppearance(page, "light");
|
||||
await finished.scrollIntoViewIfNeeded();
|
||||
await captureScreenshot(page, testInfo, "autopilot-finished-light");
|
||||
await useAppearance(page, "dark");
|
||||
await finished.scrollIntoViewIfNeeded();
|
||||
await captureScreenshot(page, testInfo, "autopilot-finished-dark");
|
||||
});
|
||||
|
||||
test("an autopilot without progress pauses and stops on request", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `autopilot-idle-${stamp}@rakazo.test`, "password12", "Autopilot Idle");
|
||||
await completeOnboarding(page);
|
||||
|
||||
await page.getByPlaceholder(/Message/).fill("Work through the night, idle check.");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const paused = page.locator('[data-testid="autopilot-line"][data-event="autopilot.paused"]');
|
||||
await expect(paused).toBeVisible({ timeout: 120_000 });
|
||||
await expect(paused).toContainText("No progress in 3 runs");
|
||||
await expect(paused.getByRole("button", { name: "Continue" })).toBeVisible();
|
||||
await useAppearance(page, "dark");
|
||||
await paused.scrollIntoViewIfNeeded();
|
||||
await captureScreenshot(page, testInfo, "autopilot-paused-dark");
|
||||
|
||||
await paused.getByRole("button", { name: "Stop" }).click();
|
||||
const stopped = page.locator('[data-testid="autopilot-line"][data-event="autopilot.stopped"]');
|
||||
await expect(stopped).toBeVisible({ timeout: 30_000 });
|
||||
await expect(paused.getByRole("button", { name: "Continue" })).toHaveCount(0);
|
||||
await useAppearance(page, "light");
|
||||
await stopped.scrollIntoViewIfNeeded();
|
||||
await captureScreenshot(page, testInfo, "autopilot-paused-light");
|
||||
});
|
||||
@ -639,5 +639,11 @@
|
||||
"Stop timer": "Timer stoppen",
|
||||
"Accept": "Annehmen",
|
||||
"Dismissed": "Abgelehnt",
|
||||
"Expired": "Abgelaufen"
|
||||
"Expired": "Abgelaufen",
|
||||
"Autopilot": "Autopilot",
|
||||
"Autopilot paused": "Autopilot pausiert",
|
||||
"Autopilot stopped": "Autopilot gestoppt",
|
||||
"Autopilot finished": "Autopilot beendet",
|
||||
"until {until}": "bis {until}",
|
||||
"allowed: {allowed}": "erlaubt: {allowed}"
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ const runTriggers = new Set<Run["trigger"]>([
|
||||
"delegation",
|
||||
"media",
|
||||
"suggestion",
|
||||
"autopilot",
|
||||
]);
|
||||
|
||||
function runFromStartedEvent(event: ProductEvent, previous: Run | undefined): Run {
|
||||
|
||||
@ -572,6 +572,10 @@ msgstr "Schreiben erlauben"
|
||||
msgid "Allowed once"
|
||||
msgstr "Einmal erlaubt"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "allowed: {allowed}"
|
||||
msgstr "erlaubt: {allowed}"
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Already have an account?"
|
||||
msgstr "Du hast bereits ein Konto?"
|
||||
@ -760,6 +764,22 @@ msgstr "Automatische Absender"
|
||||
msgid "Automated senders will appear after they post here."
|
||||
msgstr "Automatische Absender erscheinen, sobald sie hier etwas posten."
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot"
|
||||
msgstr "Autopilot"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot finished"
|
||||
msgstr "Autopilot beendet"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot paused"
|
||||
msgstr "Autopilot pausiert"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot stopped"
|
||||
msgstr "Autopilot gestoppt"
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Available after the routine is saved"
|
||||
msgstr "Verfügbar, sobald die Routine gespeichert ist"
|
||||
@ -1415,6 +1435,7 @@ msgstr "Kontextlimit"
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Weiter"
|
||||
|
||||
@ -4801,6 +4822,7 @@ msgid "Steps"
|
||||
msgstr "Schritte"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/composer.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
|
||||
msgstr "Triage läuft…"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/routine-event-line.tsx
|
||||
msgid "Try again"
|
||||
msgstr "Erneut versuchen"
|
||||
@ -5136,6 +5159,10 @@ msgstr "Ungelesene Antwort"
|
||||
msgid "Unsupported file type: {0}"
|
||||
msgstr "Nicht unterstützter Dateityp: {0}"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "until {until}"
|
||||
msgstr "bis {until}"
|
||||
|
||||
#: src/components/DesktopUpdates.tsx
|
||||
#: src/components/SoftwareUpdateSection.tsx
|
||||
msgid "Up to date"
|
||||
|
||||
@ -572,6 +572,10 @@ msgstr "Allow writing"
|
||||
msgid "Allowed once"
|
||||
msgstr "Allowed once"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "allowed: {allowed}"
|
||||
msgstr "allowed: {allowed}"
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Already have an account?"
|
||||
msgstr "Already have an account?"
|
||||
@ -760,6 +764,22 @@ msgstr "Automated senders"
|
||||
msgid "Automated senders will appear after they post here."
|
||||
msgstr "Automated senders will appear after they post here."
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot"
|
||||
msgstr "Autopilot"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot finished"
|
||||
msgstr "Autopilot finished"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot paused"
|
||||
msgstr "Autopilot paused"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot stopped"
|
||||
msgstr "Autopilot stopped"
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Available after the routine is saved"
|
||||
msgstr "Available after the routine is saved"
|
||||
@ -1415,6 +1435,7 @@ msgstr "Context limit"
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Continue"
|
||||
|
||||
@ -4801,6 +4822,7 @@ msgid "Steps"
|
||||
msgstr "Steps"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/composer.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
|
||||
msgstr "Triage in progress…"
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/routine-event-line.tsx
|
||||
msgid "Try again"
|
||||
msgstr "Try again"
|
||||
@ -5136,6 +5159,10 @@ msgstr "Unread reply"
|
||||
msgid "Unsupported file type: {0}"
|
||||
msgstr "Unsupported file type: {0}"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "until {until}"
|
||||
msgstr "until {until}"
|
||||
|
||||
#: src/components/DesktopUpdates.tsx
|
||||
#: src/components/SoftwareUpdateSection.tsx
|
||||
msgid "Up to date"
|
||||
|
||||
@ -572,6 +572,10 @@ msgstr ""
|
||||
msgid "Allowed once"
|
||||
msgstr "Permitido una vez"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "allowed: {allowed}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Already have an account?"
|
||||
msgstr "¿Ya tienes una cuenta?"
|
||||
@ -760,6 +764,22 @@ msgstr ""
|
||||
msgid "Automated senders will appear after they post here."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot finished"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot paused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot stopped"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Available after the routine is saved"
|
||||
msgstr "Disponible después de guardar la rutina"
|
||||
@ -1415,6 +1435,7 @@ msgstr "Límite de contexto"
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Continuar"
|
||||
|
||||
@ -4801,6 +4822,7 @@ msgid "Steps"
|
||||
msgstr "Pasos"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/composer.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/routine-event-line.tsx
|
||||
msgid "Try again"
|
||||
msgstr "Intentarlo de nuevo"
|
||||
@ -5136,6 +5159,10 @@ msgstr ""
|
||||
msgid "Unsupported file type: {0}"
|
||||
msgstr "Tipo de archivo no compatible: {0}"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "until {until}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/DesktopUpdates.tsx
|
||||
#: src/components/SoftwareUpdateSection.tsx
|
||||
msgid "Up to date"
|
||||
|
||||
@ -572,6 +572,10 @@ msgstr ""
|
||||
msgid "Allowed once"
|
||||
msgstr "एक बार अनुमति दी गई"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "allowed: {allowed}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Already have an account?"
|
||||
msgstr "पहले से खाता है?"
|
||||
@ -760,6 +764,22 @@ msgstr ""
|
||||
msgid "Automated senders will appear after they post here."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot finished"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot paused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot stopped"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Available after the routine is saved"
|
||||
msgstr ""
|
||||
@ -1415,6 +1435,7 @@ msgstr "कॉन्टेक्स्ट सीमा"
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Continue"
|
||||
msgstr "जारी रखें"
|
||||
|
||||
@ -4801,6 +4822,7 @@ msgid "Steps"
|
||||
msgstr "चरण"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/composer.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/routine-event-line.tsx
|
||||
msgid "Try again"
|
||||
msgstr "फिर से कोशिश करें"
|
||||
@ -5136,6 +5159,10 @@ msgstr ""
|
||||
msgid "Unsupported file type: {0}"
|
||||
msgstr "असमर्थित फ़ाइल प्रकार: {0}"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "until {until}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/DesktopUpdates.tsx
|
||||
#: src/components/SoftwareUpdateSection.tsx
|
||||
msgid "Up to date"
|
||||
|
||||
@ -572,6 +572,10 @@ msgstr ""
|
||||
msgid "Allowed once"
|
||||
msgstr "한 번 허용됨"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "allowed: {allowed}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Already have an account?"
|
||||
msgstr "이미 계정이 있나요?"
|
||||
@ -760,6 +764,22 @@ msgstr ""
|
||||
msgid "Automated senders will appear after they post here."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot finished"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot paused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot stopped"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Available after the routine is saved"
|
||||
msgstr "루틴을 저장한 후 사용할 수 있습니다"
|
||||
@ -1415,6 +1435,7 @@ msgstr "컨텍스트 한도"
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Continue"
|
||||
msgstr "계속"
|
||||
|
||||
@ -4801,6 +4822,7 @@ msgid "Steps"
|
||||
msgstr "실행 단계"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/composer.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/routine-event-line.tsx
|
||||
msgid "Try again"
|
||||
msgstr "다시 시도"
|
||||
@ -5136,6 +5159,10 @@ msgstr ""
|
||||
msgid "Unsupported file type: {0}"
|
||||
msgstr "지원하지 않는 파일 형식: {0}"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "until {until}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/DesktopUpdates.tsx
|
||||
#: src/components/SoftwareUpdateSection.tsx
|
||||
msgid "Up to date"
|
||||
|
||||
@ -572,6 +572,10 @@ msgstr ""
|
||||
msgid "Allowed once"
|
||||
msgstr "Permitido uma vez"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "allowed: {allowed}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Already have an account?"
|
||||
msgstr "Você já tem cadastro?"
|
||||
@ -760,6 +764,22 @@ msgstr ""
|
||||
msgid "Automated senders will appear after they post here."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot finished"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot paused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot stopped"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Available after the routine is saved"
|
||||
msgstr ""
|
||||
@ -1415,6 +1435,7 @@ msgstr "Limite de contexto"
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Continuar"
|
||||
|
||||
@ -4801,6 +4822,7 @@ msgid "Steps"
|
||||
msgstr "Passos"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/composer.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/routine-event-line.tsx
|
||||
msgid "Try again"
|
||||
msgstr "Tentar novamente"
|
||||
@ -5136,6 +5159,10 @@ msgstr ""
|
||||
msgid "Unsupported file type: {0}"
|
||||
msgstr "Tipo de arquivo não suportado: {0}"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "until {until}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/DesktopUpdates.tsx
|
||||
#: src/components/SoftwareUpdateSection.tsx
|
||||
msgid "Up to date"
|
||||
|
||||
@ -572,6 +572,10 @@ msgstr ""
|
||||
msgid "Allowed once"
|
||||
msgstr "Разрешено один раз"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "allowed: {allowed}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Already have an account?"
|
||||
msgstr "У вас уже есть аккаунт?"
|
||||
@ -760,6 +764,22 @@ msgstr "Автоматические отправители"
|
||||
msgid "Automated senders will appear after they post here."
|
||||
msgstr "Автоматические отправители появятся после публикации здесь."
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot finished"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot paused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot stopped"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Available after the routine is saved"
|
||||
msgstr "Доступно после сохранения задачи"
|
||||
@ -1415,6 +1435,7 @@ msgstr ""
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Продолжить"
|
||||
|
||||
@ -4801,6 +4822,7 @@ msgid "Steps"
|
||||
msgstr "Шаги"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/composer.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/routine-event-line.tsx
|
||||
msgid "Try again"
|
||||
msgstr ""
|
||||
@ -5136,6 +5159,10 @@ msgstr ""
|
||||
msgid "Unsupported file type: {0}"
|
||||
msgstr "Неподдерживаемый тип файла: {0}."
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "until {until}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/DesktopUpdates.tsx
|
||||
#: src/components/SoftwareUpdateSection.tsx
|
||||
msgid "Up to date"
|
||||
|
||||
@ -572,6 +572,10 @@ msgstr ""
|
||||
msgid "Allowed once"
|
||||
msgstr "Bir kez izin verildi"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "allowed: {allowed}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Already have an account?"
|
||||
msgstr "Zaten hesabın var mı?"
|
||||
@ -760,6 +764,22 @@ msgstr ""
|
||||
msgid "Automated senders will appear after they post here."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot finished"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot paused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot stopped"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Available after the routine is saved"
|
||||
msgstr ""
|
||||
@ -1415,6 +1435,7 @@ msgstr "Bağlam sınırı"
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Continue"
|
||||
msgstr "Devam"
|
||||
|
||||
@ -4801,6 +4822,7 @@ msgid "Steps"
|
||||
msgstr "Adımlar"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/composer.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/routine-event-line.tsx
|
||||
msgid "Try again"
|
||||
msgstr "Tekrar dene"
|
||||
@ -5136,6 +5159,10 @@ msgstr ""
|
||||
msgid "Unsupported file type: {0}"
|
||||
msgstr "Desteklenmeyen dosya türü: {0}"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "until {until}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/DesktopUpdates.tsx
|
||||
#: src/components/SoftwareUpdateSection.tsx
|
||||
msgid "Up to date"
|
||||
|
||||
@ -572,6 +572,10 @@ msgstr ""
|
||||
msgid "Allowed once"
|
||||
msgstr "已允许一次"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "allowed: {allowed}"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Auth.tsx
|
||||
msgid "Already have an account?"
|
||||
msgstr "已有账户?"
|
||||
@ -760,6 +764,22 @@ msgstr ""
|
||||
msgid "Automated senders will appear after they post here."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot finished"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot paused"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Autopilot stopped"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/RoutineEditor.tsx
|
||||
msgid "Available after the routine is saved"
|
||||
msgstr "保存例行任务后可用"
|
||||
@ -1415,6 +1435,7 @@ msgstr "上下文限制"
|
||||
#: src/components/onboarding/OnboardingOoDesigner.tsx
|
||||
#: src/components/onboarding/OnboardingScanStep.tsx
|
||||
#: src/components/onboarding/OnboardingVoiceStep.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "Continue"
|
||||
msgstr "继续"
|
||||
|
||||
@ -4801,6 +4822,7 @@ msgid "Steps"
|
||||
msgstr "步骤"
|
||||
|
||||
#: src/components/browser/InAppBrowser.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/composer.tsx
|
||||
#: src/pages/shell/fullscreen-computer-overlay.tsx
|
||||
#: src/pages/shell/transcript.tsx
|
||||
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/Onboarding.tsx
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
#: src/pages/shell/routine-event-line.tsx
|
||||
msgid "Try again"
|
||||
msgstr "重试"
|
||||
@ -5136,6 +5159,10 @@ msgstr ""
|
||||
msgid "Unsupported file type: {0}"
|
||||
msgstr "不支持的文件类型:{0}"
|
||||
|
||||
#: src/pages/shell/autopilot-line.tsx
|
||||
msgid "until {until}"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/DesktopUpdates.tsx
|
||||
#: src/components/SoftwareUpdateSection.tsx
|
||||
msgid "Up to date"
|
||||
|
||||
@ -155,6 +155,7 @@ import {
|
||||
import type { SettingsSection } from "./SettingsOverlay";
|
||||
import { SpaceSearchResults } from "./SpaceSearch";
|
||||
import { ArchivedSection } from "./shell/archived-section";
|
||||
import type { AutopilotAction } from "./shell/autopilot-line";
|
||||
import { BotSettings, CreateBotForm } from "./shell/bot-panel";
|
||||
import { ConversationBotPicker } from "./shell/bot-picker";
|
||||
import {
|
||||
@ -1978,6 +1979,19 @@ export function ShellPage() {
|
||||
await refreshThreadRef.current(botId);
|
||||
}
|
||||
}, []);
|
||||
const actOnAutopilot = useCallback(async (action: AutopilotAction) => {
|
||||
const botId = activeBotId.current;
|
||||
const groupId = activeGroupId.current;
|
||||
if (!botId && !groupId) return;
|
||||
const target = groupId ? { groupId } : { botId: botId! };
|
||||
if (action === "stop") await rpc.autopilot.stop(target);
|
||||
else await rpc.autopilot.resume(target);
|
||||
if (groupId && activeGroupId.current === groupId) {
|
||||
await refreshGroupThreadRef.current(groupId);
|
||||
} else if (botId && activeBotId.current === botId) {
|
||||
await refreshThreadRef.current(botId);
|
||||
}
|
||||
}, []);
|
||||
const reactToMessage = useCallback(
|
||||
async (message: ThreadMessage, reaction: MessageReaction) => {
|
||||
const botId = activeBotId.current;
|
||||
@ -3330,6 +3344,7 @@ export function ShellPage() {
|
||||
onOpenGroup={openGroup}
|
||||
onAnswer={answerMessage}
|
||||
onCardAct={actOnCard}
|
||||
onAutopilot={actOnAutopilot}
|
||||
onReply={(message) => {
|
||||
setReplyTarget(message);
|
||||
setReplyQuote(null);
|
||||
|
||||
123
apps/web/src/pages/shell/autopilot-line.tsx
Normal file
123
apps/web/src/pages/shell/autopilot-line.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import type { AutopilotBlock } from "@rakazo/contracts";
|
||||
import { formatAutopilotDuration } from "@rakazo/core";
|
||||
import { Icon } from "@rakazo/ui-web";
|
||||
import { CircleCheck, Pause, Play, Square } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export type AutopilotAction = "stop" | "resume";
|
||||
|
||||
const actionClass =
|
||||
"rounded-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50";
|
||||
|
||||
/**
|
||||
* One quiet line when the autopilot starts, pauses, stops or finishes. Stop stays on the start
|
||||
* line while it runs; a pause offers Continue.
|
||||
*/
|
||||
export function AutopilotLine({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: AutopilotBlock;
|
||||
onAction?: (action: AutopilotAction) => Promise<void>;
|
||||
}) {
|
||||
const { i18n, t } = useLingui();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const open = !block.ended && Boolean(onAction);
|
||||
|
||||
async function act(action: AutopilotAction) {
|
||||
if (!onAction) return;
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onAction(action);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const until = block.until
|
||||
? new Intl.DateTimeFormat(i18n.locale || "en", { hour: "numeric", minute: "2-digit" }).format(
|
||||
new Date(block.until),
|
||||
)
|
||||
: null;
|
||||
const duration =
|
||||
block.durationMs !== undefined ? formatAutopilotDuration(block.durationMs) : null;
|
||||
const parts: string[] = [];
|
||||
let icon = Play;
|
||||
let label = t`Autopilot`;
|
||||
if (block.event === "autopilot.started") {
|
||||
parts.push(block.goal);
|
||||
if (until) parts.push(t`until ${until}`);
|
||||
if (block.focus) parts.push(block.focus);
|
||||
if (block.allowed?.length) {
|
||||
const allowed = block.allowed.join(", ");
|
||||
parts.push(t`allowed: ${allowed}`);
|
||||
}
|
||||
} else if (block.event === "autopilot.paused") {
|
||||
icon = Pause;
|
||||
label = t`Autopilot paused`;
|
||||
if (block.text) parts.push(block.text);
|
||||
} else if (block.event === "autopilot.stopped") {
|
||||
icon = Square;
|
||||
label = t`Autopilot stopped`;
|
||||
if (duration) parts.push(duration);
|
||||
} else {
|
||||
icon = CircleCheck;
|
||||
label = t`Autopilot finished`;
|
||||
if (duration) parts.push(duration);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="autopilot-line"
|
||||
data-event={block.event}
|
||||
className="flex flex-col items-center gap-1 px-4 py-1.5 text-center text-ui-label text-muted-foreground"
|
||||
>
|
||||
<p className="inline-flex max-w-full flex-wrap items-center justify-center gap-x-1.5 gap-y-0.5">
|
||||
<Icon icon={icon} size="sm" />
|
||||
<span className="font-medium text-foreground/80">{label}</span>
|
||||
{parts.map((part, index) => (
|
||||
<span key={index} className="contents">
|
||||
<span aria-hidden>·</span>
|
||||
<span dir="auto">{part}</span>
|
||||
</span>
|
||||
))}
|
||||
{open && block.event === "autopilot.paused" ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className={actionClass}
|
||||
disabled={busy}
|
||||
onClick={() => void act("resume")}
|
||||
>
|
||||
{t`Continue`}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{open && (block.event === "autopilot.started" || block.event === "autopilot.paused") ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className={actionClass}
|
||||
disabled={busy}
|
||||
onClick={() => void act("stop")}
|
||||
>
|
||||
{failed ? t`Try again` : t`Stop`}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
{block.event === "autopilot.finished" && block.text ? (
|
||||
<p dir="auto" className="max-w-xl text-ui-body-sm text-foreground/80">
|
||||
{block.text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -44,6 +44,8 @@ import { messageProviderLabel } from "../../lib/messaging";
|
||||
import { withIntakeNoteText } from "../../lib/onboarding-copy";
|
||||
import { quoteDraftForSelection } from "../../lib/quote-selection";
|
||||
import { transcriptIsNearEnd, transcriptMovedDown } from "../../lib/transcript-scroll";
|
||||
import type { AutopilotAction } from "./autopilot-line";
|
||||
import { AutopilotLine } from "./autopilot-line";
|
||||
import {
|
||||
AppConnectCard,
|
||||
ArtifactImage,
|
||||
@ -70,6 +72,7 @@ export const Transcript = memo(function Transcript({
|
||||
onOpenGroup,
|
||||
onAnswer,
|
||||
onCardAct,
|
||||
onAutopilot,
|
||||
onReply,
|
||||
onQuote,
|
||||
onReact,
|
||||
@ -101,6 +104,7 @@ export const Transcript = memo(function Transcript({
|
||||
onOpenGroup: (groupId: string) => void;
|
||||
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
||||
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
|
||||
onAutopilot?: (action: AutopilotAction) => Promise<void>;
|
||||
onReply: (message: ThreadMessage) => void;
|
||||
onQuote: (message: ThreadMessage, quote: string) => void;
|
||||
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
|
||||
@ -380,7 +384,11 @@ export const Transcript = memo(function Transcript({
|
||||
return (
|
||||
<div key={message.id} data-message-id={message.id}>
|
||||
{message.blocks.map((block, index) =>
|
||||
block.kind === "event" ? <RoutineEventLine key={index} block={block} /> : null,
|
||||
block.kind === "event" ? (
|
||||
<RoutineEventLine key={index} block={block} />
|
||||
) : block.kind === "autopilot" ? (
|
||||
<AutopilotLine key={index} block={block} onAction={onAutopilot} />
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
193
packages/adapters/src/autopilot.postgres.test.ts
Normal file
193
packages/adapters/src/autopilot.postgres.test.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AutopilotBlock, MessageBlock } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
checkpointAutopilot,
|
||||
continueAutopilotAfterRun,
|
||||
endAutopilot,
|
||||
findOpenAutopilot,
|
||||
resumeAutopilot,
|
||||
startAutopilot,
|
||||
} from "./autopilot.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const describePostgres =
|
||||
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||
|
||||
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
|
||||
|
||||
describePostgres("autopilot (PostgreSQL)", () => {
|
||||
let prisma: PrismaClient;
|
||||
let close: () => Promise<void>;
|
||||
const userIds: string[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
const db = createDb(databaseUrl!);
|
||||
prisma = db.prisma;
|
||||
close = async () => {
|
||||
await db.prisma.$disconnect();
|
||||
await db.pool.end();
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!prisma) return;
|
||||
const memberships = await prisma.member.findMany({
|
||||
where: { userId: { in: userIds } },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
await prisma.organization.deleteMany({
|
||||
where: { id: { in: memberships.map((member) => member.organizationId) } },
|
||||
});
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await close();
|
||||
});
|
||||
|
||||
async function newOwner() {
|
||||
const id = `ap${randomUUID().replaceAll("-", "")}`;
|
||||
userIds.push(id);
|
||||
await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
|
||||
await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false });
|
||||
const conversation = await ensureMainConversation(prisma, id);
|
||||
const enqueue = vi.fn(async () => undefined);
|
||||
const deps = {
|
||||
prisma,
|
||||
events: { notify: vi.fn(async () => undefined) },
|
||||
jobs: { enqueue },
|
||||
};
|
||||
const owner = {
|
||||
spaceId: conversation.spaceId,
|
||||
userId: id,
|
||||
botId: conversation.leadBotId,
|
||||
threadId: conversation.threadId,
|
||||
};
|
||||
return { deps, owner, enqueue };
|
||||
}
|
||||
|
||||
async function lines(threadId: string) {
|
||||
const messages = await prisma.message.findMany({
|
||||
where: { threadId },
|
||||
orderBy: { seq: "asc" },
|
||||
select: { blocks: true },
|
||||
});
|
||||
return messages.flatMap((message) =>
|
||||
(message.blocks as MessageBlock[]).filter(
|
||||
(block): block is AutopilotBlock => block.kind === "autopilot",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function finishQueuedRuns(threadId: string) {
|
||||
await prisma.run.updateMany({
|
||||
where: { threadId, status: "queued" },
|
||||
data: { status: "completed" },
|
||||
});
|
||||
}
|
||||
|
||||
it("works step by step until it finishes, then takes its approvals away", async () => {
|
||||
const { deps, owner, enqueue } = await newOwner();
|
||||
const started = await startAutopilot(deps, owner, {
|
||||
goal: "Tidy up the tickets",
|
||||
allow: ["github_commit", "create_space"],
|
||||
});
|
||||
if (!("ok" in started)) throw new Error(started.error);
|
||||
const rules = await prisma.actionApprovalRule.findMany({
|
||||
where: { autopilotId: started.autopilot.id },
|
||||
});
|
||||
// A security-boundary tool is never granted in advance.
|
||||
expect(rules.map((rule) => rule.matchValue)).toEqual(["github_commit"]);
|
||||
|
||||
for (let step = 1; step <= 4; step += 1) {
|
||||
await checkpointAutopilot(prisma, owner, { summary: `Ticket ${step} tidied` });
|
||||
await continueAutopilotAfterRun(deps, owner, { checkpointed: true, failed: false });
|
||||
await finishQueuedRuns(owner.threadId);
|
||||
}
|
||||
const runs = await prisma.run.findMany({
|
||||
where: { threadId: owner.threadId, trigger: "autopilot" },
|
||||
include: { task: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
expect(runs).toHaveLength(4);
|
||||
expect(runs.map((run) => run.clientNonce)).toEqual(
|
||||
[1, 2, 3, 4].map((n) => `autopilot:${started.autopilot.id}:${n}`),
|
||||
);
|
||||
expect(runs[3]?.task.prompt).toContain("Last checkpoint: Ticket 4 tidied");
|
||||
expect(enqueue).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(
|
||||
await endAutopilot(deps, started.autopilot.id, { status: "finished", result: "Done." }),
|
||||
).toBe(true);
|
||||
const row = await prisma.autopilot.findUniqueOrThrow({ where: { id: started.autopilot.id } });
|
||||
expect(row).toMatchObject({ status: "finished", stepCount: 4, result: "Done." });
|
||||
expect(
|
||||
await prisma.actionApprovalRule.count({ where: { autopilotId: started.autopilot.id } }),
|
||||
).toBe(0);
|
||||
const timeline = await lines(owner.threadId);
|
||||
expect(timeline.map((line) => [line.event, Boolean(line.ended)])).toEqual([
|
||||
["autopilot.started", true],
|
||||
["autopilot.finished", false],
|
||||
]);
|
||||
// Nothing runs after the end.
|
||||
await continueAutopilotAfterRun(deps, owner, { checkpointed: false, failed: false });
|
||||
expect(enqueue).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("pauses after three runs without progress and goes on when the user continues", async () => {
|
||||
const { deps, owner, enqueue } = await newOwner();
|
||||
const started = await startAutopilot(deps, owner, { goal: "Hunt bugs" });
|
||||
if (!("ok" in started)) throw new Error(started.error);
|
||||
for (let run = 0; run < 3; run += 1) {
|
||||
await continueAutopilotAfterRun(deps, owner, { checkpointed: false, failed: false });
|
||||
await finishQueuedRuns(owner.threadId);
|
||||
}
|
||||
expect(enqueue).toHaveBeenCalledTimes(2);
|
||||
const paused = await findOpenAutopilot(prisma, owner);
|
||||
expect(paused).toMatchObject({ status: "paused" });
|
||||
expect(paused?.pauseReason).toContain("No progress in 3 runs");
|
||||
expect((await lines(owner.threadId)).at(-1)).toMatchObject({ event: "autopilot.paused" });
|
||||
|
||||
expect(await resumeAutopilot(deps, paused!.id)).toBe(true);
|
||||
expect(enqueue).toHaveBeenCalledTimes(3);
|
||||
expect(await findOpenAutopilot(prisma, owner)).toMatchObject({
|
||||
status: "active",
|
||||
idleCount: 0,
|
||||
});
|
||||
expect((await lines(owner.threadId)).at(-1)).toMatchObject({
|
||||
event: "autopilot.paused",
|
||||
ended: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs one autopilot per space, ends at its time and stops at once", async () => {
|
||||
const { deps, owner, enqueue } = await newOwner();
|
||||
const first = await startAutopilot(deps, owner, {
|
||||
goal: "Work through the night",
|
||||
until: new Date(Date.now() + 60_000),
|
||||
});
|
||||
if (!("ok" in first)) throw new Error(first.error);
|
||||
const elsewhere = await prisma.thread.findFirstOrThrow({
|
||||
where: { spaceId: owner.spaceId, id: { not: owner.threadId } },
|
||||
});
|
||||
expect(
|
||||
await startAutopilot(deps, { ...owner, threadId: elsewhere.id }, { goal: "Second" }),
|
||||
).toEqual({ error: expect.stringContaining("Stop it first") });
|
||||
|
||||
await prisma.autopilot.update({
|
||||
where: { id: first.autopilot.id },
|
||||
data: { until: new Date(Date.now() - 1000), lastCheckpoint: "Half the tickets" },
|
||||
});
|
||||
await continueAutopilotAfterRun(deps, owner, { checkpointed: true, failed: false });
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await prisma.autopilot.findUniqueOrThrow({ where: { id: first.autopilot.id } }),
|
||||
).toMatchObject({ status: "finished", result: "Half the tickets" });
|
||||
|
||||
const second = await startAutopilot(deps, owner, { goal: "Again" });
|
||||
if (!("ok" in second)) throw new Error(second.error);
|
||||
expect(await endAutopilot(deps, second.autopilot.id, { status: "stopped" })).toBe(true);
|
||||
expect(await endAutopilot(deps, second.autopilot.id, { status: "stopped" })).toBe(false);
|
||||
expect((await lines(owner.threadId)).at(-1)).toMatchObject({ event: "autopilot.stopped" });
|
||||
});
|
||||
});
|
||||
416
packages/adapters/src/autopilot.ts
Normal file
416
packages/adapters/src/autopilot.ts
Normal file
@ -0,0 +1,416 @@
|
||||
import type { JobPublisher } from "@rakazo/adapter-kit";
|
||||
import { runContinueJob } from "@rakazo/adapter-kit";
|
||||
import type { AutopilotBlock, Autopilot as AutopilotView } from "@rakazo/contracts";
|
||||
import { AUTOPILOT_LIMITS } from "@rakazo/contracts";
|
||||
import {
|
||||
ACTIVE_RUN_STATUSES,
|
||||
advanceAutopilotGuard,
|
||||
autopilotContinuationPrompt,
|
||||
autopilotExpired,
|
||||
toolRequiresExplicitApproval,
|
||||
} from "@rakazo/core";
|
||||
import type { Prisma, PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||
import {
|
||||
appendEventInTransaction,
|
||||
endAutopilotLinesInTransaction,
|
||||
postAutopilotLineInTransaction,
|
||||
} from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
|
||||
type AutopilotDeps = {
|
||||
prisma: PrismaClient;
|
||||
events: Pick<ThreadEvents, "notify">;
|
||||
jobs: Pick<JobPublisher, "enqueue">;
|
||||
};
|
||||
|
||||
type AutopilotRow = NonNullable<Awaited<ReturnType<PrismaClient["autopilot"]["findFirst"]>>>;
|
||||
|
||||
/** Who the autopilot works for and where. */
|
||||
export interface AutopilotOwner {
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
botId: string;
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
const OPEN = ["active", "paused"];
|
||||
|
||||
function clip(value: string, max: number) {
|
||||
const flat = value.replace(/\s+/g, " ").trim();
|
||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
||||
}
|
||||
|
||||
export function autopilotView(row: AutopilotRow): AutopilotView {
|
||||
return {
|
||||
id: row.id,
|
||||
botId: row.botId,
|
||||
threadId: row.threadId,
|
||||
goal: row.goal,
|
||||
focus: row.focus,
|
||||
until: row.until?.toISOString() ?? null,
|
||||
status: row.status as AutopilotView["status"],
|
||||
pauseReason: row.pauseReason,
|
||||
lastCheckpoint: row.lastCheckpoint,
|
||||
nextStep: row.nextStep,
|
||||
result: row.result,
|
||||
stepCount: row.stepCount,
|
||||
startedAt: row.startedAt.toISOString(),
|
||||
endedAt: row.endedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function notify(events: AutopilotDeps["events"], threadId: string, seq: number | null) {
|
||||
if (seq === null) return;
|
||||
await events.notify(threadId, seq).catch((error) => {
|
||||
getLogger().error("autopilot realtime notification", error);
|
||||
});
|
||||
}
|
||||
|
||||
async function lockAutopilot(tx: Prisma.TransactionClient, id: string) {
|
||||
await tx.$queryRaw`SELECT id FROM autopilots WHERE id = ${id} FOR UPDATE`;
|
||||
return tx.autopilot.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
/** The thread's open autopilot for this user, active or paused. */
|
||||
export function findOpenAutopilot(
|
||||
prisma: PrismaClient | Prisma.TransactionClient,
|
||||
owner: Pick<AutopilotOwner, "userId" | "threadId">,
|
||||
) {
|
||||
return prisma.autopilot.findFirst({
|
||||
where: { threadId: owner.threadId, userId: owner.userId, status: { in: OPEN } },
|
||||
orderBy: { startedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an autopilot, or give the open one in this thread a new order. One per user and space:
|
||||
* an open autopilot in another thread must be stopped first. Approvals granted for it become
|
||||
* rules that end with it.
|
||||
*/
|
||||
export async function startAutopilot(
|
||||
deps: AutopilotDeps,
|
||||
owner: AutopilotOwner,
|
||||
input: { goal: string; focus?: string; until?: Date; allow?: string[] },
|
||||
): Promise<{ ok: true; autopilot: AutopilotRow } | { error: string }> {
|
||||
const goal = clip(input.goal, AUTOPILOT_LIMITS.goal);
|
||||
if (!goal) return { error: "goal is required." };
|
||||
const focus = input.focus?.trim() ? clip(input.focus, AUTOPILOT_LIMITS.focus) : null;
|
||||
if (input.until && input.until.getTime() <= Date.now()) {
|
||||
return { error: "until must be in the future." };
|
||||
}
|
||||
const allow = [...new Set((input.allow ?? []).map((name) => name.trim()).filter(Boolean))]
|
||||
.filter((name) => !toolRequiresExplicitApproval(name))
|
||||
.slice(0, 20);
|
||||
const result = await deps.prisma.$transaction(async (tx) => {
|
||||
// One open autopilot per user and space: serialize starts on the user's space row.
|
||||
await tx.$queryRaw`SELECT id FROM spaces WHERE id = ${owner.spaceId} FOR UPDATE`;
|
||||
const open = await tx.autopilot.findFirst({
|
||||
where: { spaceId: owner.spaceId, userId: owner.userId, status: { in: OPEN } },
|
||||
});
|
||||
if (open && open.threadId !== owner.threadId) {
|
||||
return { error: "Another autopilot is already running in this space. Stop it first." };
|
||||
}
|
||||
const data = {
|
||||
goal,
|
||||
focus,
|
||||
until: input.until ?? null,
|
||||
status: "active",
|
||||
pauseReason: null,
|
||||
idleCount: 0,
|
||||
failCount: 0,
|
||||
};
|
||||
const autopilot = open
|
||||
? await tx.autopilot.update({ where: { id: open.id }, data: { ...data, botId: owner.botId } })
|
||||
: await tx.autopilot.create({ data: { ...owner, ...data } });
|
||||
if (open) {
|
||||
await tx.actionApprovalRule.deleteMany({ where: { autopilotId: open.id } });
|
||||
await endAutopilotLinesInTransaction(tx, { ...owner, autopilotId: open.id });
|
||||
}
|
||||
if (allow.length) {
|
||||
await tx.actionApprovalRule.createMany({
|
||||
data: allow.map((name) => ({
|
||||
spaceId: owner.spaceId,
|
||||
createdByUserId: owner.userId,
|
||||
effect: "always_allow",
|
||||
matchKind: "tool",
|
||||
matchValue: name,
|
||||
autopilotId: autopilot.id,
|
||||
})),
|
||||
// A standing rule of the user's own stays theirs and outlives the autopilot.
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
const line = await postAutopilotLineInTransaction(tx, {
|
||||
...owner,
|
||||
block: {
|
||||
kind: "autopilot",
|
||||
event: "autopilot.started",
|
||||
autopilotId: autopilot.id,
|
||||
goal,
|
||||
...(focus ? { focus } : {}),
|
||||
...(input.until ? { until: input.until.toISOString() } : {}),
|
||||
...(allow.length ? { allowed: allow } : {}),
|
||||
},
|
||||
});
|
||||
const updated = await tx.autopilot.update({
|
||||
where: { id: autopilot.id },
|
||||
data: { startMessageId: line.messageId },
|
||||
});
|
||||
return { ok: true as const, autopilot: updated, seq: line.seq };
|
||||
});
|
||||
if ("error" in result) return result;
|
||||
await notify(deps.events, owner.threadId, result.seq);
|
||||
return { ok: true, autopilot: result.autopilot };
|
||||
}
|
||||
|
||||
/** A step is done: remember where the work stands. */
|
||||
export async function checkpointAutopilot(
|
||||
prisma: PrismaClient,
|
||||
owner: Pick<AutopilotOwner, "userId" | "threadId">,
|
||||
input: { summary: string; next?: string },
|
||||
): Promise<{ ok: true; steps: number } | { error: string }> {
|
||||
const open = await findOpenAutopilot(prisma, owner);
|
||||
if (open?.status !== "active") return { error: "No autopilot is running in this thread." };
|
||||
const updated = await prisma.autopilot.update({
|
||||
where: { id: open.id },
|
||||
data: {
|
||||
stepCount: { increment: 1 },
|
||||
idleCount: 0,
|
||||
lastCheckpoint: clip(input.summary, AUTOPILOT_LIMITS.checkpoint),
|
||||
nextStep: input.next?.trim() ? clip(input.next, AUTOPILOT_LIMITS.checkpoint) : null,
|
||||
},
|
||||
});
|
||||
return { ok: true, steps: updated.stepCount };
|
||||
}
|
||||
|
||||
/** End the autopilot: finished with a result, or stopped by the user. */
|
||||
export async function endAutopilot(
|
||||
deps: AutopilotDeps,
|
||||
autopilotId: string,
|
||||
outcome: { status: "finished"; result: string } | { status: "stopped" },
|
||||
): Promise<boolean> {
|
||||
const ended = await deps.prisma.$transaction(async (tx) => {
|
||||
const autopilot = await lockAutopilot(tx, autopilotId);
|
||||
if (!autopilot || !OPEN.includes(autopilot.status)) return null;
|
||||
const endedAt = new Date();
|
||||
const result =
|
||||
outcome.status === "finished" ? clip(outcome.result, AUTOPILOT_LIMITS.result) : null;
|
||||
await tx.autopilot.update({
|
||||
where: { id: autopilot.id },
|
||||
data: { status: outcome.status, endedAt, result, pauseReason: null },
|
||||
});
|
||||
await tx.actionApprovalRule.deleteMany({ where: { autopilotId: autopilot.id } });
|
||||
await endAutopilotLinesInTransaction(tx, { ...autopilot, autopilotId: autopilot.id });
|
||||
const block: AutopilotBlock = {
|
||||
kind: "autopilot",
|
||||
event: outcome.status === "finished" ? "autopilot.finished" : "autopilot.stopped",
|
||||
autopilotId: autopilot.id,
|
||||
goal: autopilot.goal,
|
||||
steps: autopilot.stepCount,
|
||||
durationMs: endedAt.getTime() - autopilot.startedAt.getTime(),
|
||||
...(result ? { text: result } : {}),
|
||||
};
|
||||
const line = await postAutopilotLineInTransaction(tx, { ...autopilot, block });
|
||||
return { threadId: autopilot.threadId, seq: line.seq };
|
||||
});
|
||||
if (!ended) return false;
|
||||
await notify(deps.events, ended.threadId, ended.seq);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Hold the autopilot and say why; the user continues or stops it from the line. */
|
||||
async function pauseAutopilotInTransaction(
|
||||
tx: Prisma.TransactionClient,
|
||||
autopilot: AutopilotRow,
|
||||
reason: string,
|
||||
) {
|
||||
await tx.autopilot.update({
|
||||
where: { id: autopilot.id },
|
||||
data: { status: "paused", pauseReason: reason },
|
||||
});
|
||||
return postAutopilotLineInTransaction(tx, {
|
||||
...autopilot,
|
||||
block: {
|
||||
kind: "autopilot",
|
||||
event: "autopilot.paused",
|
||||
autopilotId: autopilot.id,
|
||||
goal: autopilot.goal,
|
||||
steps: autopilot.stepCount,
|
||||
text: reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Let a paused autopilot go on, with a fresh idle count, and start its next run. */
|
||||
export async function resumeAutopilot(deps: AutopilotDeps, autopilotId: string): Promise<boolean> {
|
||||
const resumed = await deps.prisma.$transaction(async (tx) => {
|
||||
const autopilot = await lockAutopilot(tx, autopilotId);
|
||||
if (autopilot?.status !== "paused") return null;
|
||||
const seq = await endPausedLines(tx, autopilot);
|
||||
const active = await tx.autopilot.update({
|
||||
where: { id: autopilot.id },
|
||||
data: { status: "active", pauseReason: null, idleCount: 0, failCount: 0 },
|
||||
});
|
||||
const runId = await createContinuationRun(tx, active);
|
||||
return { threadId: autopilot.threadId, seq, runId };
|
||||
});
|
||||
if (!resumed) return false;
|
||||
await notify(deps.events, resumed.threadId, resumed.seq);
|
||||
await enqueue(deps, resumed.runId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Only the pause lines lose their Continue control; the start line keeps its Stop. */
|
||||
async function endPausedLines(tx: Prisma.TransactionClient, autopilot: AutopilotRow) {
|
||||
const messages = await tx.message.findMany({
|
||||
where: {
|
||||
threadId: autopilot.threadId,
|
||||
blocks: {
|
||||
array_contains: [
|
||||
{ kind: "autopilot", autopilotId: autopilot.id, event: "autopilot.paused" },
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { id: true, role: true, botId: true, blocks: true },
|
||||
});
|
||||
let seq: number | null = null;
|
||||
for (const message of messages) {
|
||||
const blocks = (message.blocks as AutopilotBlock[]).map((block) =>
|
||||
block.kind === "autopilot" && block.event === "autopilot.paused" && !block.ended
|
||||
? { ...block, ended: true }
|
||||
: block,
|
||||
);
|
||||
await tx.message.update({ where: { id: message.id }, data: { blocks } });
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: autopilot.spaceId,
|
||||
threadId: autopilot.threadId,
|
||||
botId: message.botId ?? autopilot.botId,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId: message.id, role: message.role, blocks },
|
||||
});
|
||||
seq = event.seq;
|
||||
}
|
||||
return seq;
|
||||
}
|
||||
|
||||
async function enqueue(deps: AutopilotDeps, runId: string | null) {
|
||||
if (!runId) return;
|
||||
await deps.jobs.enqueue(runContinueJob(runId)).catch((error) => {
|
||||
getLogger().error("autopilot continuation enqueue", error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The next run of an active autopilot, unless the bot is already busy in the thread. Each
|
||||
* continuation has its own nonce, so two workers finishing at once start it only once.
|
||||
*/
|
||||
async function createContinuationRun(
|
||||
tx: Prisma.TransactionClient,
|
||||
autopilot: AutopilotRow,
|
||||
): Promise<string | null> {
|
||||
const busy = await tx.run.findFirst({
|
||||
where: {
|
||||
threadId: autopilot.threadId,
|
||||
botId: autopilot.botId,
|
||||
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (busy) return null;
|
||||
const continuation = autopilot.continuations + 1;
|
||||
const claimed = await tx.autopilot.updateMany({
|
||||
where: { id: autopilot.id, continuations: autopilot.continuations },
|
||||
data: { continuations: continuation },
|
||||
});
|
||||
if (claimed.count === 0) return null;
|
||||
const prompt = autopilotContinuationPrompt(autopilot);
|
||||
const task = await tx.task.create({
|
||||
data: {
|
||||
spaceId: autopilot.spaceId,
|
||||
botId: autopilot.botId,
|
||||
threadId: autopilot.threadId,
|
||||
userId: autopilot.userId,
|
||||
prompt,
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
const run = await tx.run.create({
|
||||
data: {
|
||||
spaceId: autopilot.spaceId,
|
||||
botId: autopilot.botId,
|
||||
threadId: autopilot.threadId,
|
||||
taskId: task.id,
|
||||
userId: autopilot.userId,
|
||||
status: "queued",
|
||||
trigger: "autopilot",
|
||||
clientNonce: `autopilot:${autopilot.id}:${continuation}`,
|
||||
},
|
||||
});
|
||||
return run.id;
|
||||
}
|
||||
|
||||
/** Start the next run of a just-started autopilot when nothing in the thread runs yet. */
|
||||
export async function kickAutopilot(deps: AutopilotDeps, autopilotId: string): Promise<void> {
|
||||
const runId = await deps.prisma.$transaction(async (tx) => {
|
||||
const autopilot = await lockAutopilot(tx, autopilotId);
|
||||
return autopilot?.status === "active" ? createContinuationRun(tx, autopilot) : null;
|
||||
});
|
||||
await enqueue(deps, runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a run of the autopilot's bot ends in its thread: finish at the chosen time, pause when it
|
||||
* stopped making progress or keeps failing, and otherwise start the next run.
|
||||
*/
|
||||
export async function continueAutopilotAfterRun(
|
||||
deps: AutopilotDeps,
|
||||
run: { userId: string; threadId: string; botId: string },
|
||||
outcome: { checkpointed: boolean; failed: boolean; error?: string },
|
||||
): Promise<void> {
|
||||
const open = await deps.prisma.autopilot.findFirst({
|
||||
where: {
|
||||
threadId: run.threadId,
|
||||
userId: run.userId,
|
||||
botId: run.botId,
|
||||
status: "active",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!open) return;
|
||||
const next = await deps.prisma.$transaction(async (tx) => {
|
||||
const autopilot = await lockAutopilot(tx, open.id);
|
||||
if (autopilot?.status !== "active") return null;
|
||||
if (autopilotExpired(autopilot.until)) {
|
||||
return { expired: { id: autopilot.id, lastCheckpoint: autopilot.lastCheckpoint } };
|
||||
}
|
||||
const guard = advanceAutopilotGuard(autopilot, outcome);
|
||||
if (guard.pause) {
|
||||
const reason =
|
||||
guard.pause === "failing"
|
||||
? `Runs keep failing${outcome.error ? `: ${clip(outcome.error, 200)}` : "."}`
|
||||
: `No progress in ${guard.idleCount} runs${autopilot.lastCheckpoint ? `. Last: ${autopilot.lastCheckpoint}` : "."}`;
|
||||
const line = await pauseAutopilotInTransaction(tx, autopilot, reason);
|
||||
return { threadId: autopilot.threadId, seq: line.seq, runId: null };
|
||||
}
|
||||
const updated = await tx.autopilot.update({
|
||||
where: { id: autopilot.id },
|
||||
data: { idleCount: guard.idleCount, failCount: guard.failCount },
|
||||
});
|
||||
return {
|
||||
threadId: autopilot.threadId,
|
||||
seq: null,
|
||||
runId: await createContinuationRun(tx, updated),
|
||||
};
|
||||
});
|
||||
if (!next) return;
|
||||
if ("expired" in next && next.expired) {
|
||||
const autopilot = next.expired;
|
||||
await endAutopilot(deps, autopilot.id, {
|
||||
status: "finished",
|
||||
result: autopilot.lastCheckpoint ?? "Time is up.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await notify(deps.events, next.threadId, next.seq);
|
||||
await enqueue(deps, next.runId);
|
||||
}
|
||||
@ -367,6 +367,67 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
required: ["tool", "args"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autopilot_start",
|
||||
description:
|
||||
"Keep working on the user's order on your own, run after run, with no time limit: when the user asks you to work through the night, keep going until something is done, or only hunt bugs for a while. Starts at once and shows a line with Stop in the conversation. Calling it again in this thread replaces the order.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
goal: { type: "string", maxLength: 500, description: "The order in the user's words." },
|
||||
focus: {
|
||||
type: "string",
|
||||
maxLength: 200,
|
||||
description: 'An optional limit the user set, such as "only find bugs, change nothing".',
|
||||
},
|
||||
until: {
|
||||
type: "string",
|
||||
description: "ISO datetime to stop at. Omit to work until the order is done.",
|
||||
},
|
||||
allow: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Tool names the user explicitly allowed without asking while this runs, such as a commit tool. Only what the user said; the rest is parked for their approval.",
|
||||
},
|
||||
},
|
||||
required: ["goal"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autopilot_checkpoint",
|
||||
description:
|
||||
"Report a finished step of the autopilot: what changed, and what comes next. Call it after every step; without it the autopilot pauses.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
summary: { type: "string", maxLength: 300, description: "What this step achieved." },
|
||||
next: { type: "string", maxLength: 300, description: "The next step you plan." },
|
||||
},
|
||||
required: ["summary"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autopilot_finish",
|
||||
description: "End the autopilot because the order is done, with the result for the user.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: {
|
||||
type: "string",
|
||||
maxLength: 2000,
|
||||
description: "What was done, what is left, and what waits on the user.",
|
||||
},
|
||||
},
|
||||
required: ["result"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "autopilot_stop",
|
||||
description:
|
||||
'Stop the autopilot in this thread when the user asks you to ("stop", "that\'s enough").',
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "message_user",
|
||||
description:
|
||||
|
||||
@ -147,6 +147,7 @@ export function selectBuiltinToolsForRun(options: {
|
||||
"show_card",
|
||||
"suggest",
|
||||
].includes(tool.name) &&
|
||||
!tool.name.startsWith("autopilot_") &&
|
||||
!USER_MODEL_TOOLS.has(tool.name) &&
|
||||
!tool.name.startsWith("scratchpad_"))),
|
||||
);
|
||||
|
||||
@ -156,6 +156,13 @@ import {
|
||||
resolveAutoReviewChecker,
|
||||
runAutoReviewJudge,
|
||||
} from "./auto-review.js";
|
||||
import {
|
||||
checkpointAutopilot,
|
||||
continueAutopilotAfterRun,
|
||||
endAutopilot,
|
||||
findOpenAutopilot,
|
||||
startAutopilot,
|
||||
} from "./autopilot.js";
|
||||
import { loadBotMessageContext, messageBot, returnBotMessageOutcome } from "./bot-messages.js";
|
||||
import {
|
||||
findBotSecret,
|
||||
@ -1363,6 +1370,12 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
await deps.jobs
|
||||
.enqueue(runContinueJob(failed.continuationRunId))
|
||||
.catch((error) => getLogger().error("steering continuation enqueue", error));
|
||||
} else {
|
||||
await continueAutopilotAfterRun(deps, run, {
|
||||
checkpointed: false,
|
||||
failed: true,
|
||||
error: MISSING_MODEL_MESSAGE,
|
||||
}).catch((continueError) => getLogger().error("autopilot continuation", continueError));
|
||||
}
|
||||
if (run.trigger === "bot_message") {
|
||||
await returnBotMessageOutcome(
|
||||
@ -1595,6 +1608,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
// Rehydrate from this run's prior progress rows so a resume after ask/takeover
|
||||
// still knows progress was already published (skip hollow finals; status outcome).
|
||||
let publishedMidTurnUserMessage = false;
|
||||
// An autopilot run that reported a step; without one the autopilot counts as idle.
|
||||
let autopilotCheckpointed = false;
|
||||
const midTurnUserTexts: string[] = [];
|
||||
let midTurnProgressCount = 0;
|
||||
{
|
||||
@ -2142,6 +2157,38 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
};
|
||||
|
||||
const requestApproval = async () => {
|
||||
// Nobody is there to approve during an autopilot: park the exact call as a
|
||||
// suggestion for the user and keep working on other things.
|
||||
if (run.trigger === "autopilot" && applied && !requiresMandatoryApproval) {
|
||||
const parked = await createSuggestionFromTool(deps, {
|
||||
run: { ...run, threadId: thread.id },
|
||||
bot,
|
||||
executionId,
|
||||
exposedToolNames,
|
||||
args: JSON.parse(
|
||||
redactSecrets(
|
||||
JSON.stringify({
|
||||
tool: name,
|
||||
args,
|
||||
why: reviewReason ?? "Needs your approval",
|
||||
}),
|
||||
runSecrets,
|
||||
),
|
||||
) as Record<string, unknown>,
|
||||
});
|
||||
const result =
|
||||
"ok" in parked
|
||||
? {
|
||||
parked: true,
|
||||
note: "This needs the user's approval and waits for them as a suggestion. Do not retry it; continue with other work.",
|
||||
}
|
||||
: parked;
|
||||
await deps.prisma.externalEffect.updateMany({
|
||||
where: { id: applied.effect.id, status: "intended" },
|
||||
data: { status: "completed", result: result as never },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (!(await renewRunLease(deps, runId, workerId, fence))) {
|
||||
// Another worker owns the run now; exit without leaving a local pause card.
|
||||
return pauseForApproval();
|
||||
@ -3506,6 +3553,54 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
autoSpeak: updated.autoSpeak,
|
||||
});
|
||||
}
|
||||
if (name.startsWith("autopilot_")) {
|
||||
const owner = {
|
||||
spaceId: run.spaceId,
|
||||
userId: run.userId,
|
||||
botId: bot.id,
|
||||
threadId: thread.id,
|
||||
};
|
||||
const text = (key: string) =>
|
||||
typeof args[key] === "string" ? redactSecrets(String(args[key]), runSecrets) : "";
|
||||
if (name === "autopilot_start") {
|
||||
const until = text("until") ? new Date(text("until")) : undefined;
|
||||
if (until && !Number.isFinite(until.getTime())) {
|
||||
return finish({ error: "until must be an ISO datetime." });
|
||||
}
|
||||
const started = await startAutopilot(deps, owner, {
|
||||
goal: text("goal"),
|
||||
focus: text("focus") || undefined,
|
||||
until,
|
||||
allow: Array.isArray(args.allow) ? args.allow.map(String) : [],
|
||||
});
|
||||
return finish(
|
||||
"error" in started
|
||||
? started
|
||||
: {
|
||||
ok: true,
|
||||
note: "The autopilot is on. Work on the first step now and report it with autopilot_checkpoint.",
|
||||
},
|
||||
);
|
||||
}
|
||||
if (name === "autopilot_checkpoint") {
|
||||
const checkpoint = await checkpointAutopilot(deps.prisma, owner, {
|
||||
summary: text("summary"),
|
||||
next: text("next") || undefined,
|
||||
});
|
||||
if ("ok" in checkpoint) autopilotCheckpointed = true;
|
||||
return finish(checkpoint);
|
||||
}
|
||||
const open = await findOpenAutopilot(deps.prisma, owner);
|
||||
if (!open) return finish({ error: "No autopilot is running in this thread." });
|
||||
const ended = await endAutopilot(
|
||||
deps,
|
||||
open.id,
|
||||
name === "autopilot_finish"
|
||||
? { status: "finished", result: text("result") }
|
||||
: { status: "stopped" },
|
||||
);
|
||||
return finish(ended ? { ok: true } : { error: "The autopilot already ended." });
|
||||
}
|
||||
if (name === "suggest") {
|
||||
const suggested = await createSuggestionFromTool(deps, {
|
||||
run: { ...run, threadId: thread.id },
|
||||
@ -4503,13 +4598,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
allowSilentPeerMessage ||
|
||||
messagingChannelRun ||
|
||||
publishedMidTurnUserMessage ||
|
||||
Boolean(acceptedSuggestion),
|
||||
Boolean(acceptedSuggestion) ||
|
||||
run.trigger === "autopilot",
|
||||
emptyResponseText,
|
||||
suppressOutput: handedOff,
|
||||
skipEmptyFallback:
|
||||
publishedTerminalSubagent ||
|
||||
publishedMidTurnUserMessage ||
|
||||
Boolean(acceptedSuggestion),
|
||||
Boolean(acceptedSuggestion) ||
|
||||
run.trigger === "autopilot",
|
||||
});
|
||||
}
|
||||
const blocks = handedOff
|
||||
@ -4547,6 +4644,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
await deps.jobs
|
||||
.enqueue(runContinueJob(completed.continuationRunId))
|
||||
.catch((error) => getLogger().error("steering continuation enqueue", error));
|
||||
} else {
|
||||
await continueAutopilotAfterRun(deps, run, {
|
||||
checkpointed: autopilotCheckpointed,
|
||||
failed: false,
|
||||
}).catch((error) => getLogger().error("autopilot continuation", error));
|
||||
}
|
||||
if (botMessageOutcome) {
|
||||
// Prefer the final reply. If the turn only posted mid-turn progress, return that
|
||||
@ -4619,6 +4721,12 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
await deps.jobs
|
||||
.enqueue(runContinueJob(failed.continuationRunId))
|
||||
.catch((error) => getLogger().error("steering continuation enqueue", error));
|
||||
} else {
|
||||
await continueAutopilotAfterRun(deps, run, {
|
||||
checkpointed: autopilotCheckpointed,
|
||||
failed: true,
|
||||
error: message,
|
||||
}).catch((continueError) => getLogger().error("autopilot continuation", continueError));
|
||||
}
|
||||
if (run.trigger === "bot_message") {
|
||||
await returnBotMessageOutcome(
|
||||
|
||||
@ -4,6 +4,7 @@ export * from "./ai-consent.js";
|
||||
export * from "./artifact-files.js";
|
||||
export * from "./artifacts.js";
|
||||
export * from "./auto-review.js";
|
||||
export * from "./autopilot.js";
|
||||
export * from "./background-job-handlers.js";
|
||||
export * from "./bot-messages.js";
|
||||
export * from "./box-emulator.js";
|
||||
|
||||
@ -36,11 +36,20 @@ export function withScheduleTargetBot(tool: ConnectorTool): ConnectorTool {
|
||||
};
|
||||
}
|
||||
|
||||
/** Tools only an autopilot's own runs report through. */
|
||||
const AUTOPILOT_RUN_TOOLS = new Set(["autopilot_checkpoint", "autopilot_finish"]);
|
||||
|
||||
export function filterBuiltinToolsForRun<T extends { name: string }>(
|
||||
tools: T[],
|
||||
runTrigger: string,
|
||||
): T[] {
|
||||
return runTrigger === "routine" ? tools.filter((tool) => tool.name !== "schedule_create") : tools;
|
||||
return tools.filter((tool) => {
|
||||
if (tool.name === "schedule_create") return runTrigger !== "routine";
|
||||
if (AUTOPILOT_RUN_TOOLS.has(tool.name)) return runTrigger === "autopilot";
|
||||
// Only the user starts an autopilot, never a bot, a routine or a webhook.
|
||||
if (tool.name === "autopilot_start") return runTrigger === "user" || runTrigger === "autopilot";
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep cross-bot messaging thread-specific while exposing schedules in DMs and groups. */
|
||||
|
||||
@ -275,6 +275,52 @@ export function inferScript(
|
||||
},
|
||||
];
|
||||
}
|
||||
// An autopilot's own runs: five steps, each reported, then the result.
|
||||
const autopilot = /^Autopilot:.*\nOrder: (.*)$/m.exec(prompt);
|
||||
if (autopilot) {
|
||||
const steps = Number(/^Steps so far: (\d+)$/m.exec(prompt)?.[1] ?? 0);
|
||||
if (/idle/i.test(autopilot[1] ?? "")) {
|
||||
return [{ assistant: "still looking.", complete: true }];
|
||||
}
|
||||
const step = steps + 1;
|
||||
return [
|
||||
{
|
||||
assistant: "",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "autopilot_checkpoint",
|
||||
args: { summary: `Ticket ${step} tidied`, next: `Ticket ${step + 1}` },
|
||||
},
|
||||
...(step >= 5
|
||||
? [
|
||||
{
|
||||
name: "autopilot_finish",
|
||||
args: { result: "Tidied 5 tickets; nothing waits on you." },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
complete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (lower.includes("work through the night")) {
|
||||
return [
|
||||
{
|
||||
assistant: "on it. i will keep going until the tickets are tidy.",
|
||||
toolCalls: [
|
||||
{
|
||||
name: "autopilot_start",
|
||||
args: {
|
||||
goal: lower.includes("idle") ? "Idle check" : "Tidy up the tickets",
|
||||
focus: "Only tidy, change nothing else",
|
||||
},
|
||||
},
|
||||
],
|
||||
complete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
// An idea for the user: nothing runs until they accept it.
|
||||
if (lower.includes("i should move more")) {
|
||||
return [
|
||||
|
||||
63
packages/contracts/src/autopilot.ts
Normal file
63
packages/contracts/src/autopilot.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import * as z from "zod";
|
||||
import { Id } from "./ids.js";
|
||||
|
||||
/** Continuations without a checkpoint, or failed runs in a row, before the autopilot pauses. */
|
||||
export const AUTOPILOT_IDLE_LIMIT = 3;
|
||||
export const AUTOPILOT_FAIL_LIMIT = 3;
|
||||
|
||||
export const AUTOPILOT_LIMITS = { goal: 500, focus: 200, checkpoint: 300, result: 2000 } as const;
|
||||
|
||||
export const AutopilotStatusSchema = z.enum(["active", "paused", "finished", "stopped"]);
|
||||
export type AutopilotStatus = z.infer<typeof AutopilotStatusSchema>;
|
||||
|
||||
/**
|
||||
* A quiet timeline line for an autopilot: when it started (with its goal, end time, focus and
|
||||
* the approvals granted only for it), paused, stopped or finished. `ended` is set on the start
|
||||
* line once the autopilot is over, so its Stop control goes away.
|
||||
*/
|
||||
export const AutopilotBlockSchema = z.object({
|
||||
kind: z.literal("autopilot"),
|
||||
event: z.enum([
|
||||
"autopilot.started",
|
||||
"autopilot.paused",
|
||||
"autopilot.stopped",
|
||||
"autopilot.finished",
|
||||
]),
|
||||
autopilotId: Id,
|
||||
goal: z.string(),
|
||||
focus: z.string().optional(),
|
||||
until: z.string().optional(),
|
||||
/** Tools or apps the bot may use without asking while this autopilot runs. */
|
||||
allowed: z.array(z.string()).optional(),
|
||||
steps: z.number().int().nonnegative().optional(),
|
||||
durationMs: z.number().int().nonnegative().optional(),
|
||||
/** Why it paused, or what it achieved. */
|
||||
text: z.string().optional(),
|
||||
ended: z.boolean().optional(),
|
||||
});
|
||||
export type AutopilotBlock = z.infer<typeof AutopilotBlockSchema>;
|
||||
|
||||
export const AutopilotSchema = z.object({
|
||||
id: Id,
|
||||
botId: Id,
|
||||
threadId: Id,
|
||||
goal: z.string(),
|
||||
focus: z.string().nullable(),
|
||||
until: z.string().nullable(),
|
||||
status: AutopilotStatusSchema,
|
||||
pauseReason: z.string().nullable(),
|
||||
lastCheckpoint: z.string().nullable(),
|
||||
nextStep: z.string().nullable(),
|
||||
result: z.string().nullable(),
|
||||
stepCount: z.number().int(),
|
||||
startedAt: z.string(),
|
||||
endedAt: z.string().nullable(),
|
||||
});
|
||||
export type Autopilot = z.infer<typeof AutopilotSchema>;
|
||||
|
||||
export const AutopilotStartInputSchema = z.object({
|
||||
goal: z.string().trim().min(1).max(AUTOPILOT_LIMITS.goal),
|
||||
focus: z.string().trim().max(AUTOPILOT_LIMITS.focus).optional(),
|
||||
/** ISO time to stop at; without it the autopilot runs until the goal is done. */
|
||||
until: z.string().datetime({ offset: true }).optional(),
|
||||
});
|
||||
@ -930,6 +930,7 @@ export const RunSchema = z.object({
|
||||
"delegation",
|
||||
"media",
|
||||
"suggestion",
|
||||
"autopilot",
|
||||
]),
|
||||
routineId: Id.nullable(),
|
||||
modelProvider: z.string().nullable(),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import * as z from "zod";
|
||||
import { AutopilotBlockSchema } from "./autopilot.js";
|
||||
import { BotSecretDestination } from "./bot-secrets.js";
|
||||
import { CardBlockSchema } from "./cards.js";
|
||||
import { Id } from "./ids.js";
|
||||
@ -199,6 +200,7 @@ export const MessageBlock = z.discriminatedUnion("kind", [
|
||||
ChoiceBlockSchema,
|
||||
IntakeNoteBlockSchema,
|
||||
RoutineEventBlockSchema,
|
||||
AutopilotBlockSchema,
|
||||
z.object({
|
||||
/** Inline app authorization card (Composio-backed): logo, name, one-line
|
||||
description, and an Authorize button that flips to connected. */
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export * from "./ai-consent.js";
|
||||
export * from "./attachments.js";
|
||||
export * from "./autopilot.js";
|
||||
export * from "./bot-secrets.js";
|
||||
export * from "./cards.js";
|
||||
export * from "./clock-time.js";
|
||||
|
||||
@ -2,6 +2,7 @@ import { eventIterator, oc } from "@orpc/contract";
|
||||
import * as z from "zod";
|
||||
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
|
||||
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js";
|
||||
import { AutopilotSchema, AutopilotStartInputSchema } from "./autopilot.js";
|
||||
import { CardActSchema } from "./cards.js";
|
||||
import {
|
||||
CheckInSchema,
|
||||
@ -939,6 +940,15 @@ export const appContract = {
|
||||
inbox: {
|
||||
list: oc.output(InboxListOutputSchema),
|
||||
},
|
||||
/** The thread's open autopilot: start one, stop it, or let a paused one go on. */
|
||||
autopilot: {
|
||||
get: oc.input(threadTarget).output(AutopilotSchema.nullable()),
|
||||
start: oc
|
||||
.input(threadTarget.safeExtend(AutopilotStartInputSchema.shape))
|
||||
.output(AutopilotSchema),
|
||||
stop: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
|
||||
resume: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
|
||||
},
|
||||
voice: {
|
||||
catalog: oc.output(z.array(VoiceCatalogEntrySchema)),
|
||||
status: oc.output(VoiceStatusSchema),
|
||||
|
||||
@ -24,6 +24,7 @@ export const RunActivityRowSchema = z.object({
|
||||
"delegation",
|
||||
"media",
|
||||
"suggestion",
|
||||
"autopilot",
|
||||
]),
|
||||
notificationsEnabled: z.boolean(),
|
||||
promptSnippet: z.string(),
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
isAllowedAttachmentMimeType,
|
||||
isAttachmentImageMimeType,
|
||||
} from "@rakazo/contracts";
|
||||
import { autopilotHistoryText } from "./autopilot.js";
|
||||
import { cardHistoryText } from "./cards.js";
|
||||
import { routineEventHistoryText } from "./routine-events.js";
|
||||
|
||||
@ -140,6 +141,7 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
|
||||
return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`;
|
||||
}
|
||||
if (block.kind === "event") return routineEventHistoryText(block);
|
||||
if (block.kind === "autopilot") return autopilotHistoryText(block);
|
||||
if (block.kind === "card") return cardHistoryText(block);
|
||||
if (block.kind === "handoff") {
|
||||
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
|
||||
|
||||
85
packages/core/src/autopilot.test.ts
Normal file
85
packages/core/src/autopilot.test.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type { AutopilotBlock } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
advanceAutopilotGuard,
|
||||
autopilotContinuationPrompt,
|
||||
autopilotExpired,
|
||||
autopilotHistoryText,
|
||||
formatAutopilotDuration,
|
||||
markAutopilotLinesEnded,
|
||||
} from "./autopilot.js";
|
||||
|
||||
const started: AutopilotBlock = {
|
||||
kind: "autopilot",
|
||||
event: "autopilot.started",
|
||||
autopilotId: "a1",
|
||||
goal: "Tidy up the tickets",
|
||||
focus: "only tidy",
|
||||
until: "2026-10-05T05:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("autopilot", () => {
|
||||
it("pauses after three runs without a checkpoint or three failures, not before", () => {
|
||||
let counts = { idleCount: 0, failCount: 0 };
|
||||
for (const expected of [null, null, "idle"]) {
|
||||
const next = advanceAutopilotGuard(counts, { checkpointed: false, failed: false });
|
||||
expect(next.pause).toBe(expected);
|
||||
counts = next;
|
||||
}
|
||||
expect(advanceAutopilotGuard(counts, { checkpointed: true, failed: false })).toEqual({
|
||||
idleCount: 0,
|
||||
failCount: 0,
|
||||
pause: null,
|
||||
});
|
||||
expect(
|
||||
advanceAutopilotGuard({ idleCount: 0, failCount: 2 }, { checkpointed: true, failed: true })
|
||||
.pause,
|
||||
).toBe("failing");
|
||||
});
|
||||
|
||||
it("tells each continuation the order and where it stands", () => {
|
||||
const prompt = autopilotContinuationPrompt({
|
||||
goal: "Tidy up the tickets",
|
||||
focus: "only tidy",
|
||||
until: null,
|
||||
lastCheckpoint: "Ticket 2 tidied",
|
||||
nextStep: "Ticket 3",
|
||||
stepCount: 2,
|
||||
});
|
||||
expect(prompt).toMatch(/^Autopilot:/);
|
||||
expect(prompt).toContain("Order: Tidy up the tickets");
|
||||
expect(prompt).toContain("Until: done");
|
||||
expect(prompt).toContain("Steps so far: 2");
|
||||
expect(prompt).toContain("Last checkpoint: Ticket 2 tidied");
|
||||
});
|
||||
|
||||
it("ends at its time", () => {
|
||||
expect(autopilotExpired(null)).toBe(false);
|
||||
expect(autopilotExpired(new Date(1000), 2000)).toBe(true);
|
||||
expect(autopilotExpired(new Date(3000), 2000)).toBe(false);
|
||||
});
|
||||
|
||||
it("reads as a line in the model history and loses its controls once over", () => {
|
||||
expect(autopilotHistoryText(started)).toBe(
|
||||
"[autopilot on: Tidy up the tickets — until 2026-10-05T05:00:00.000Z — focus: only tidy]",
|
||||
);
|
||||
const ended = markAutopilotLinesEnded([started, { kind: "text" }], "a1");
|
||||
expect(ended?.[0]).toMatchObject({ ended: true });
|
||||
expect(markAutopilotLinesEnded(ended ?? [], "a1")).toBeNull();
|
||||
expect(
|
||||
autopilotHistoryText({
|
||||
...started,
|
||||
event: "autopilot.finished",
|
||||
steps: 5,
|
||||
text: "Tidied 5 tickets",
|
||||
}),
|
||||
).toBe("[autopilot finished after 5 steps: Tidied 5 tickets]");
|
||||
});
|
||||
|
||||
it("words durations briefly", () => {
|
||||
expect(formatAutopilotDuration(10_000)).toBe("1 min");
|
||||
expect(formatAutopilotDuration(12 * 60_000)).toBe("12 min");
|
||||
expect(formatAutopilotDuration(222 * 60_000)).toBe("3 h 42 min");
|
||||
expect(formatAutopilotDuration(120 * 60_000)).toBe("2 h");
|
||||
});
|
||||
});
|
||||
96
packages/core/src/autopilot.ts
Normal file
96
packages/core/src/autopilot.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import type { AutopilotBlock } from "@rakazo/contracts";
|
||||
import { AUTOPILOT_FAIL_LIMIT, AUTOPILOT_IDLE_LIMIT } from "@rakazo/contracts";
|
||||
|
||||
/** What the autopilot remembers between runs. */
|
||||
export interface AutopilotState {
|
||||
goal: string;
|
||||
focus: string | null;
|
||||
until: Date | null;
|
||||
lastCheckpoint: string | null;
|
||||
nextStep: string | null;
|
||||
stepCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt of each continuation run: the order in the user's words and where the work stands,
|
||||
* so a fresh run picks up without the whole history.
|
||||
*/
|
||||
export function autopilotContinuationPrompt(state: AutopilotState, now = new Date()): string {
|
||||
return [
|
||||
"Autopilot: continue the user's order on your own, one sensible step at a time.",
|
||||
`Order: ${state.goal}`,
|
||||
state.focus ? `Focus: ${state.focus}` : null,
|
||||
state.until ? `Until: ${state.until.toISOString()} (now ${now.toISOString()})` : "Until: done",
|
||||
`Steps so far: ${state.stepCount}`,
|
||||
state.lastCheckpoint ? `Last checkpoint: ${state.lastCheckpoint}` : null,
|
||||
state.nextStep ? `Planned next: ${state.nextStep}` : null,
|
||||
"Do the next step, then call autopilot_checkpoint with what changed. When the order is done, call autopilot_finish with the result. Actions that need the user's approval are parked as suggestions; continue with other work.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Whether the run that just ended moved the autopilot on, and if it should pause now. */
|
||||
export function advanceAutopilotGuard(
|
||||
counts: { idleCount: number; failCount: number },
|
||||
run: { checkpointed: boolean; failed: boolean },
|
||||
): { idleCount: number; failCount: number; pause: "idle" | "failing" | null } {
|
||||
const idleCount = run.checkpointed ? 0 : counts.idleCount + 1;
|
||||
const failCount = run.failed ? counts.failCount + 1 : 0;
|
||||
const pause =
|
||||
failCount >= AUTOPILOT_FAIL_LIMIT
|
||||
? "failing"
|
||||
: idleCount >= AUTOPILOT_IDLE_LIMIT
|
||||
? "idle"
|
||||
: null;
|
||||
return { idleCount, failCount, pause };
|
||||
}
|
||||
|
||||
/** The autopilot's time is up. */
|
||||
export function autopilotExpired(until: Date | null, now = Date.now()): boolean {
|
||||
return Boolean(until && until.getTime() <= now);
|
||||
}
|
||||
|
||||
/** How a later turn reads an autopilot line. */
|
||||
export function autopilotHistoryText(block: AutopilotBlock): string {
|
||||
switch (block.event) {
|
||||
case "autopilot.started": {
|
||||
const parts = [block.goal];
|
||||
if (block.until) parts.push(`until ${block.until}`);
|
||||
if (block.focus) parts.push(`focus: ${block.focus}`);
|
||||
if (block.allowed?.length) parts.push(`allowed without asking: ${block.allowed.join(", ")}`);
|
||||
return `[autopilot ${block.ended ? "was on" : "on"}: ${parts.join(" — ")}]`;
|
||||
}
|
||||
case "autopilot.paused":
|
||||
return `[autopilot paused: ${block.text ?? "no progress"}]`;
|
||||
case "autopilot.stopped":
|
||||
return "[autopilot stopped by the user]";
|
||||
case "autopilot.finished":
|
||||
return `[autopilot finished after ${block.steps ?? 0} steps: ${block.text ?? ""}]`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The blocks with the start line of `autopilotId` marked ended, or null when nothing changes. */
|
||||
export function markAutopilotLinesEnded(
|
||||
blocks: readonly { kind: string }[],
|
||||
autopilotId: string,
|
||||
): AutopilotBlock[] | null {
|
||||
let changed = false;
|
||||
const next = blocks.map((block) => {
|
||||
const line = block as AutopilotBlock;
|
||||
if (line.kind !== "autopilot" || line.autopilotId !== autopilotId || line.ended) return line;
|
||||
if (line.event !== "autopilot.started" && line.event !== "autopilot.paused") return line;
|
||||
changed = true;
|
||||
return { ...line, ended: true };
|
||||
});
|
||||
return changed ? next : null;
|
||||
}
|
||||
|
||||
/** "3 h 42 min", "12 min", or "1 min" for anything shorter. */
|
||||
export function formatAutopilotDuration(ms: number): string {
|
||||
const minutes = Math.max(1, Math.round(ms / 60_000));
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const rest = minutes % 60;
|
||||
if (!hours) return `${minutes} min`;
|
||||
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
||||
}
|
||||
@ -4,6 +4,7 @@ export * from "./ai-consent.js";
|
||||
export * from "./answerable-ask.js";
|
||||
export * from "./async.js";
|
||||
export * from "./attachments.js";
|
||||
export * from "./autopilot.js";
|
||||
export * from "./bot-messages.js";
|
||||
export * from "./bot-reply-sanitize.js";
|
||||
export * from "./bot-sections.js";
|
||||
|
||||
@ -3,7 +3,10 @@ import { formatCron, isOneShotRoutineCrons } from "./cron.js";
|
||||
|
||||
/** A message that is only timeline lines, shown without a speaker, bubble or reactions. */
|
||||
export function isRoutineEventMessage(blocks: readonly MessageBlock[]): boolean {
|
||||
return blocks.length > 0 && blocks.every((block) => block.kind === "event");
|
||||
return (
|
||||
blocks.length > 0 &&
|
||||
blocks.every((block) => block.kind === "event" || block.kind === "autopilot")
|
||||
);
|
||||
}
|
||||
|
||||
/** The blocks with every line about `routineId` marked removed, or null when nothing changes. */
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
-- A standing order the bot keeps working on run after run, and the approvals granted only for it.
|
||||
CREATE TABLE "autopilots" (
|
||||
"id" TEXT NOT NULL,
|
||||
"spaceId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"botId" TEXT NOT NULL,
|
||||
"threadId" TEXT NOT NULL,
|
||||
"goal" TEXT NOT NULL,
|
||||
"focus" TEXT,
|
||||
"until" TIMESTAMP(3),
|
||||
"status" TEXT NOT NULL,
|
||||
"pauseReason" TEXT,
|
||||
"lastCheckpoint" TEXT,
|
||||
"nextStep" TEXT,
|
||||
"result" TEXT,
|
||||
"stepCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"idleCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"failCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"continuations" INTEGER NOT NULL DEFAULT 0,
|
||||
"startMessageId" TEXT,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"endedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "autopilots_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "autopilots_spaceId_userId_status_idx" ON "autopilots"("spaceId", "userId", "status");
|
||||
CREATE INDEX "autopilots_threadId_status_idx" ON "autopilots"("threadId", "status");
|
||||
|
||||
ALTER TABLE "autopilots" ADD CONSTRAINT "autopilots_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "autopilots" ADD CONSTRAINT "autopilots_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "autopilots" ADD CONSTRAINT "autopilots_botId_fkey" FOREIGN KEY ("botId") REFERENCES "bots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "autopilots" ADD CONSTRAINT "autopilots_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "threads"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "action_approval_rules" ADD COLUMN "autopilotId" TEXT;
|
||||
ALTER TABLE "action_approval_rules" ADD CONSTRAINT "action_approval_rules_autopilotId_fkey" FOREIGN KEY ("autopilotId") REFERENCES "autopilots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -41,6 +41,7 @@ model User {
|
||||
mcpServers McpServer[]
|
||||
botSections BotSection[]
|
||||
actionApprovalRules ActionApprovalRule[]
|
||||
autopilots Autopilot[]
|
||||
actionAutoReviewPreferences ActionAutoReviewPreference[]
|
||||
createdSpaces Space[] @relation("SpaceCreator")
|
||||
botSecrets BotSecret[]
|
||||
@ -132,6 +133,9 @@ model ActionApprovalRule {
|
||||
effect String
|
||||
matchKind String
|
||||
matchValue String
|
||||
/// Set when the rule was granted for one autopilot; it ends with it.
|
||||
autopilotId String?
|
||||
autopilot Autopilot? @relation(fields: [autopilotId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([spaceId, createdByUserId, effect, matchKind, matchValue])
|
||||
@ -194,6 +198,7 @@ model Space {
|
||||
tasks Task[]
|
||||
runs Run[]
|
||||
routines Routine[]
|
||||
autopilots Autopilot[]
|
||||
scratchpadItems ScratchpadItem[]
|
||||
projectGoals ProjectGoal[]
|
||||
taughtSkills TaughtSkill[]
|
||||
@ -418,6 +423,7 @@ model Bot {
|
||||
allowedBuiltinTools String[] @default([])
|
||||
modelTier String?
|
||||
routines Routine[]
|
||||
autopilots Autopilot[]
|
||||
scratchpadItems ScratchpadItem[]
|
||||
taughtSkills TaughtSkill[]
|
||||
memoryDocuments MemoryDocument[]
|
||||
@ -542,6 +548,7 @@ model Thread {
|
||||
tasks Task[]
|
||||
runs Run[]
|
||||
routines Routine[]
|
||||
autopilots Autopilot[]
|
||||
delegatedTasks DelegatedTask[]
|
||||
|
||||
@@index([spaceId])
|
||||
@ -757,6 +764,45 @@ model ExternalEffect {
|
||||
@@map("external_effects")
|
||||
}
|
||||
|
||||
/// A standing order in the user's words that the bot works on run after run, until it is done,
|
||||
/// the chosen time is up or the user stops it. At most one per user and space is open at a time.
|
||||
model Autopilot {
|
||||
id String @id @default(cuid())
|
||||
spaceId String
|
||||
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
botId String
|
||||
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
|
||||
threadId String
|
||||
thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade)
|
||||
goal String
|
||||
focus String?
|
||||
until DateTime?
|
||||
/// active, paused, finished or stopped
|
||||
status String
|
||||
pauseReason String?
|
||||
lastCheckpoint String?
|
||||
nextStep String?
|
||||
result String?
|
||||
stepCount Int @default(0)
|
||||
/// Continuations since the last checkpoint, and failed runs in a row.
|
||||
idleCount Int @default(0)
|
||||
failCount Int @default(0)
|
||||
/// Continuation runs started; also keys each one so it starts once.
|
||||
continuations Int @default(0)
|
||||
startMessageId String?
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
approvalRules ActionApprovalRule[]
|
||||
|
||||
@@index([spaceId, userId, status])
|
||||
@@index([threadId, status])
|
||||
@@map("autopilots")
|
||||
}
|
||||
|
||||
model Routine {
|
||||
id String @id @default(cuid())
|
||||
spaceId String
|
||||
|
||||
59
packages/db/src/autopilot-lines.ts
Normal file
59
packages/db/src/autopilot-lines.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { AutopilotBlock, MessageBlock } from "@rakazo/contracts";
|
||||
import { markAutopilotLinesEnded } from "@rakazo/core";
|
||||
import type { Prisma } from "./client.js";
|
||||
import { appendEventInTransaction } from "./events.js";
|
||||
import { createThreadMessageInTransaction } from "./messages.js";
|
||||
|
||||
/** Write one autopilot line in the thread; it never marks the thread unread. */
|
||||
export async function postAutopilotLineInTransaction(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: { spaceId: string; threadId: string; botId: string; block: AutopilotBlock },
|
||||
): Promise<{ messageId: string; seq: number }> {
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: input.threadId,
|
||||
role: "bot",
|
||||
blocks: [input.block],
|
||||
botId: input.botId,
|
||||
markUnread: false,
|
||||
});
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: input.spaceId,
|
||||
threadId: input.threadId,
|
||||
botId: input.botId,
|
||||
type: "thread.message.created",
|
||||
payload: { messageId: message.id, role: "bot", blocks: [input.block] },
|
||||
});
|
||||
return { messageId: message.id, seq: event.seq };
|
||||
}
|
||||
|
||||
/** Take the Stop and Continue controls off an autopilot's earlier lines once it is over. */
|
||||
export async function endAutopilotLinesInTransaction(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: { spaceId: string; threadId: string; autopilotId: string },
|
||||
): Promise<number | null> {
|
||||
const messages = await tx.message.findMany({
|
||||
where: {
|
||||
threadId: input.threadId,
|
||||
blocks: { array_contains: [{ kind: "autopilot", autopilotId: input.autopilotId }] },
|
||||
},
|
||||
select: { id: true, role: true, botId: true, blocks: true },
|
||||
});
|
||||
let seq: number | null = null;
|
||||
for (const message of messages) {
|
||||
const blocks = markAutopilotLinesEnded(message.blocks as MessageBlock[], input.autopilotId);
|
||||
if (!blocks || !message.botId) continue;
|
||||
await tx.message.update({
|
||||
where: { id: message.id },
|
||||
data: { blocks: blocks as Prisma.InputJsonValue },
|
||||
});
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: input.spaceId,
|
||||
threadId: input.threadId,
|
||||
botId: message.botId,
|
||||
type: "thread.message.updated",
|
||||
payload: { messageId: message.id, role: message.role, blocks },
|
||||
});
|
||||
seq = event.seq;
|
||||
}
|
||||
return seq;
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./autopilot-lines.js";
|
||||
export * from "./bootstrap-user.js";
|
||||
export * from "./cancel-runs.js";
|
||||
export * from "./card-messages.js";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user