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:
cynarAI 2026-10-05 03:56:45 +02:00 committed by GitHub
parent 63578bb645
commit 460eede226
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1981 additions and 8 deletions

79
apps/api/src/autopilot.ts Normal file
View 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 };
}

View File

@ -171,6 +171,12 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec
import { createAgentSkillsService } from "./agent-skills.js"; import { createAgentSkillsService } from "./agent-skills.js";
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js"; import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.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 { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js"; import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
import { createCoachService } from "./coach.js"; import { createCoachService } from "./coach.js";
@ -1885,6 +1891,8 @@ export function createRouter(deps: RouterDeps) {
}), }),
stop: authed.threads.stop.handler(async ({ context, input }) => { stop: authed.threads.stop.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, 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); await stopThreadRuns(deps, context.actor, target);
return { ok: true as const }; return { ok: true as const };
}), }),
@ -5142,6 +5150,30 @@ export function createRouter(deps: RouterDeps) {
items: await listInbox(deps.prisma, context.actor), 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: { voice: {
catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()), catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()),
status: authed.voice.status.handler(async ({ context }) => { status: authed.voice.status.handler(async ({ context }) => {

View File

@ -64,6 +64,8 @@ import { useReducedMotion } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppConnectCard } from "../components/AppConnectCard"; import { AppConnectCard } from "../components/AppConnectCard";
import { AskActions } from "../components/AskActions"; 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 { BotAvatar } from "../components/bot-avatar";
import { import {
MarkdownArtifactPreview, MarkdownArtifactPreview,
@ -1247,6 +1249,18 @@ function Thread() {
[botId, groupId], [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( const openBot = useCallback(
(id: string, botName: string) => (id: string, botName: string) =>
router.push({ pathname: "/thread", params: { botId: id, name: botName } }), router.push({ pathname: "/thread", params: { botId: id, name: botName } }),
@ -1497,6 +1511,7 @@ function Thread() {
canAnswer={message.id === answerableAskMessageId} canAnswer={message.id === answerableAskMessageId}
onAnswer={answerMessage} onAnswer={answerMessage}
onCardAct={actOnCard} onCardAct={actOnCard}
onAutopilot={actOnAutopilot}
onOpenBot={openBot} onOpenBot={openBot}
onPreviewMarkdown={setMarkdownPreview} onPreviewMarkdown={setMarkdownPreview}
actionProps={actionProps} actionProps={actionProps}
@ -2372,6 +2387,7 @@ const MessageBubble = memo(function MessageBubble({
canAnswer, canAnswer,
onAnswer, onAnswer,
onCardAct, onCardAct,
onAutopilot,
onOpenBot, onOpenBot,
onPreviewMarkdown, onPreviewMarkdown,
actionProps, actionProps,
@ -2386,6 +2402,7 @@ const MessageBubble = memo(function MessageBubble({
canAnswer: boolean; canAnswer: boolean;
onAnswer: (message: MobileMessage, answer: string) => Promise<void>; onAnswer: (message: MobileMessage, answer: string) => Promise<void>;
onCardAct: (message: MobileMessage, cardId: string, act: CardAct) => Promise<void>; onCardAct: (message: MobileMessage, cardId: string, act: CardAct) => Promise<void>;
onAutopilot: (action: AutopilotAction) => Promise<void>;
onOpenBot: (botId: string, name: string) => void; onOpenBot: (botId: string, name: string) => void;
onPreviewMarkdown: (target: MarkdownArtifactPreviewTarget) => void; onPreviewMarkdown: (target: MarkdownArtifactPreviewTarget) => void;
actionProps: MessageActionProps; actionProps: MessageActionProps;
@ -2449,7 +2466,11 @@ const MessageBubble = memo(function MessageBubble({
return ( return (
<View style={{ width: "100%" }}> <View style={{ width: "100%" }}>
{message.blocks.map((block, index) => {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> </View>
); );

View 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>
);
}

View File

@ -902,6 +902,7 @@ export function blockText(message: MobileMessage) {
} }
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`; if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
if (block.kind === "event") return block.name; if (block.kind === "event") return block.name;
if (block.kind === "autopilot") return block.text ?? block.goal;
if (block.kind === "card") { if (block.kind === "card") {
const header = block.sections?.find((part) => part.type === "header"); const header = block.sections?.find((part) => part.type === "header");
return header?.type === "header" return header?.type === "header"

View File

@ -689,4 +689,10 @@ export const RU_MESSAGES: Record<string, string> = {
Accept: "Принять", Accept: "Принять",
Dismissed: "Отклонено", Dismissed: "Отклонено",
Expired: "Истекло", Expired: "Истекло",
Autopilot: "Автопилот",
"Autopilot finished": "Автопилот завершён",
"Autopilot paused": "Автопилот на паузе",
"Autopilot stopped": "Автопилот остановлен",
"allowed: {allowed}": "разрешено: {allowed}",
"until {until}": "до {until}",
}; };

View File

@ -673,4 +673,10 @@ export const ZH_MESSAGES: Record<string, string> = {
Accept: "接受", Accept: "接受",
Dismissed: "已忽略", Dismissed: "已忽略",
Expired: "已过期", Expired: "已过期",
Autopilot: "自动驾驶",
"Autopilot finished": "自动驾驶已完成",
"Autopilot paused": "自动驾驶已暂停",
"Autopilot stopped": "自动驾驶已停止",
"allowed: {allowed}": "已允许:{allowed}",
"until {until}": "直到 {until}",
}; };

View File

@ -8,7 +8,8 @@ export function isCenteredAgentEvent(blocks: readonly MessageBlock[]): boolean {
block.kind === "bot_message_sent" || block.kind === "bot_message_sent" ||
block.kind === "bot_message_received" || block.kind === "bot_message_received" ||
block.kind === "channel_message" || block.kind === "channel_message" ||
block.kind === "event", block.kind === "event" ||
block.kind === "autopilot",
); );
} }

View 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");
});

View File

@ -639,5 +639,11 @@
"Stop timer": "Timer stoppen", "Stop timer": "Timer stoppen",
"Accept": "Annehmen", "Accept": "Annehmen",
"Dismissed": "Abgelehnt", "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}"
} }

View File

@ -35,6 +35,7 @@ const runTriggers = new Set<Run["trigger"]>([
"delegation", "delegation",
"media", "media",
"suggestion", "suggestion",
"autopilot",
]); ]);
function runFromStartedEvent(event: ProductEvent, previous: Run | undefined): Run { function runFromStartedEvent(event: ProductEvent, previous: Run | undefined): Run {

View File

@ -572,6 +572,10 @@ msgstr "Schreiben erlauben"
msgid "Allowed once" msgid "Allowed once"
msgstr "Einmal erlaubt" msgstr "Einmal erlaubt"
#: src/pages/shell/autopilot-line.tsx
msgid "allowed: {allowed}"
msgstr "erlaubt: {allowed}"
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Already have an account?" msgid "Already have an account?"
msgstr "Du hast bereits ein Konto?" msgstr "Du hast bereits ein Konto?"
@ -760,6 +764,22 @@ msgstr "Automatische Absender"
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "Automatische Absender erscheinen, sobald sie hier etwas posten." 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 #: src/pages/RoutineEditor.tsx
msgid "Available after the routine is saved" msgid "Available after the routine is saved"
msgstr "Verfügbar, sobald die Routine gespeichert ist" msgstr "Verfügbar, sobald die Routine gespeichert ist"
@ -1415,6 +1435,7 @@ msgstr "Kontextlimit"
#: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "Continue" msgid "Continue"
msgstr "Weiter" msgstr "Weiter"
@ -4801,6 +4822,7 @@ msgid "Steps"
msgstr "Schritte" msgstr "Schritte"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
msgstr "Triage läuft…" msgstr "Triage läuft…"
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "Erneut versuchen" msgstr "Erneut versuchen"
@ -5136,6 +5159,10 @@ msgstr "Ungelesene Antwort"
msgid "Unsupported file type: {0}" msgid "Unsupported file type: {0}"
msgstr "Nicht unterstützter Dateityp: {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/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -572,6 +572,10 @@ msgstr "Allow writing"
msgid "Allowed once" msgid "Allowed once"
msgstr "Allowed once" msgstr "Allowed once"
#: src/pages/shell/autopilot-line.tsx
msgid "allowed: {allowed}"
msgstr "allowed: {allowed}"
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Already have an account?" msgid "Already have an account?"
msgstr "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." msgid "Automated senders will appear after they post here."
msgstr "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 #: src/pages/RoutineEditor.tsx
msgid "Available after the routine is saved" msgid "Available after the routine is saved"
msgstr "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/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "Continue" msgid "Continue"
msgstr "Continue" msgstr "Continue"
@ -4801,6 +4822,7 @@ msgid "Steps"
msgstr "Steps" msgstr "Steps"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
msgstr "Triage in progress…" msgstr "Triage in progress…"
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "Try again" msgstr "Try again"
@ -5136,6 +5159,10 @@ msgstr "Unread reply"
msgid "Unsupported file type: {0}" msgid "Unsupported file type: {0}"
msgstr "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/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -572,6 +572,10 @@ msgstr ""
msgid "Allowed once" msgid "Allowed once"
msgstr "Permitido una vez" msgstr "Permitido una vez"
#: src/pages/shell/autopilot-line.tsx
msgid "allowed: {allowed}"
msgstr ""
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Already have an account?" msgid "Already have an account?"
msgstr "¿Ya tienes una cuenta?" msgstr "¿Ya tienes una cuenta?"
@ -760,6 +764,22 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" 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 #: src/pages/RoutineEditor.tsx
msgid "Available after the routine is saved" msgid "Available after the routine is saved"
msgstr "Disponible después de guardar la rutina" 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/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "Continue" msgid "Continue"
msgstr "Continuar" msgstr "Continuar"
@ -4801,6 +4822,7 @@ msgid "Steps"
msgstr "Pasos" msgstr "Pasos"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "Intentarlo de nuevo" msgstr "Intentarlo de nuevo"
@ -5136,6 +5159,10 @@ msgstr ""
msgid "Unsupported file type: {0}" msgid "Unsupported file type: {0}"
msgstr "Tipo de archivo no compatible: {0}" msgstr "Tipo de archivo no compatible: {0}"
#: src/pages/shell/autopilot-line.tsx
msgid "until {until}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -572,6 +572,10 @@ msgstr ""
msgid "Allowed once" msgid "Allowed once"
msgstr "एक बार अनुमति दी गई" msgstr "एक बार अनुमति दी गई"
#: src/pages/shell/autopilot-line.tsx
msgid "allowed: {allowed}"
msgstr ""
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Already have an account?" msgid "Already have an account?"
msgstr "पहले से खाता है?" msgstr "पहले से खाता है?"
@ -760,6 +764,22 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" 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 #: src/pages/RoutineEditor.tsx
msgid "Available after the routine is saved" msgid "Available after the routine is saved"
msgstr "" msgstr ""
@ -1415,6 +1435,7 @@ msgstr "कॉन्टेक्स्ट सीमा"
#: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "Continue" msgid "Continue"
msgstr "जारी रखें" msgstr "जारी रखें"
@ -4801,6 +4822,7 @@ msgid "Steps"
msgstr "चरण" msgstr "चरण"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "फिर से कोशिश करें" msgstr "फिर से कोशिश करें"
@ -5136,6 +5159,10 @@ msgstr ""
msgid "Unsupported file type: {0}" msgid "Unsupported file type: {0}"
msgstr "असमर्थित फ़ाइल प्रकार: {0}" msgstr "असमर्थित फ़ाइल प्रकार: {0}"
#: src/pages/shell/autopilot-line.tsx
msgid "until {until}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -572,6 +572,10 @@ msgstr ""
msgid "Allowed once" msgid "Allowed once"
msgstr "한 번 허용됨" msgstr "한 번 허용됨"
#: src/pages/shell/autopilot-line.tsx
msgid "allowed: {allowed}"
msgstr ""
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Already have an account?" msgid "Already have an account?"
msgstr "이미 계정이 있나요?" msgstr "이미 계정이 있나요?"
@ -760,6 +764,22 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" 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 #: src/pages/RoutineEditor.tsx
msgid "Available after the routine is saved" msgid "Available after the routine is saved"
msgstr "루틴을 저장한 후 사용할 수 있습니다" msgstr "루틴을 저장한 후 사용할 수 있습니다"
@ -1415,6 +1435,7 @@ msgstr "컨텍스트 한도"
#: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "Continue" msgid "Continue"
msgstr "계속" msgstr "계속"
@ -4801,6 +4822,7 @@ msgid "Steps"
msgstr "실행 단계" msgstr "실행 단계"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "다시 시도" msgstr "다시 시도"
@ -5136,6 +5159,10 @@ msgstr ""
msgid "Unsupported file type: {0}" msgid "Unsupported file type: {0}"
msgstr "지원하지 않는 파일 형식: {0}" msgstr "지원하지 않는 파일 형식: {0}"
#: src/pages/shell/autopilot-line.tsx
msgid "until {until}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -572,6 +572,10 @@ msgstr ""
msgid "Allowed once" msgid "Allowed once"
msgstr "Permitido uma vez" msgstr "Permitido uma vez"
#: src/pages/shell/autopilot-line.tsx
msgid "allowed: {allowed}"
msgstr ""
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Already have an account?" msgid "Already have an account?"
msgstr "Você já tem cadastro?" msgstr "Você já tem cadastro?"
@ -760,6 +764,22 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" 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 #: src/pages/RoutineEditor.tsx
msgid "Available after the routine is saved" msgid "Available after the routine is saved"
msgstr "" msgstr ""
@ -1415,6 +1435,7 @@ msgstr "Limite de contexto"
#: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "Continue" msgid "Continue"
msgstr "Continuar" msgstr "Continuar"
@ -4801,6 +4822,7 @@ msgid "Steps"
msgstr "Passos" msgstr "Passos"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "Tentar novamente" msgstr "Tentar novamente"
@ -5136,6 +5159,10 @@ msgstr ""
msgid "Unsupported file type: {0}" msgid "Unsupported file type: {0}"
msgstr "Tipo de arquivo não suportado: {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/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -572,6 +572,10 @@ msgstr ""
msgid "Allowed once" msgid "Allowed once"
msgstr "Разрешено один раз" msgstr "Разрешено один раз"
#: src/pages/shell/autopilot-line.tsx
msgid "allowed: {allowed}"
msgstr ""
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Already have an account?" msgid "Already have an account?"
msgstr "У вас уже есть аккаунт?" msgstr "У вас уже есть аккаунт?"
@ -760,6 +764,22 @@ msgstr "Автоматические отправители"
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "Автоматические отправители появятся после публикации здесь." 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 #: src/pages/RoutineEditor.tsx
msgid "Available after the routine is saved" msgid "Available after the routine is saved"
msgstr "Доступно после сохранения задачи" msgstr "Доступно после сохранения задачи"
@ -1415,6 +1435,7 @@ msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "Continue" msgid "Continue"
msgstr "Продолжить" msgstr "Продолжить"
@ -4801,6 +4822,7 @@ msgid "Steps"
msgstr "Шаги" msgstr "Шаги"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "" msgstr ""
@ -5136,6 +5159,10 @@ msgstr ""
msgid "Unsupported file type: {0}" msgid "Unsupported file type: {0}"
msgstr "Неподдерживаемый тип файла: {0}." msgstr "Неподдерживаемый тип файла: {0}."
#: src/pages/shell/autopilot-line.tsx
msgid "until {until}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -572,6 +572,10 @@ msgstr ""
msgid "Allowed once" msgid "Allowed once"
msgstr "Bir kez izin verildi" msgstr "Bir kez izin verildi"
#: src/pages/shell/autopilot-line.tsx
msgid "allowed: {allowed}"
msgstr ""
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Already have an account?" msgid "Already have an account?"
msgstr "Zaten hesabın var mı?" msgstr "Zaten hesabın var mı?"
@ -760,6 +764,22 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" 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 #: src/pages/RoutineEditor.tsx
msgid "Available after the routine is saved" msgid "Available after the routine is saved"
msgstr "" msgstr ""
@ -1415,6 +1435,7 @@ msgstr "Bağlam sınırı"
#: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "Continue" msgid "Continue"
msgstr "Devam" msgstr "Devam"
@ -4801,6 +4822,7 @@ msgid "Steps"
msgstr "Adımlar" msgstr "Adımlar"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "Tekrar dene" msgstr "Tekrar dene"
@ -5136,6 +5159,10 @@ msgstr ""
msgid "Unsupported file type: {0}" msgid "Unsupported file type: {0}"
msgstr "Desteklenmeyen dosya türü: {0}" msgstr "Desteklenmeyen dosya türü: {0}"
#: src/pages/shell/autopilot-line.tsx
msgid "until {until}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -572,6 +572,10 @@ msgstr ""
msgid "Allowed once" msgid "Allowed once"
msgstr "已允许一次" msgstr "已允许一次"
#: src/pages/shell/autopilot-line.tsx
msgid "allowed: {allowed}"
msgstr ""
#: src/pages/Auth.tsx #: src/pages/Auth.tsx
msgid "Already have an account?" msgid "Already have an account?"
msgstr "已有账户?" msgstr "已有账户?"
@ -760,6 +764,22 @@ msgstr ""
msgid "Automated senders will appear after they post here." msgid "Automated senders will appear after they post here."
msgstr "" 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 #: src/pages/RoutineEditor.tsx
msgid "Available after the routine is saved" msgid "Available after the routine is saved"
msgstr "保存例行任务后可用" msgstr "保存例行任务后可用"
@ -1415,6 +1435,7 @@ msgstr "上下文限制"
#: src/components/onboarding/OnboardingOoDesigner.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/shell/autopilot-line.tsx
msgid "Continue" msgid "Continue"
msgstr "继续" msgstr "继续"
@ -4801,6 +4822,7 @@ msgid "Steps"
msgstr "步骤" msgstr "步骤"
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
#: src/pages/shell/fullscreen-computer-overlay.tsx #: src/pages/shell/fullscreen-computer-overlay.tsx
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
@ -5087,6 +5109,7 @@ msgid "Triage in progress…"
msgstr "" msgstr ""
#: src/pages/Onboarding.tsx #: src/pages/Onboarding.tsx
#: src/pages/shell/autopilot-line.tsx
#: src/pages/shell/routine-event-line.tsx #: src/pages/shell/routine-event-line.tsx
msgid "Try again" msgid "Try again"
msgstr "重试" msgstr "重试"
@ -5136,6 +5159,10 @@ msgstr ""
msgid "Unsupported file type: {0}" msgid "Unsupported file type: {0}"
msgstr "不支持的文件类型:{0}" msgstr "不支持的文件类型:{0}"
#: src/pages/shell/autopilot-line.tsx
msgid "until {until}"
msgstr ""
#: src/components/DesktopUpdates.tsx #: src/components/DesktopUpdates.tsx
#: src/components/SoftwareUpdateSection.tsx #: src/components/SoftwareUpdateSection.tsx
msgid "Up to date" msgid "Up to date"

View File

@ -155,6 +155,7 @@ import {
import type { SettingsSection } from "./SettingsOverlay"; import type { SettingsSection } from "./SettingsOverlay";
import { SpaceSearchResults } from "./SpaceSearch"; import { SpaceSearchResults } from "./SpaceSearch";
import { ArchivedSection } from "./shell/archived-section"; import { ArchivedSection } from "./shell/archived-section";
import type { AutopilotAction } from "./shell/autopilot-line";
import { BotSettings, CreateBotForm } from "./shell/bot-panel"; import { BotSettings, CreateBotForm } from "./shell/bot-panel";
import { ConversationBotPicker } from "./shell/bot-picker"; import { ConversationBotPicker } from "./shell/bot-picker";
import { import {
@ -1978,6 +1979,19 @@ export function ShellPage() {
await refreshThreadRef.current(botId); 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( const reactToMessage = useCallback(
async (message: ThreadMessage, reaction: MessageReaction) => { async (message: ThreadMessage, reaction: MessageReaction) => {
const botId = activeBotId.current; const botId = activeBotId.current;
@ -3330,6 +3344,7 @@ export function ShellPage() {
onOpenGroup={openGroup} onOpenGroup={openGroup}
onAnswer={answerMessage} onAnswer={answerMessage}
onCardAct={actOnCard} onCardAct={actOnCard}
onAutopilot={actOnAutopilot}
onReply={(message) => { onReply={(message) => {
setReplyTarget(message); setReplyTarget(message);
setReplyQuote(null); setReplyQuote(null);

View 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>
);
}

View File

@ -44,6 +44,8 @@ import { messageProviderLabel } from "../../lib/messaging";
import { withIntakeNoteText } from "../../lib/onboarding-copy"; import { withIntakeNoteText } from "../../lib/onboarding-copy";
import { quoteDraftForSelection } from "../../lib/quote-selection"; import { quoteDraftForSelection } from "../../lib/quote-selection";
import { transcriptIsNearEnd, transcriptMovedDown } from "../../lib/transcript-scroll"; import { transcriptIsNearEnd, transcriptMovedDown } from "../../lib/transcript-scroll";
import type { AutopilotAction } from "./autopilot-line";
import { AutopilotLine } from "./autopilot-line";
import { import {
AppConnectCard, AppConnectCard,
ArtifactImage, ArtifactImage,
@ -70,6 +72,7 @@ export const Transcript = memo(function Transcript({
onOpenGroup, onOpenGroup,
onAnswer, onAnswer,
onCardAct, onCardAct,
onAutopilot,
onReply, onReply,
onQuote, onQuote,
onReact, onReact,
@ -101,6 +104,7 @@ export const Transcript = memo(function Transcript({
onOpenGroup: (groupId: string) => void; onOpenGroup: (groupId: string) => void;
onAnswer: (message: ThreadMessage, text: string) => Promise<void>; onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>; onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
onAutopilot?: (action: AutopilotAction) => Promise<void>;
onReply: (message: ThreadMessage) => void; onReply: (message: ThreadMessage) => void;
onQuote: (message: ThreadMessage, quote: string) => void; onQuote: (message: ThreadMessage, quote: string) => void;
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>; onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
@ -380,7 +384,11 @@ export const Transcript = memo(function Transcript({
return ( return (
<div key={message.id} data-message-id={message.id}> <div key={message.id} data-message-id={message.id}>
{message.blocks.map((block, index) => {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> </div>
); );

View 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" });
});
});

View 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);
}

View File

@ -367,6 +367,67 @@ export const builtinAgentTools: ConnectorTool[] = [
required: ["tool", "args"], 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", name: "message_user",
description: description:

View File

@ -147,6 +147,7 @@ export function selectBuiltinToolsForRun(options: {
"show_card", "show_card",
"suggest", "suggest",
].includes(tool.name) && ].includes(tool.name) &&
!tool.name.startsWith("autopilot_") &&
!USER_MODEL_TOOLS.has(tool.name) && !USER_MODEL_TOOLS.has(tool.name) &&
!tool.name.startsWith("scratchpad_"))), !tool.name.startsWith("scratchpad_"))),
); );

View File

@ -156,6 +156,13 @@ import {
resolveAutoReviewChecker, resolveAutoReviewChecker,
runAutoReviewJudge, runAutoReviewJudge,
} from "./auto-review.js"; } from "./auto-review.js";
import {
checkpointAutopilot,
continueAutopilotAfterRun,
endAutopilot,
findOpenAutopilot,
startAutopilot,
} from "./autopilot.js";
import { loadBotMessageContext, messageBot, returnBotMessageOutcome } from "./bot-messages.js"; import { loadBotMessageContext, messageBot, returnBotMessageOutcome } from "./bot-messages.js";
import { import {
findBotSecret, findBotSecret,
@ -1363,6 +1370,12 @@ export function createRunExecutor(deps: ExecutorDeps) {
await deps.jobs await deps.jobs
.enqueue(runContinueJob(failed.continuationRunId)) .enqueue(runContinueJob(failed.continuationRunId))
.catch((error) => getLogger().error("steering continuation enqueue", error)); .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") { if (run.trigger === "bot_message") {
await returnBotMessageOutcome( 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 // 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). // still knows progress was already published (skip hollow finals; status outcome).
let publishedMidTurnUserMessage = false; let publishedMidTurnUserMessage = false;
// An autopilot run that reported a step; without one the autopilot counts as idle.
let autopilotCheckpointed = false;
const midTurnUserTexts: string[] = []; const midTurnUserTexts: string[] = [];
let midTurnProgressCount = 0; let midTurnProgressCount = 0;
{ {
@ -2142,6 +2157,38 @@ export function createRunExecutor(deps: ExecutorDeps) {
}; };
const requestApproval = async () => { 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))) { if (!(await renewRunLease(deps, runId, workerId, fence))) {
// Another worker owns the run now; exit without leaving a local pause card. // Another worker owns the run now; exit without leaving a local pause card.
return pauseForApproval(); return pauseForApproval();
@ -3506,6 +3553,54 @@ export function createRunExecutor(deps: ExecutorDeps) {
autoSpeak: updated.autoSpeak, 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") { if (name === "suggest") {
const suggested = await createSuggestionFromTool(deps, { const suggested = await createSuggestionFromTool(deps, {
run: { ...run, threadId: thread.id }, run: { ...run, threadId: thread.id },
@ -4503,13 +4598,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
allowSilentPeerMessage || allowSilentPeerMessage ||
messagingChannelRun || messagingChannelRun ||
publishedMidTurnUserMessage || publishedMidTurnUserMessage ||
Boolean(acceptedSuggestion), Boolean(acceptedSuggestion) ||
run.trigger === "autopilot",
emptyResponseText, emptyResponseText,
suppressOutput: handedOff, suppressOutput: handedOff,
skipEmptyFallback: skipEmptyFallback:
publishedTerminalSubagent || publishedTerminalSubagent ||
publishedMidTurnUserMessage || publishedMidTurnUserMessage ||
Boolean(acceptedSuggestion), Boolean(acceptedSuggestion) ||
run.trigger === "autopilot",
}); });
} }
const blocks = handedOff const blocks = handedOff
@ -4547,6 +4644,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
await deps.jobs await deps.jobs
.enqueue(runContinueJob(completed.continuationRunId)) .enqueue(runContinueJob(completed.continuationRunId))
.catch((error) => getLogger().error("steering continuation enqueue", error)); .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) { if (botMessageOutcome) {
// Prefer the final reply. If the turn only posted mid-turn progress, return that // 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 await deps.jobs
.enqueue(runContinueJob(failed.continuationRunId)) .enqueue(runContinueJob(failed.continuationRunId))
.catch((error) => getLogger().error("steering continuation enqueue", error)); .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") { if (run.trigger === "bot_message") {
await returnBotMessageOutcome( await returnBotMessageOutcome(

View File

@ -4,6 +4,7 @@ export * from "./ai-consent.js";
export * from "./artifact-files.js"; export * from "./artifact-files.js";
export * from "./artifacts.js"; export * from "./artifacts.js";
export * from "./auto-review.js"; export * from "./auto-review.js";
export * from "./autopilot.js";
export * from "./background-job-handlers.js"; export * from "./background-job-handlers.js";
export * from "./bot-messages.js"; export * from "./bot-messages.js";
export * from "./box-emulator.js"; export * from "./box-emulator.js";

View File

@ -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 }>( export function filterBuiltinToolsForRun<T extends { name: string }>(
tools: T[], tools: T[],
runTrigger: string, runTrigger: string,
): T[] { ): 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. */ /** Keep cross-bot messaging thread-specific while exposing schedules in DMs and groups. */

View File

@ -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. // An idea for the user: nothing runs until they accept it.
if (lower.includes("i should move more")) { if (lower.includes("i should move more")) {
return [ return [

View 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(),
});

View File

@ -930,6 +930,7 @@ export const RunSchema = z.object({
"delegation", "delegation",
"media", "media",
"suggestion", "suggestion",
"autopilot",
]), ]),
routineId: Id.nullable(), routineId: Id.nullable(),
modelProvider: z.string().nullable(), modelProvider: z.string().nullable(),

View File

@ -1,4 +1,5 @@
import * as z from "zod"; import * as z from "zod";
import { AutopilotBlockSchema } from "./autopilot.js";
import { BotSecretDestination } from "./bot-secrets.js"; import { BotSecretDestination } from "./bot-secrets.js";
import { CardBlockSchema } from "./cards.js"; import { CardBlockSchema } from "./cards.js";
import { Id } from "./ids.js"; import { Id } from "./ids.js";
@ -199,6 +200,7 @@ export const MessageBlock = z.discriminatedUnion("kind", [
ChoiceBlockSchema, ChoiceBlockSchema,
IntakeNoteBlockSchema, IntakeNoteBlockSchema,
RoutineEventBlockSchema, RoutineEventBlockSchema,
AutopilotBlockSchema,
z.object({ z.object({
/** Inline app authorization card (Composio-backed): logo, name, one-line /** Inline app authorization card (Composio-backed): logo, name, one-line
description, and an Authorize button that flips to connected. */ description, and an Authorize button that flips to connected. */

View File

@ -1,5 +1,6 @@
export * from "./ai-consent.js"; export * from "./ai-consent.js";
export * from "./attachments.js"; export * from "./attachments.js";
export * from "./autopilot.js";
export * from "./bot-secrets.js"; export * from "./bot-secrets.js";
export * from "./cards.js"; export * from "./cards.js";
export * from "./clock-time.js"; export * from "./clock-time.js";

View File

@ -2,6 +2,7 @@ import { eventIterator, oc } from "@orpc/contract";
import * as z from "zod"; import * as z from "zod";
import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js"; import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js";
import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js"; import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js";
import { AutopilotSchema, AutopilotStartInputSchema } from "./autopilot.js";
import { CardActSchema } from "./cards.js"; import { CardActSchema } from "./cards.js";
import { import {
CheckInSchema, CheckInSchema,
@ -939,6 +940,15 @@ export const appContract = {
inbox: { inbox: {
list: oc.output(InboxListOutputSchema), 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: { voice: {
catalog: oc.output(z.array(VoiceCatalogEntrySchema)), catalog: oc.output(z.array(VoiceCatalogEntrySchema)),
status: oc.output(VoiceStatusSchema), status: oc.output(VoiceStatusSchema),

View File

@ -24,6 +24,7 @@ export const RunActivityRowSchema = z.object({
"delegation", "delegation",
"media", "media",
"suggestion", "suggestion",
"autopilot",
]), ]),
notificationsEnabled: z.boolean(), notificationsEnabled: z.boolean(),
promptSnippet: z.string(), promptSnippet: z.string(),

View File

@ -6,6 +6,7 @@ import {
isAllowedAttachmentMimeType, isAllowedAttachmentMimeType,
isAttachmentImageMimeType, isAttachmentImageMimeType,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { autopilotHistoryText } from "./autopilot.js";
import { cardHistoryText } from "./cards.js"; import { cardHistoryText } from "./cards.js";
import { routineEventHistoryText } from "./routine-events.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}`; return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`;
} }
if (block.kind === "event") return routineEventHistoryText(block); if (block.kind === "event") return routineEventHistoryText(block);
if (block.kind === "autopilot") return autopilotHistoryText(block);
if (block.kind === "card") return cardHistoryText(block); if (block.kind === "card") return cardHistoryText(block);
if (block.kind === "handoff") { if (block.kind === "handoff") {
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`; return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;

View 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");
});
});

View 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`;
}

View File

@ -4,6 +4,7 @@ export * from "./ai-consent.js";
export * from "./answerable-ask.js"; export * from "./answerable-ask.js";
export * from "./async.js"; export * from "./async.js";
export * from "./attachments.js"; export * from "./attachments.js";
export * from "./autopilot.js";
export * from "./bot-messages.js"; export * from "./bot-messages.js";
export * from "./bot-reply-sanitize.js"; export * from "./bot-reply-sanitize.js";
export * from "./bot-sections.js"; export * from "./bot-sections.js";

View File

@ -3,7 +3,10 @@ import { formatCron, isOneShotRoutineCrons } from "./cron.js";
/** A message that is only timeline lines, shown without a speaker, bubble or reactions. */ /** A message that is only timeline lines, shown without a speaker, bubble or reactions. */
export function isRoutineEventMessage(blocks: readonly MessageBlock[]): boolean { 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. */ /** The blocks with every line about `routineId` marked removed, or null when nothing changes. */

View File

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

View File

@ -41,6 +41,7 @@ model User {
mcpServers McpServer[] mcpServers McpServer[]
botSections BotSection[] botSections BotSection[]
actionApprovalRules ActionApprovalRule[] actionApprovalRules ActionApprovalRule[]
autopilots Autopilot[]
actionAutoReviewPreferences ActionAutoReviewPreference[] actionAutoReviewPreferences ActionAutoReviewPreference[]
createdSpaces Space[] @relation("SpaceCreator") createdSpaces Space[] @relation("SpaceCreator")
botSecrets BotSecret[] botSecrets BotSecret[]
@ -132,6 +133,9 @@ model ActionApprovalRule {
effect String effect String
matchKind String matchKind String
matchValue 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()) createdAt DateTime @default(now())
@@unique([spaceId, createdByUserId, effect, matchKind, matchValue]) @@unique([spaceId, createdByUserId, effect, matchKind, matchValue])
@ -194,6 +198,7 @@ model Space {
tasks Task[] tasks Task[]
runs Run[] runs Run[]
routines Routine[] routines Routine[]
autopilots Autopilot[]
scratchpadItems ScratchpadItem[] scratchpadItems ScratchpadItem[]
projectGoals ProjectGoal[] projectGoals ProjectGoal[]
taughtSkills TaughtSkill[] taughtSkills TaughtSkill[]
@ -418,6 +423,7 @@ model Bot {
allowedBuiltinTools String[] @default([]) allowedBuiltinTools String[] @default([])
modelTier String? modelTier String?
routines Routine[] routines Routine[]
autopilots Autopilot[]
scratchpadItems ScratchpadItem[] scratchpadItems ScratchpadItem[]
taughtSkills TaughtSkill[] taughtSkills TaughtSkill[]
memoryDocuments MemoryDocument[] memoryDocuments MemoryDocument[]
@ -542,6 +548,7 @@ model Thread {
tasks Task[] tasks Task[]
runs Run[] runs Run[]
routines Routine[] routines Routine[]
autopilots Autopilot[]
delegatedTasks DelegatedTask[] delegatedTasks DelegatedTask[]
@@index([spaceId]) @@index([spaceId])
@ -757,6 +764,45 @@ model ExternalEffect {
@@map("external_effects") @@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 { model Routine {
id String @id @default(cuid()) id String @id @default(cuid())
spaceId String spaceId String

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

View File

@ -1,3 +1,4 @@
export * from "./autopilot-lines.js";
export * from "./bootstrap-user.js"; export * from "./bootstrap-user.js";
export * from "./cancel-runs.js"; export * from "./cancel-runs.js";
export * from "./card-messages.js"; export * from "./card-messages.js";