fix(sandboxes,adapters): allow cloning git repositories and link space git remote in prompts

This commit is contained in:
cynarAI 2026-10-23 02:13:25 +02:00
parent 8a98bfe79f
commit 5168518759
46 changed files with 1595 additions and 328 deletions

View File

@ -273,6 +273,7 @@ import {
reactToThreadMessage, reactToThreadMessage,
removeQueuedMessage, removeQueuedMessage,
resolveThreadTarget, resolveThreadTarget,
resumeThreadRuns,
sendQueuedThreadMessagesNow, sendQueuedThreadMessagesNow,
sendThreadMessage, sendThreadMessage,
setThreadUnreadState, setThreadUnreadState,
@ -2064,6 +2065,16 @@ export function createRouter(deps: RouterDeps) {
await stopThreadRuns(deps, context.actor, target); await stopThreadRuns(deps, context.actor, target);
return { ok: true as const }; return { ok: true as const };
}), }),
resume: authed.threads.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);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return resumeThreadRuns(deps, context.actor, target);
}),
sendNow: authed.threads.sendNow.handler(async ({ context, input }) => { sendNow: authed.threads.sendNow.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) { if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." }); throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });

View File

@ -2,16 +2,17 @@ import type { SandboxProvider } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts"; import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db"; import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { ThreadTarget } from "./thread-target.js";
import { import {
cancelSupersededQueuedRuns, cancelSupersededQueuedRuns,
followUpGroupThreadMessage, followUpGroupThreadMessage,
groupDefaultBotId, groupDefaultBotId,
reactToThreadMessage, reactToThreadMessage,
resolveGroupFollowUpBotId, resolveGroupFollowUpBotId,
resumeThreadRuns,
sendQueuedThreadMessagesNow, sendQueuedThreadMessagesNow,
sendThreadMessage, sendThreadMessage,
stopThreadRuns, stopThreadRuns,
type ThreadTarget,
threadHead, threadHead,
threadSnapshot, threadSnapshot,
} from "./thread-target.js"; } from "./thread-target.js";
@ -2228,3 +2229,83 @@ describe("stopThreadRuns", () => {
expect(events.notify).toHaveBeenCalledWith("thread-1", 42); expect(events.notify).toHaveBeenCalledWith("thread-1", 42);
}); });
}); });
describe("resumeThreadRuns", () => {
it("re-queues the latest cancelled or failed run without creating a duplicate message", async () => {
const transaction = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: "run-cancelled",
taskId: "task-cancelled",
botId: "bot-a",
status: "cancelled",
createdAt: new Date("2026-04-20T10:00:00.000Z"),
completedAt: new Date("2026-04-20T10:02:00.000Z"),
},
]),
update: vi.fn().mockResolvedValue({}),
},
task: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 19 }),
},
};
const prisma = {
$transaction: vi.fn(async (fn: (tx: typeof transaction) => Promise<unknown>) =>
fn(transaction),
),
} as unknown as PrismaClient;
const jobs = {
enqueue: vi.fn().mockResolvedValue(undefined),
};
const events = {
notify: vi.fn().mockResolvedValue(undefined),
};
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
const target = {
kind: "bot",
botId: "bot-a",
threadId: "thread-1",
bot: {} as never,
} satisfies ThreadTarget;
const result = await resumeThreadRuns(
{
prisma,
events: events as never,
jobs: jobs as never,
},
actor,
target,
);
expect(result).toEqual({ ok: true, runIds: ["run-cancelled"] });
expect(transaction.task.updateMany).toHaveBeenCalledWith({
where: { id: "task-cancelled" },
data: { status: "queued" },
});
expect(transaction.run.update).toHaveBeenCalledWith({
where: { id: "run-cancelled" },
data: {
status: "queued",
error: null,
completedAt: null,
leaseOwner: null,
leaseExpiresAt: null,
},
});
expect(jobs.enqueue).toHaveBeenCalledWith({
name: "run.continue",
payload: { runId: "run-cancelled" },
replaceKey: "run:run-cancelled",
});
expect(events.notify).toHaveBeenCalledWith("thread-1", 19);
});
});

View File

@ -1307,6 +1307,106 @@ export async function stopThreadRuns(
await teardownCancelledRuns(deps, actor, snapshot); await teardownCancelledRuns(deps, actor, snapshot);
} }
/**
* Resume the latest stopped (`cancelled`) or interrupted (`failed`) run in the thread
* without creating a duplicate user message row.
*/
export async function resumeThreadRuns(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const committed = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true, status: true },
});
if (activeRuns.length > 0) {
return {
runs: activeRuns,
eventSeq: null as number | null,
};
}
const recentTerminals = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 20,
select: {
id: true,
botId: true,
taskId: true,
status: true,
createdAt: true,
completedAt: true,
},
});
const latestTerminal = pickLatestTerminalRun(recentTerminals);
if (
!latestTerminal ||
(latestTerminal.status !== "cancelled" && latestTerminal.status !== "failed")
) {
return {
runs: [] as Array<{ id: string; status: string }>,
eventSeq: null as number | null,
};
}
await tx.task.updateMany({
where: { id: latestTerminal.taskId },
data: { status: "queued" },
});
await tx.run.update({
where: { id: latestTerminal.id },
data: {
status: "queued",
error: null,
completedAt: null,
leaseOwner: null,
leaseExpiresAt: null,
},
});
if (target.kind === "group") {
await touchGroupUpdatedAt(tx, target.groupId);
}
const latestEvent = await tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return {
runs: [{ id: latestTerminal.id, status: "queued" }],
eventSeq: latestEvent?.seq ?? null,
};
});
if (committed.eventSeq !== null) {
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread resume realtime notification", error);
});
}
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
return {
ok: true as const,
runIds: committed.runs.map((run) => run.id),
};
}
/** /**
* Promote queued follow-up messages immediately: cancel any currently active runs * Promote queued follow-up messages immediately: cancel any currently active runs
* holding up the queue without discarding unclaimed steering messages, create a * holding up the queue without discarding unclaimed steering messages, create a

View File

@ -147,7 +147,11 @@ export async function persistVoiceCredential(
let voiceId = input.voiceId?.trim() ?? ""; let voiceId = input.voiceId?.trim() ?? "";
if (!voiceId) { if (!voiceId) {
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal)); const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
voiceId = voices[0]?.id ?? ""; const germanVoice =
input.provider === "kokoro"
? voices.find((v) => v.id === "df_eva" || /^d[fm]_/i.test(v.id))
: undefined;
voiceId = germanVoice?.id ?? voices[0]?.id ?? "";
} }
const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal)); const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
const cred = await withSerializableRetry(() => const cred = await withSerializableRetry(() =>

View File

@ -336,6 +336,7 @@ function Thread() {
const [replyTarget, setReplyTarget] = useState<MobileMessage | null>(null); const [replyTarget, setReplyTarget] = useState<MobileMessage | null>(null);
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null); const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [stopped, setStopped] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false);
const [markdownPreview, setMarkdownPreview] = useState<MarkdownArtifactPreviewTarget | null>( const [markdownPreview, setMarkdownPreview] = useState<MarkdownArtifactPreviewTarget | null>(
@ -1134,6 +1135,7 @@ function Thread() {
setReplyTarget(null); setReplyTarget(null);
setAttachmentNotice(null); setAttachmentNotice(null);
setError(null); setError(null);
setStopped(false);
}, [threadKey]); }, [threadKey]);
function updateDraft(value: string) { function updateDraft(value: string) {
@ -1228,6 +1230,7 @@ function Thread() {
if (initialBotTarget) cancelFocusPrompt(initialBotTarget); if (initialBotTarget) cancelFocusPrompt(initialBotTarget);
}; };
setSending(true); setSending(true);
setStopped(false);
setError(null); setError(null);
try { try {
if (plan.shouldRunRoutines) { if (plan.shouldRunRoutines) {
@ -1337,12 +1340,16 @@ function Thread() {
const targetGroupId = groupId; const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sending) return; if ((!targetBotId && !targetGroupId) || sending) return;
setSending(true); setSending(true);
setStopped(false);
setError(null); setError(null);
try { try {
await rpc( await rpc(
"threads/stop", "threads/stop",
targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }, targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },
); );
if (isCurrentTarget(targetBotId, targetGroupId)) {
setStopped(true);
}
} catch (err) { } catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) { if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Failed to stop work")); setError(err instanceof Error ? err.message : t("Failed to stop work"));
@ -1362,6 +1369,30 @@ function Thread() {
} }
} }
async function resume() {
const targetBotId = botId;
const targetGroupId = groupId;
if ((!targetBotId && !targetGroupId) || sending) return;
setSending(true);
setStopped(false);
setError(null);
try {
await rpc(
"threads/resume",
targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },
);
if (isCurrentTarget(targetBotId, targetGroupId)) {
await refresh();
}
} catch (err) {
if (isCurrentTarget(targetBotId, targetGroupId)) {
setError(err instanceof Error ? err.message : t("Could not continue"));
}
} finally {
setSending(false);
}
}
const [sendingNowId, setSendingNowId] = useState<string | null>(null); const [sendingNowId, setSendingNowId] = useState<string | null>(null);
async function sendQueuedNow(message: MobileMessage) { async function sendQueuedNow(message: MobileMessage) {
@ -1925,7 +1956,70 @@ function Thread() {
> >
{error ? <Text style={{ color: tokens.mutedForeground, marginTop: 12 }}>{error}</Text> : null} {error ? <Text style={{ color: tokens.mutedForeground, marginTop: 12 }}>{error}</Text> : null}
{runError ? ( {runError ? (
<Text style={{ color: tokens.destructive, marginTop: 12 }}>{runError}</Text> <View
style={{
marginTop: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<Text style={{ color: tokens.destructive, flex: 1 }}>{runError}</Text>
{!working ? (
<Pressable
accessibilityRole="button"
testID="thread-resume-button"
disabled={sending}
onPress={() => void resume()}
style={{
borderRadius: 999,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 12,
paddingVertical: 6,
opacity: sending ? 0.5 : 1,
}}
>
<Text style={{ color: tokens.foreground, fontSize: 13, fontWeight: "600" }}>
{t("Resume")}
</Text>
</Pressable>
) : null}
</View>
) : stopped && !working ? (
<View
testID="thread-stopped-banner"
style={{
marginTop: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<Text style={{ color: tokens.mutedForeground, flex: 1 }}>{t("Stopped")}</Text>
<Pressable
accessibilityRole="button"
testID="thread-resume-button"
disabled={sending}
onPress={() => void resume()}
style={{
borderRadius: 999,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 12,
paddingVertical: 6,
opacity: sending ? 0.5 : 1,
}}
>
<Text style={{ color: tokens.foreground, fontSize: 13, fontWeight: "600" }}>
{t("Resume")}
</Text>
</Pressable>
</View>
) : null} ) : null}
<View style={{ flex: 1, position: "relative" }}> <View style={{ flex: 1, position: "relative" }}>
{showPinnedPage ? ( {showPinnedPage ? (

View File

@ -413,6 +413,7 @@ export function ShellPage() {
const inputQueueRef = useRef(new OptimisticInputQueue()); const inputQueueRef = useRef(new OptimisticInputQueue());
const [optimisticMessages, setOptimisticMessages] = useState<OptimisticMessage[]>([]); const [optimisticMessages, setOptimisticMessages] = useState<OptimisticMessage[]>([]);
const [sendError, setSendError] = useState<string | null>(null); const [sendError, setSendError] = useState<string | null>(null);
const [stoppedThreadKey, setStoppedThreadKey] = useState<string | null>(null);
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null); const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
// Safety watchdog: clear a stuck send only when no run is active (local models can take longer). // Safety watchdog: clear a stuck send only when no run is active (local models can take longer).
@ -1245,15 +1246,29 @@ export function ShellPage() {
"df_kerstin", "df_kerstin",
] as const; ] as const;
if (!botId) return "df_eva"; if (!botId) return "df_eva";
let hash = 0; const idx = botsRef.current.findIndex((b) => b.id === botId);
if (idx >= 0) {
return germanVoices[idx % germanVoices.length] ?? "df_eva";
}
let hash = 2166136261;
for (let i = 0; i < botId.length; i += 1) { for (let i = 0; i < botId.length; i += 1) {
hash = (hash * 31 + botId.charCodeAt(i)) >>> 0; hash ^= botId.charCodeAt(i);
hash = Math.imul(hash, 16777619) >>> 0;
} }
return germanVoices[hash % germanVoices.length] ?? "df_eva"; return germanVoices[hash % germanVoices.length] ?? "df_eva";
}, },
[], [],
); );
useEffect(() => {
const unsubSpeech = speaker.subscribe((state) => {
setSpeakingMessageId(state.status === "idle" ? null : (state.messageId ?? null));
});
return () => {
unsubSpeech();
};
}, []);
useEffect(() => { useEffect(() => {
void rpc.voice void rpc.voice
.status() .status()
@ -1277,27 +1292,18 @@ export function ShellPage() {
setVoiceStatus(status); setVoiceStatus(status);
}) })
.catch(() => undefined); .catch(() => undefined);
const unsubSpeech = speaker.subscribe((state) => { }, [currentSpaceId]);
setSpeakingMessageId(state.status === "idle" ? null : (state.messageId ?? null));
});
return () => {
unsubSpeech();
};
}, []);
const migratedBotIdsRef = useRef(new Set<string>());
useEffect(() => { useEffect(() => {
if (!bots.length) return; if (!bots.length) return;
const migratedKey = "rakazo:voice-de-default-v2";
try {
if (localStorage.getItem(migratedKey) === "1") return;
localStorage.setItem(migratedKey, "1");
} catch {
return;
}
const staleBots = bots.filter( const staleBots = bots.filter(
(b) => b.autoSpeak === false || !b.voiceId || /^a[fm]_/i.test(b.voiceId), (b) =>
!migratedBotIdsRef.current.has(b.id) &&
(b.autoSpeak === false || !b.voiceId || /^a[fm]_/i.test(b.voiceId)),
); );
if (!staleBots.length) return; if (!staleBots.length) return;
for (const b of staleBots) migratedBotIdsRef.current.add(b.id);
void Promise.all( void Promise.all(
staleBots.map((b) => staleBots.map((b) =>
rpc.bots rpc.bots
@ -1325,7 +1331,13 @@ export function ShellPage() {
if (!threadKey || !currentSnapshot) return; if (!threadKey || !currentSnapshot) return;
const lastBot = [...currentSnapshot.messages] const lastBot = [...currentSnapshot.messages]
.reverse() .reverse()
.find((message) => message.role === "bot"); .find(
(message) =>
message.role === "bot" &&
!message.id.startsWith("progress:") &&
!message.id.startsWith("subagent:") &&
Boolean(spokenSummaryFromBlocks(message.blocks)),
);
if (autoSpokenBotId.current !== threadKey) { if (autoSpokenBotId.current !== threadKey) {
autoSpokenBotId.current = threadKey; autoSpokenBotId.current = threadKey;
autoSpoken.current = lastBot?.id ?? null; autoSpoken.current = lastBot?.id ?? null;
@ -1344,12 +1356,6 @@ export function ShellPage() {
autoSpoken.current = lastBot?.id ?? null; autoSpoken.current = lastBot?.id ?? null;
return; return;
} }
if (
currentSnapshot.run &&
["running", "queued", "leased"].includes(currentSnapshot.run.status)
) {
return;
}
if (!lastBot || lastBot.id === autoSpoken.current) return; if (!lastBot || lastBot.id === autoSpoken.current) return;
const text = spokenSummaryFromBlocks(lastBot.blocks); const text = spokenSummaryFromBlocks(lastBot.blocks);
if (!text) return; if (!text) return;
@ -2592,6 +2598,7 @@ export function ShellPage() {
hasAttachments: attachments.length > 0, hasAttachments: attachments.length > 0,
}); });
if (plan.isNoOp) return; if (plan.isNoOp) return;
setStoppedThreadKey(null);
const reroutedToGroup = Boolean( const reroutedToGroup = Boolean(
plan.rerouteGroupId && plan.rerouteGroupId !== initialGroupTarget, plan.rerouteGroupId && plan.rerouteGroupId !== initialGroupTarget,
); );
@ -2881,6 +2888,7 @@ export function ShellPage() {
} }
// Stop has no terminal event; clear run UI before refresh races with in-flight gets. // Stop has no terminal event; clear run UI before refresh races with in-flight gets.
if (activeGroupId.current === groupTarget) { if (activeGroupId.current === groupTarget) {
setStoppedThreadKey(`group:${groupTarget}`);
updateSnapshot((prev) => updateSnapshot((prev) =>
prev && prev.groupId === groupTarget ? clearActiveThreadRuns(prev) : prev, prev && prev.groupId === groupTarget ? clearActiveThreadRuns(prev) : prev,
); );
@ -2902,6 +2910,7 @@ export function ShellPage() {
// superseded in-flight refresh (older cursor) cannot leave Stop enabled / Take control // superseded in-flight refresh (older cursor) cannot leave Stop enabled / Take control
// blocked while the API is already idle. // blocked while the API is already idle.
if (activeBotId.current === botTarget) { if (activeBotId.current === botTarget) {
setStoppedThreadKey(`bot:${botTarget}`);
updateSnapshot((prev) => updateSnapshot((prev) =>
!prev || (prev.botId !== botTarget && prev.botId) ? prev : clearActiveThreadRuns(prev), !prev || (prev.botId !== botTarget && prev.botId) ? prev : clearActiveThreadRuns(prev),
); );
@ -2915,6 +2924,45 @@ export function ShellPage() {
setSending(false); setSending(false);
} }
}, [sending, t]); }, [sending, t]);
const resumeRun = useCallback(
async (fallbackPrompt?: string) => {
const botTarget = activeBotId.current;
const groupTarget = activeGroupId.current;
if (!botTarget && !groupTarget) return;
setSending(true);
setSendError(null);
setStoppedThreadKey(null);
const failedRunId = snapshotRef.current?.run?.id ?? null;
if (failedRunId) {
rememberSeenRunErrorId(failedRunId);
setDismissedRunErrorIds((current) => new Set(current).add(failedRunId));
}
try {
const res = await rpc.threads.resume(
groupTarget ? { groupId: groupTarget } : { botId: botTarget! },
);
if (res.runIds.length === 0 && fallbackPrompt?.trim()) {
await sendMessage(fallbackPrompt);
return;
}
if (groupTarget && activeGroupId.current === groupTarget) {
await refreshGroupThreadRef.current(groupTarget).catch(() => undefined);
} else if (botTarget && activeBotId.current === botTarget) {
await refreshThreadRef.current(botTarget).catch(() => undefined);
}
} catch (error) {
const stillHere = groupTarget
? activeGroupId.current === groupTarget
: activeBotId.current === botTarget;
if (stillHere) {
setSendError(error instanceof Error ? error.message : t`Could not continue`);
}
} finally {
setSending(false);
}
},
[sendMessage, t],
);
const stopTeaching = useCallback(async () => { const stopTeaching = useCallback(async () => {
const id = activeBotId.current; const id = activeBotId.current;
if (!id || teachBusy) return; if (!id || teachBusy) return;
@ -4333,7 +4381,7 @@ export function ShellPage() {
voiceReady={Boolean(voiceStatus?.ready)} voiceReady={Boolean(voiceStatus?.ready)}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
onSpeak={speakMessage} onSpeak={speakMessage}
onRestartTask={sendMessage} onRestartTask={resumeRun}
onSendQueuedNow={sendQueuedMessageNow} onSendQueuedNow={sendQueuedMessageNow}
onRemoveQueuedMessage={removeQueuedMessage} onRemoveQueuedMessage={removeQueuedMessage}
/> />
@ -4366,6 +4414,13 @@ export function ShellPage() {
onRemoveAttachment={removeAttachment} onRemoveAttachment={removeAttachment}
onSend={sendMessage} onSend={sendMessage}
onStop={stopRun} onStop={stopRun}
stopped={
!composerRunning &&
stoppedThreadKey ===
(inGroup ? `group:${groupId}` : `bot:${active?.id}`)
}
onResume={resumeRun}
onDismissStopped={() => setStoppedThreadKey(null)}
onVoice={ onVoice={
active || inGroup active || inGroup
? () => { ? () => {

View File

@ -20,6 +20,7 @@ import {
ModelTierSchema, ModelTierSchema,
normalizeBotTags, normalizeBotTags,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { GERMAN_VOICE_PRESETS, germanVoiceForBot, voicesForLocale } from "@rakazo/core";
import { import {
Button, Button,
Input, Input,
@ -37,6 +38,7 @@ import { connectedModelOptions } from "../../lib/connected-models";
import { lazyWithRecovery } from "../../lib/lazy-recovery"; import { lazyWithRecovery } from "../../lib/lazy-recovery";
import { modelTierLabel } from "../../lib/model-tier-label"; import { modelTierLabel } from "../../lib/model-tier-label";
import { rpc } from "../../lib/rpc"; import { rpc } from "../../lib/rpc";
import { readUiLocale } from "../../lib/ui-locale";
import { BotCapabilitiesFields } from "./bot-capabilities"; import { BotCapabilitiesFields } from "./bot-capabilities";
const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () => const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () =>
@ -280,8 +282,8 @@ export function BotSettings({
const [computerMode, setComputerMode] = useState(bot.computerMode); const [computerMode, setComputerMode] = useState(bot.computerMode);
const [memoryScope, setMemoryScope] = useState(bot.memoryScope); const [memoryScope, setMemoryScope] = useState(bot.memoryScope);
const [autoSpeak, setAutoSpeak] = useState(bot.autoSpeak); const [autoSpeak, setAutoSpeak] = useState(bot.autoSpeak);
const [voiceId, setVoiceId] = useState(bot.voiceId ?? ""); const [voiceId, setVoiceId] = useState(bot.voiceId || germanVoiceForBot(bot.id, bot.voiceId));
const [voices, setVoices] = useState<VoiceInfo[]>([]); const [voices, setVoices] = useState<VoiceInfo[]>(GERMAN_VOICE_PRESETS);
const [modelKey, setModelKey] = useState( const [modelKey, setModelKey] = useState(
bot.modelTier bot.modelTier
? tierOptionKey(bot.modelTier) ? tierOptionKey(bot.modelTier)
@ -314,8 +316,15 @@ export function BotSettings({
useEffect(() => { useEffect(() => {
void rpc.voice void rpc.voice
.voices({}) .voices({})
.then(setVoices) .then((listed) => {
.catch(() => setVoices([])); const uiLocale = readUiLocale();
const isGerman = uiLocale.toLowerCase().startsWith("de");
const hasGerman = listed.some((v) => /^d[fm]_/i.test(v.id));
const combined = isGerman && !hasGerman ? [...GERMAN_VOICE_PRESETS, ...listed] : listed;
const filtered = voicesForLocale(combined, uiLocale);
setVoices(filtered.length > 0 ? filtered : GERMAN_VOICE_PRESETS);
})
.catch(() => setVoices(GERMAN_VOICE_PRESETS));
void rpc.bots void rpc.bots
.builtinTools({ botId: bot.id }) .builtinTools({ botId: bot.id })
.then(setToolNames) .then(setToolNames)
@ -675,7 +684,7 @@ export function BotSettings({
<NativeSelectOption value="">{t`Account default`}</NativeSelectOption> <NativeSelectOption value="">{t`Account default`}</NativeSelectOption>
{voices.map((voice) => ( {voices.map((voice) => (
<NativeSelectOption key={voice.id} value={voice.id}> <NativeSelectOption key={voice.id} value={voice.id}>
{voice.label} {voice.description ? `${voice.label} (${voice.description})` : voice.label}
</NativeSelectOption> </NativeSelectOption>
))} ))}
</NativeSelect> </NativeSelect>

View File

@ -31,6 +31,7 @@ import {
Play, Play,
Plus, Plus,
Puzzle, Puzzle,
RotateCcw,
Settings, Settings,
Square, Square,
X, X,
@ -83,6 +84,9 @@ export const Composer = memo(function Composer({
onConnectModel, onConnectModel,
onAutopilot, onAutopilot,
activePlan, activePlan,
stopped = false,
onResume,
onDismissStopped,
}: { }: {
draftKey?: string; draftKey?: string;
running: boolean; running: boolean;
@ -116,6 +120,9 @@ export const Composer = memo(function Composer({
/** Hand the order to the autopilot; the composer menu offers it only when set. */ /** Hand the order to the autopilot; the composer menu offers it only when set. */
onAutopilot?: (goal: string, until?: string) => Promise<void>; onAutopilot?: (goal: string, until?: string) => Promise<void>;
activePlan?: ComposerActivePlan | null; activePlan?: ComposerActivePlan | null;
stopped?: boolean;
onResume?: () => void | Promise<void>;
onDismissStopped?: () => void;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]); const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]);
@ -484,6 +491,18 @@ export const Composer = memo(function Composer({
className="mb-3 flex items-center gap-2 rounded-[14px] border border-destructive/40 bg-destructive/10 px-4 py-2 text-ui-body-sm text-destructive" className="mb-3 flex items-center gap-2 rounded-[14px] border border-destructive/40 bg-destructive/10 px-4 py-2 text-ui-body-sm text-destructive"
> >
<span className="min-w-0 flex-1">{sendError ?? runError}</span> <span className="min-w-0 flex-1">{sendError ?? runError}</span>
{runError && !sendError && onResume ? (
<button
type="button"
data-testid="composer-error-resume"
disabled={sending}
onClick={() => void onResume()}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-destructive/30 bg-background/80 px-2.5 py-1 text-ui-label font-medium text-foreground transition-colors hover:bg-background disabled:opacity-50 cursor-pointer"
>
<RotateCcw className="size-3" aria-hidden="true" />
<Trans>Resume</Trans>
</button>
) : null}
<button <button
type="button" type="button"
aria-label={t`Dismiss error`} aria-label={t`Dismiss error`}
@ -497,6 +516,39 @@ export const Composer = memo(function Composer({
<X strokeWidth={2} className="size-icon-sm" /> <X strokeWidth={2} className="size-icon-sm" />
</button> </button>
</div> </div>
) : stopped && !running && onResume ? (
<div
data-testid="composer-stopped"
className="mb-3 flex items-center gap-2 rounded-[14px] border border-border bg-muted px-4 py-2 text-ui-body-sm text-muted-foreground"
>
<span className="min-w-0 flex-1">
<Trans>Stopped</Trans>
</span>
<button
type="button"
data-testid="composer-resume"
disabled={sending}
onClick={() => void onResume()}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border bg-background px-2.5 py-1 text-ui-label font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50 cursor-pointer"
>
<RotateCcw className="size-3" aria-hidden="true" />
<Trans>Resume</Trans>
</button>
{onDismissStopped ? (
<button
type="button"
aria-label={t`Dismiss`}
data-testid="composer-stopped-dismiss"
onClick={() => {
onDismissStopped();
window.requestAnimationFrame(() => textareaRef.current?.focus());
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X strokeWidth={2} className="size-icon-sm" />
</button>
) : null}
</div>
) : null} ) : null}
{replyTarget ? ( {replyTarget ? (
<div <div
@ -650,7 +702,7 @@ export const Composer = memo(function Composer({
})} })}
</div> </div>
) : null} ) : null}
{activePlan ? <ComposerTaskPill activePlan={activePlan} /> : null} {activePlan ? <ComposerTaskPill activePlan={activePlan} onResume={onResume} /> : null}
<div <div
data-testid="composer-bar" data-testid="composer-bar"
className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200" className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200"

View File

@ -40,6 +40,8 @@ import {
Maximize2, Maximize2,
MinusCircle, MinusCircle,
Moon, Moon,
PauseCircle,
RotateCcw,
Sun, Sun,
X, X,
ZoomIn, ZoomIn,
@ -1365,7 +1367,13 @@ export function extractActivePlanState(
* Floating task pill (Variant B) docked right above the composer input bar. * Floating task pill (Variant B) docked right above the composer input bar.
* Shows the current active step + live intermediate step summary, and expands upward on tap or swipe-up. * Shows the current active step + live intermediate step summary, and expands upward on tap or swipe-up.
*/ */
export function ComposerTaskPill({ activePlan }: { activePlan: ComposerActivePlan }) { export function ComposerTaskPill({
activePlan,
onResume,
}: {
activePlan: ComposerActivePlan;
onResume?: () => void | Promise<void>;
}) {
const { t } = useLingui(); const { t } = useLingui();
const drawerId = useId(); const drawerId = useId();
const { block, steps, running, startedAt } = activePlan; const { block, steps, running, startedAt } = activePlan;
@ -1431,119 +1439,132 @@ export function ComposerTaskPill({ activePlan }: { activePlan: ComposerActivePla
data-expanded={expanded ? "true" : "false"} data-expanded={expanded ? "true" : "false"}
className="mb-2.5 rounded-[22px] border border-border bg-card/95 px-3.5 pt-2 pb-2.5 shadow-md backdrop-blur-md transition-all duration-200" className="mb-2.5 rounded-[22px] border border-border bg-card/95 px-3.5 pt-2 pb-2.5 shadow-md backdrop-blur-md transition-all duration-200"
> >
<button <div
type="button" aria-hidden="true"
data-testid="composer-task-pill-toggle" className="mx-auto mb-1.5 h-1 w-8 rounded-full bg-border transition-colors"
aria-expanded={expanded} />
aria-controls={drawerId} <div className="flex items-center gap-2">
onPointerDown={(event) => { <button
dragStartYRef.current = event.clientY; type="button"
didSwipeRef.current = false; data-testid="composer-task-pill-toggle"
}} aria-expanded={expanded}
onPointerMove={(event) => { aria-controls={drawerId}
if (dragStartYRef.current === null) return; onPointerDown={(event) => {
const deltaY = event.clientY - dragStartYRef.current; dragStartYRef.current = event.clientY;
if (deltaY < -18 && !expanded) {
didSwipeRef.current = true;
dragStartYRef.current = null;
setExpanded(true);
} else if (deltaY > 18 && expanded) {
didSwipeRef.current = true;
dragStartYRef.current = null;
setExpanded(false);
}
}}
onPointerUp={() => {
dragStartYRef.current = null;
}}
onClick={() => {
if (didSwipeRef.current) {
didSwipeRef.current = false; didSwipeRef.current = false;
return; }}
} onPointerMove={(event) => {
setExpanded((current) => !current); if (dragStartYRef.current === null) return;
}} const deltaY = event.clientY - dragStartYRef.current;
className="w-full cursor-pointer text-start outline-none" if (deltaY < -18 && !expanded) {
> didSwipeRef.current = true;
<div dragStartYRef.current = null;
aria-hidden="true" setExpanded(true);
className="mx-auto mb-1.5 h-1 w-8 rounded-full bg-border transition-colors" } else if (deltaY > 18 && expanded) {
/> didSwipeRef.current = true;
<div className="flex items-center justify-between gap-2.5"> dragStartYRef.current = null;
<div className="flex min-w-0 flex-1 items-center gap-2"> setExpanded(false);
<span className="shrink-0"> }
{isCompleted ? ( }}
<CheckCircle2 className="size-4 text-success" aria-hidden="true" /> onPointerUp={() => {
) : activeStep?.status === "in_progress" || running ? ( dragStartYRef.current = null;
<Loader2 className="size-4 animate-spin text-warning" aria-hidden="true" /> }}
) : ( onClick={() => {
<Circle className="size-4 text-muted-foreground" aria-hidden="true" /> if (didSwipeRef.current) {
)} didSwipeRef.current = false;
</span> return;
<div className="min-w-0 flex-1"> }
<div setExpanded((current) => !current);
data-testid="composer-task-pill-label" }}
className="truncate text-ui-body-sm font-semibold text-foreground" className="min-w-0 flex-1 cursor-pointer text-start outline-none"
dir="auto" >
> <div className="flex items-center justify-between gap-2.5">
{headline} <div className="flex min-w-0 flex-1 items-center gap-2">
</div> <span className="shrink-0">
{showSubline ? ( {isCompleted ? (
<div className="mt-0.5 flex items-center gap-1.5 truncate text-ui-label text-muted-foreground"> <CheckCircle2 className="size-4 text-success" aria-hidden="true" />
{liveTimer ? ( ) : running ? (
<span className="shrink-0 font-mono text-warning tabular-nums"> <Loader2 className="size-4 animate-spin text-warning" aria-hidden="true" />
{liveTimer} ) : (
</span> <PauseCircle className="size-4 text-muted-foreground" aria-hidden="true" />
) : null} )}
{badges.slice(0, 2).map((badge) => ( </span>
<span <div className="min-w-0 flex-1">
key={badge.type} <div
className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-muted-foreground" data-testid="composer-task-pill-label"
> className="truncate text-ui-body-sm font-semibold text-foreground"
<span>·</span> dir="auto"
{badge.icon} >
<span> {headline}
{badge.label}
{badge.count > 1 ? ` (${badge.count})` : ""}
</span>
</span>
))}
{sublineText ? (
<>
{liveTimer || badges.length > 0 ? <span>·</span> : null}
<span className="truncate" dir="auto">
{sublineText}
</span>
</>
) : null}
</div> </div>
) : null} {showSubline ? (
<div className="mt-0.5 flex items-center gap-1.5 truncate text-ui-label text-muted-foreground">
{liveTimer ? (
<span className="shrink-0 font-mono text-warning tabular-nums">
{liveTimer}
</span>
) : null}
{badges.slice(0, 2).map((badge) => (
<span
key={badge.type}
className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-muted-foreground"
>
<span>·</span>
{badge.icon}
<span>
{badge.label}
{badge.count > 1 ? ` (${badge.count})` : ""}
</span>
</span>
))}
{sublineText ? (
<>
{liveTimer || badges.length > 0 ? <span>·</span> : null}
<span className="truncate" dir="auto">
{sublineText}
</span>
</>
) : null}
</div>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span
data-testid="composer-task-pill-progress"
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 font-mono text-ui-label font-medium tabular-nums",
isCompleted
? "border-success/30 bg-success/10 text-success"
: isAwaiting
? "border-warning/30 bg-warning/10 text-warning"
: "border-border bg-background text-foreground",
)}
>
{isCompleted ? t`Done` : `${doneCount}/${total}`}
</span>
<ChevronUp
aria-hidden="true"
className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
expanded && "rotate-180",
)}
/>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> </button>
<span {!running && !isCompleted && onResume ? (
data-testid="composer-task-pill-progress" <button
className={cn( type="button"
"shrink-0 rounded-full border px-2 py-0.5 font-mono text-ui-label font-medium tabular-nums", data-testid="composer-task-pill-resume"
isCompleted onClick={() => void onResume()}
? "border-success/30 bg-success/10 text-success" className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border bg-background px-2.5 py-1 text-ui-label font-medium text-foreground transition-colors hover:bg-accent cursor-pointer"
: isAwaiting >
? "border-warning/30 bg-warning/10 text-warning" <RotateCcw className="size-3" aria-hidden="true" />
: "border-border bg-background text-foreground", <Trans>Resume</Trans>
)} </button>
> ) : null}
{isCompleted ? t`Done` : `${doneCount}/${total}`} </div>
</span>
<ChevronUp
aria-hidden="true"
className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
expanded && "rotate-180",
)}
/>
</div>
</div>
</button>
<div <div
id={drawerId} id={drawerId}
@ -1587,8 +1608,10 @@ export function ComposerTaskPill({ activePlan }: { activePlan: ComposerActivePla
<span className="mt-0.5 shrink-0"> <span className="mt-0.5 shrink-0">
{done ? ( {done ? (
<CheckCircle2 className="size-4 text-success" aria-hidden="true" /> <CheckCircle2 className="size-4 text-success" aria-hidden="true" />
) : active ? ( ) : active && running ? (
<Loader2 className="size-4 animate-spin text-warning" aria-hidden="true" /> <Loader2 className="size-4 animate-spin text-warning" aria-hidden="true" />
) : active ? (
<PauseCircle className="size-4 text-muted-foreground" aria-hidden="true" />
) : skipped ? ( ) : skipped ? (
<MinusCircle className="size-4 text-muted-foreground" aria-hidden="true" /> <MinusCircle className="size-4 text-muted-foreground" aria-hidden="true" />
) : ( ) : (

View File

@ -52,6 +52,8 @@ describe("github broker host git", () => {
it("maps computer paths onto the host workspace", () => { it("maps computer paths onto the host workspace", () => {
expect(hostPathFor(session, "/home/rakazo")).toBe("/srv/homes/chief"); expect(hostPathFor(session, "/home/rakazo")).toBe("/srv/homes/chief");
expect(hostPathFor(session, "/home/rakazo/repos/demo")).toBe("/srv/homes/chief/repos/demo"); expect(hostPathFor(session, "/home/rakazo/repos/demo")).toBe("/srv/homes/chief/repos/demo");
expect(hostPathFor(session, "~")).toBe("/srv/homes/chief");
expect(hostPathFor(session, "~/repos/demo")).toBe("/srv/homes/chief/repos/demo");
expect(hostPathFor(session, "/home/rakazo/../etc")).toBeNull(); expect(hostPathFor(session, "/home/rakazo/../etc")).toBeNull();
expect(hostPathFor(session, "/tmp")).toBeNull(); expect(hostPathFor(session, "/tmp")).toBeNull();
expect(hostPathFor(session, "/home/rakazoX")).toBeNull(); expect(hostPathFor(session, "/home/rakazoX")).toBeNull();
@ -153,6 +155,19 @@ describe("github broker remotes", () => {
repo, repo,
), ),
).rejects.toThrow(/linked repository remote/); ).rejects.toThrow(/linked repository remote/);
await expect(
assertRemoteAllowed(
linked,
["git", "clone", "https://github.com/cynar/umbra.git", "/home/rakazo/umbra"],
repo,
),
).resolves.toBeUndefined();
await expect(
assertRemoteAllowed(linked, ["git", "fetch", "origin"], repo),
).resolves.toBeUndefined();
await expect(
assertRemoteAllowed(linked, ["git", "ls-remote", "https://github.com/cynar/umbra.git"], repo),
).resolves.toBeUndefined();
} finally { } finally {
rmSync(repo, { recursive: true, force: true }); rmSync(repo, { recursive: true, force: true });
} }

View File

@ -38,8 +38,13 @@ const GIT_CREDENTIAL_ARGS = [
/** Maps a path inside the computer onto the host workspace; anything outside the mount stays unmapped. */ /** Maps a path inside the computer onto the host workspace; anything outside the mount stays unmapped. */
export function hostPathFor(session: GithubBrokerSession, containerPath: string): string | null { export function hostPathFor(session: GithubBrokerSession, containerPath: string): string | null {
const root = session.containerRoot.replace(/\/$/, ""); const root = session.containerRoot.replace(/\/$/, "");
if (containerPath !== root && !containerPath.startsWith(`${root}/`)) return null; const normalized = containerPath.startsWith("~/")
const resolved = path.resolve(session.workspacePath, path.relative(root, containerPath)); ? `${root}/${containerPath.slice(2)}`
: containerPath === "~"
? root
: containerPath;
if (normalized !== root && !normalized.startsWith(`${root}/`)) return null;
const resolved = path.resolve(session.workspacePath, path.relative(root, normalized));
const inside = path.relative(session.workspacePath, resolved); const inside = path.relative(session.workspacePath, resolved);
return inside.startsWith("..") || path.isAbsolute(inside) ? null : resolved; return inside.startsWith("..") || path.isAbsolute(inside) ? null : resolved;
} }
@ -195,6 +200,8 @@ export async function assertRemoteAllowed(
cwd = session.workspacePath, cwd = session.workspacePath,
) { ) {
if (!session.allowedRemoteUrl) return; if (!session.allowedRemoteUrl) return;
const sub = argv[1];
if (sub !== "push") return;
const remoteArg = argv.find((part, index) => index > 1 && !part.startsWith("-")); const remoteArg = argv.find((part, index) => index > 1 && !part.startsWith("-"));
const candidate = remoteArg const candidate = remoteArg
? normalizeGitRemoteUrl(remoteArg) ? normalizeGitRemoteUrl(remoteArg)
@ -202,7 +209,7 @@ export async function assertRemoteAllowed(
: ((await readRemoteUrl(session, remoteArg, cwd)) ?? remoteArg) : ((await readRemoteUrl(session, remoteArg, cwd)) ?? remoteArg)
: await readRemoteUrl(session, "origin", cwd); : await readRemoteUrl(session, "origin", cwd);
if (candidate && !gitRemoteMatchesAllowlist(candidate, session.allowedRemoteUrl)) { if (candidate && !gitRemoteMatchesAllowlist(candidate, session.allowedRemoteUrl)) {
throw new Error("Push and fetch are limited to this space's linked repository remote."); throw new Error("Push is limited to this space's linked repository remote.");
} }
} }

View File

@ -79,12 +79,14 @@ const SKILLS: Array<{ name: string; description: string; body: string }> = [
- Laconic / Sovereign: 2–4 words of quiet, unmistakable conviction. - Laconic / Sovereign: 2–4 words of quiet, unmistakable conviction.
- Rhythmic / Vernacular: human warmth, dry wit, cadence, or cultural wink that people quote to friends. - Rhythmic / Vernacular: human warmth, dry wit, cadence, or cultural wink that people quote to friends.
Challenge the draft with run_subagent "creative-director" (model_tier standard): apply the Human Dinner-Table Test ("Would a real person actually say or share this, or does it sound like an ad?") to kill anything interchangeable and sharpen the strongest 4–6 claims plus voice guidelines (how the brand speaks on buttons, packaging, and footnotes). Challenge the draft with run_subagent "creative-director" (model_tier standard): apply the Human Dinner-Table Test ("Would a real person actually say or share this, or does it sound like an ad?") to kill anything interchangeable and sharpen the strongest 4–6 claims plus voice guidelines (how the brand speaks on buttons, packaging, and footnotes).
3. Art-Directed Key Visuals with Tactile Humanity on ComfyUI. When generate_image is available, never substitute hand-coded SVG clipart for brand photography, moodboards, product shots, or campaign visuals. Call generate_image with continue_after set to true so the rendered files land on your computer in attachments/<artifactId>.<ext>. Direct authentic, lived-in editorial photography rather than sterile symmetry: specify human gestures in motion, natural skin texture, water droplets on raw travertine, crumpled linen, warm morning side-light or dramatic chiaroscuro, lens and focal length (e.g. 50mm f/1.4 editorial portrait, 90mm macro studio shot, medium-format analog film grain), and the exact brand color palette, paired with a clean negative_prompt. 3. ComfyUI Logo Exploration, Brand Mockups, and Key Visuals. When generate_image is available, never hand-type crude single-primitive SVG clipart for logos, signets, or imagery. Queue your ComfyUI renders with continue_after set to true so the files land in attachments/<artifactId>.<ext>, then end your turn and wait to be woken with the rendered files before building the final lockups and Brand Book:
4. Hybrid Logo System (Wordmark + Signet + Lockups). Never deliver a crude single-primitive SVG icon. Construct a three-part logo suite: - Logo / Signet / Monogram Render: call generate_image (aspect square, count 2–4, continue_after true) with a studio-grade identity prompt — e.g. minimalist flat vector emblem or bespoke monogram on a pure solid background, Pentagram / Swiss modernist negative-space geometry, modulated stroke weights, razor-sharp silhouette, zero 3D bevels or drop shadows.
- Typographic Wordmark: clean SVG loading the brand's display Google Font via @import, with deliberate weight/italic contrast, tight optical letter-spacing, and custom terminal or ligature details. - Tactile Brand Mockups & Editorial Key Visuals: call generate_image (continue_after true) for lived-in editorial photography and physical brand applications (debossed cotton paper card, ceramic vessel, brushed metal signage, linen tote, or architectural facade) specifying human gestures in motion, natural textures, lens and focal length (e.g. 50mm f/1.4 editorial portrait, 90mm macro studio shot, medium-format analog film grain), and the exact brand color palette, paired with a clean negative_prompt.
- Signet / Monogram: either generate a high-contrast black-on-white emblem concept via generate_image (continue_after true) and trace or refine it into crisp vector paths on your computer, or construct a mathematically precise bezier emblem/monogram using golden-ratio geometry, modulated stroke weights, and clever negative space. 4. Hybrid Logo System (Image-to-Vector Signet + Path Wordmark + Lockups). Once woken with the rendered files in attachments/<artifactId>.<ext>, construct a three-part logo suite:
- Three Lockups: Primary horizontal/stacked lockup, 1:1 monogram/favicon mark, and monochrome inverted variants tested on both light Canvas and dark Ink backgrounds. - Vectorized Signet / Emblem: trace the strongest generated black-on-white emblem from attachments/ into crisp, thresholded SVG bezier <path> geometry on your computer with shell (e.g. Python bitmap-to-SVG contour tracing onto an explicit solid background <rect>), and also attach the high-resolution ComfyUI logo mark and mockups directly.
5. Brand Book Presentation and Visual Check. Build a self-contained HTML Brand Book (brand-book.html) with write_file that showcases the human truth manifesto, the four slogan territories, voice rules, color swatches, live Google Fonts typography specimens, the three SVG logo lockups on light and dark backgrounds, the generated ComfyUI key visuals from attachments/, and real-world applications. When browser_navigate and computer_observe are available, open the Brand Book and inspect the rendered screenshot to verify kerning, contrast, and loaded imagery before attaching deliverables with attach_file, summarizing with show_card, and saving the locked brand tokens with save_memory or remember.`, - Typographic Wordmark: never rely on external @import Google Font URLs inside standalone .svg files (server rasterizers and <img> tags strip external font requests and fall back to generic system fonts). Either convert the downloaded Google Font glyphs into true SVG <path> outlines with shell, render the lockup in headless Chrome, or present live @import Google Font wordmarks inline inside the HTML Brand Book <head>.
- Three Lockups: Primary horizontal/stacked lockup, 1:1 monogram/signet mark, and monochrome inverted variants on explicit light Canvas and dark Ink background <rect> canvases so they never disappear on dark UI surfaces.
5. Brand Book Presentation and Visual Check. Build a self-contained HTML Brand Book (brand-book.html) with write_file that loads the Google Fonts in <head> and showcases the human truth manifesto, the four slogan territories, voice rules, color swatches, live typography specimens, the logo lockups on light and dark backgrounds, and the generated ComfyUI logo marks, mockups, and key visuals from attachments/. When browser_navigate and computer_observe are available, open the Brand Book and inspect the rendered screenshot to verify kerning, contrast, and loaded imagery before attaching deliverables with attach_file, summarizing with show_card, and saving the locked brand tokens with save_memory or remember.`,
}, },
{ {
name: "Bespoke web design", name: "Bespoke web design",

View File

@ -177,7 +177,7 @@ export const builtinAgentTools: ConnectorTool[] = [
{ {
name: "read_file", name: "read_file",
description: description:
"Read a UTF-8 text file from this bot's home. Pass query when looking for specific sections, facts, or numbers in a long document or attachment so the most relevant passages are extracted. On a Team Computer, relative paths use the bot folder and shared/... accesses shared work. Open visual or binary files with open_path instead.", "Read a UTF-8 text file from this bot's home. Pass start_line and end_line (1-indexed, inclusive) to read a specific line range of a large file, or query when looking for specific sections, facts, or numbers in a long document or attachment so the most relevant passages are extracted. On a Team Computer, relative paths use the bot folder and shared/... accesses shared work. Open visual or binary files with open_path instead.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@ -187,6 +187,14 @@ export const builtinAgentTools: ConnectorTool[] = [
description: description:
"Optional focus query to extract the relevant sections and facts from a long document.", "Optional focus query to extract the relevant sections and facts from a long document.",
}, },
start_line: {
type: "integer",
description: "Optional 1-indexed first line to read (inclusive).",
},
end_line: {
type: "integer",
description: "Optional 1-indexed last line to read (inclusive).",
},
}, },
required: ["path"], required: ["path"],
}, },
@ -194,7 +202,7 @@ export const builtinAgentTools: ConnectorTool[] = [
{ {
name: "write_file", name: "write_file",
description: description:
"Write a UTF-8 file into this bot's home. On a Team Computer, relative paths use the bot folder; use shared/... only for work other bots should share.", "Write or overwrite a UTF-8 file in this bot's home. To modify an existing file without rewriting all of it, prefer edit_file. On a Team Computer, relative paths use the bot folder; use shared/... only for work other bots should share.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@ -204,6 +212,30 @@ export const builtinAgentTools: ConnectorTool[] = [
required: ["path", "content"], required: ["path", "content"],
}, },
}, },
{
name: "edit_file",
description:
"Replace an exact text snippet in an existing UTF-8 file in this bot's home without rewriting the whole file. Prefer this over write_file when modifying existing files.",
inputSchema: {
type: "object",
properties: {
path: { type: "string" },
old_text: {
type: "string",
description: "Exact existing text to find in the file.",
},
new_text: {
type: "string",
description: "Replacement text.",
},
replace_all: {
type: "boolean",
description: "Replace all occurrences of old_text instead of requiring a single match.",
},
},
required: ["path", "old_text", "new_text"],
},
},
{ {
name: "attach_file", name: "attach_file",
description: description:
@ -681,7 +713,7 @@ export const builtinAgentTools: ConnectorTool[] = [
{ {
name: "generate_image", name: "generate_image",
description: description:
"Generate up to 4 images from a prompt on the self-hosted ComfyUI server, or rework a source image (image-to-image). Write art-directed prompts with human warmth, tactile materials, camera/lens, lighting mood, and palette; set continue_after true when you need the rendered files in your workspace (attachments/<artifactId>.<ext>) for a website, brand book, or next step.", "Generate up to 4 images from a prompt on the self-hosted ComfyUI server, or rework a source image (image-to-image). Use this for logos, signets, monograms, brand marks, packaging/signage mockups, key visuals, moodboards, and photography. For logos and emblems, prompt for high-contrast flat vector marks, negative-space geometry, or luxury tactile marks on a pure solid background; for photography, specify tactile materials, camera/lens, lighting mood, and palette. Set continue_after true when you need the rendered files in your workspace (attachments/<artifactId>.<ext>) for vector tracing, a brand book, a website, or a next step.",
inputSchema: mediaParameters(GenerateImageInputSchema), inputSchema: mediaParameters(GenerateImageInputSchema),
}, },
{ {

View File

@ -77,6 +77,12 @@ describe("createComfyUiProvider", () => {
expect(Object.values(texts).map((node) => node.inputs.text)).toEqual( expect(Object.values(texts).map((node) => node.inputs.text)).toEqual(
expect.arrayContaining(["a lighthouse", "fog"]), expect.arrayContaining(["a lighthouse", "fog"]),
); );
await provider.generateImage(IMAGE);
const defaultNegativeTexts = Object.values(emulator.prompts[1]!.graph).map((node) =>
String(node.inputs.text ?? ""),
);
expect(defaultNegativeTexts.some((text) => text.includes("amateur clipart"))).toBe(true);
expect(defaultNegativeTexts.some((text) => text.includes("artificial symmetry"))).toBe(false);
}); });
it("uses the configured model, else the first checkpoint the video workflows do not use", async () => { it("uses the configured model, else the first checkpoint the video workflows do not use", async () => {

View File

@ -57,7 +57,7 @@ const VIDEO_STEPS = 30;
const DEFAULT_STRENGTH = 0.6; const DEFAULT_STRENGTH = 0.6;
const DEFAULT_NEGATIVE_PROMPTS = { const DEFAULT_NEGATIVE_PROMPTS = {
image: image:
"blurry, low quality, distorted, watermark, text, plastic skin, airbrushed, CGI render, stock photo, generic corporate, oversaturated, artificial symmetry", "blurry, low quality, distorted, watermark, signature, gibberish text, amateur clipart, 3D bevel, lens flare, plastic skin, airbrushed, CGI render, stock photo, generic corporate, oversaturated",
video: video:
"worst quality, inconsistent motion, blurry, jittery, distorted, plastic skin, stock footage, watermark", "worst quality, inconsistent motion, blurry, jittery, distorted, plastic skin, stock footage, watermark",
}; };

View File

@ -300,7 +300,7 @@ export async function loadDelegatedTaskInstruction(
prisma: PrismaClient, prisma: PrismaClient,
run: { id: string; trigger: string }, run: { id: string; trigger: string },
): Promise<string | undefined> { ): Promise<string | undefined> {
if (run.trigger !== DELEGATED_TASK_TRIGGER) return undefined; if (run.trigger !== DELEGATED_TASK_TRIGGER && run.trigger !== "media") return undefined;
const task = await prisma.delegatedTask.findUnique({ const task = await prisma.delegatedTask.findUnique({
where: { runId: run.id }, where: { runId: run.id },
select: { title: true, brief: true, deadlineAt: true, requesterBotId: true }, select: { title: true, brief: true, deadlineAt: true, requesterBotId: true },
@ -323,7 +323,7 @@ export async function syncDelegatedTaskRun(
deps: DelegationDeps, deps: DelegationDeps,
run: { id: string; trigger: string }, run: { id: string; trigger: string },
): Promise<void> { ): Promise<void> {
if (run.trigger !== DELEGATED_TASK_TRIGGER) return; if (run.trigger !== DELEGATED_TASK_TRIGGER && run.trigger !== "media") return;
await syncDelegatedTask(deps, run.id).catch((error) => { await syncDelegatedTask(deps, run.id).catch((error) => {
getLogger().error("delegated task sync", error); getLogger().error("delegated task sync", error);
}); });
@ -378,7 +378,18 @@ export async function syncDelegatedTask(deps: DelegationDeps, runId: string): Pr
select: { status: true, error: true, threadId: true, spaceId: true, sourceMessageId: true }, select: { status: true, error: true, threadId: true, spaceId: true, sourceMessageId: true },
}); });
// A run deleted with its bot can never report back. // A run deleted with its bot can never report back.
const status = run ? delegatedTaskStatusForRun(run.status) : "cancelled"; const rawStatus = (run ? delegatedTaskStatusForRun(run.status) : "cancelled") ?? null;
const pendingMediaWake =
rawStatus === "done" && typeof tx.mediaJob?.count === "function"
? (await tx.mediaJob.count({
where: {
runId,
continueAfter: true,
status: { in: ["queued", "running"] },
},
})) > 0
: false;
const status: DelegatedTaskStatus | null = pendingMediaWake ? "waiting" : rawStatus;
if (!status || status === task.status) return null; if (!status || status === task.status) return null;
await lockThreads(tx, run ? [task.threadId, run.threadId] : [task.threadId]); await lockThreads(tx, run ? [task.threadId, run.threadId] : [task.threadId]);
const terminal = !isOpenDelegatedTaskStatus(status); const terminal = !isOpenDelegatedTaskStatus(status);

View File

@ -337,4 +337,77 @@ describe("bot capabilities in runs", () => {
expect(results[0]).toEqual({ error: "Only the main agent can schedule for another bot." }); expect(results[0]).toEqual({ error: "Only the main agent can schedule for another bot." });
expect(routineCreate).not.toHaveBeenCalled(); expect(routineCreate).not.toHaveBeenCalled();
}); });
it("edits an existing file in place with edit_file and enforces single-match safety unless replace_all is set", async () => {
const files = new Map<string, Uint8Array>([
[
"styles/site.css",
new TextEncoder().encode(".hero { color: black; }\r\n.card { color: black; }\r\n"),
],
]);
const sandbox = {
describe: () => ({ capabilities: { graphical: false } }),
readFile: vi.fn(async (_computer: unknown, path: string) => {
const bytes = files.get(path);
if (!bytes) throw new Error(`ENOENT: ${path}`);
return bytes;
}),
writeFile: vi.fn(async (_computer: unknown, file: { path: string; content: Uint8Array }) => {
files.set(file.path, file.content);
}),
};
const { results } = await runBot(
{},
[
{
name: "edit_file",
args: {
path: "styles/site.css",
old_text: "color: black;",
new_text: "color: white;",
},
},
{
name: "edit_file",
args: {
path: "styles/site.css",
old_text: ".hero { color: black; }\n.card { color: black; }",
new_text: ".hero { color: navy; }\n.card { color: black; }",
},
},
{
name: "edit_file",
args: {
path: "styles/site.css",
old_text: "color:",
new_text: "background-color:",
replace_all: true,
},
},
],
{},
{ sandbox },
);
expect(results[0]).toEqual({
error:
"old_text matched multiple locations; include more surrounding context lines or set replace_all: true",
path: "styles/site.css",
matches: 2,
});
expect(results[1]).toEqual({
ok: true,
path: "styles/site.css",
replacements: 1,
});
expect(results[2]).toEqual({
ok: true,
path: "styles/site.css",
replacements: 2,
});
expect(new TextDecoder().decode(files.get("styles/site.css"))).toBe(
".hero { background-color: navy; }\r\n.card { background-color: black; }\r\n",
);
});
}); });

View File

@ -302,6 +302,7 @@ describe("formatWorkingPrinciples", () => {
const withImage = formatWorkingPrinciples(new Set(["generate_image"])); const withImage = formatWorkingPrinciples(new Set(["generate_image"]));
expect(withImage).toContain("generate_image"); expect(withImage).toContain("generate_image");
expect(withImage).toContain("logos, brand marks, signets, monograms");
expect(withImage).toContain("continue_after true"); expect(withImage).toContain("continue_after true");
expect(withImage).toContain("attachments/<artifactId>.<ext>"); expect(withImage).toContain("attachments/<artifactId>.<ext>");
}); });
@ -315,4 +316,13 @@ describe("formatWorkingPrinciples", () => {
expect(withAttach).toContain("chat gallery and preview pane"); expect(withAttach).toContain("chat gallery and preview pane");
expect(withAttach).toContain("`#C41E5C`"); expect(withAttach).toContain("`#C41E5C`");
}); });
it("instructs targeted replacements with edit_file when edit_file is available", () => {
const withoutEdit = formatWorkingPrinciples(new Set(["write_file"]));
expect(withoutEdit).not.toContain("edit_file");
const withEdit = formatWorkingPrinciples(new Set(["write_file", "edit_file"]));
expect(withEdit).toContain("edit_file");
expect(withEdit).toContain("instead of rewriting the entire file with write_file");
});
}); });

View File

@ -57,11 +57,14 @@ export function formatWorkingPrinciples(toolNames: ReadonlySet<string>): string
"- Understand before you act: read the request and what it touches. On anything non-trivial, state the assumptions you make.", "- Understand before you act: read the request and what it touches. On anything non-trivial, state the assumptions you make.",
"- Prefer the simplest thing that works: reuse what exists, add nothing that was not asked for, and touch only what the task needs. For branding, visual identity, copywriting, campaigns, and website design, work at creative-agency standard instead of minimal boilerplate: give every brand and website its own unmistakable art direction, bespoke typography, intentional palette, layout grammar, and voice — never reuse a generic template or repeat another brand's look.", "- Prefer the simplest thing that works: reuse what exists, add nothing that was not asked for, and touch only what the task needs. For branding, visual identity, copywriting, campaigns, and website design, work at creative-agency standard instead of minimal boilerplate: give every brand and website its own unmistakable art direction, bespoke typography, intentional palette, layout grammar, and voice — never reuse a generic template or repeat another brand's look.",
toolNames.has("generate_image") toolNames.has("generate_image")
? "- When a task calls for photography, key visuals, moodboards, product shots, campaign imagery, textures, or raster brand assets, generate real images with generate_image (set continue_after true when the files feed into a website, brand book, or next step, where they land on your computer in attachments/<artifactId>.<ext>) instead of hand-coding placeholder or clipart SVGs. Reserve hand-crafted SVG for crisp typographic wordmarks, monograms, geometric emblems, and icons." ? "- When a task calls for logos, brand marks, signets, monograms, emblems, packaging or signage mockups, photography, key visuals, moodboards, product shots, campaign imagery, or textures, generate real visuals with generate_image (use count 2–4 to explore logo or visual directions, and set continue_after true when the files feed into vector tracing, a website, a brand book, or a next step, where they land on your computer in attachments/<artifactId>.<ext>) instead of hand-coding amateur clipart SVGs. Never use external @import web fonts inside standalone .svg files (rasterizers and <img> tags strip external font requests); use real path outlines, trace generated marks into clean SVG paths, or load Google Fonts in HTML <head>."
: undefined, : undefined,
toolNames.has("attach_file") toolNames.has("attach_file")
? "- When you create or export visual assets, logos, templates, documents, or reports (such as .svg, .png, .jpg, .webp, .pdf, or .md files) for the user, attach the finished deliverables to the conversation with attach_file (setting present true on the primary deliverable) so they render in the chat gallery and preview pane — never just list workspace or server file paths as plain text. Write hex color codes in backticks (e.g. `#C41E5C`) so inline color swatches render." ? "- When you create or export visual assets, logos, templates, documents, or reports (such as .svg, .png, .jpg, .webp, .pdf, or .md files) for the user, attach the finished deliverables to the conversation with attach_file (setting present true on the primary deliverable) so they render in the chat gallery and preview pane — never just list workspace or server file paths as plain text. Write hex color codes in backticks (e.g. `#C41E5C`) so inline color swatches render."
: undefined, : undefined,
toolNames.has("edit_file")
? "- When modifying an existing file, use edit_file with targeted old_text and new_text replacements instead of rewriting the entire file with write_file."
: undefined,
"- Say something is done only after a tool confirmed it: read back what you wrote, run what you built, check what you changed. When something fails, find the cause instead of working around it, say what failed, and try another way.", "- Say something is done only after a tool confirmed it: read back what you wrote, run what you built, check what you changed. When something fails, find the cause instead of working around it, say what failed, and try another way.",
"- When a request has a real problem, say so and propose the better option, then follow the user's decision.", "- When a request has a real problem, say so and propose the better option, then follow the user's decision.",
toolNames.has("skill_read") toolNames.has("skill_read")

View File

@ -29,12 +29,7 @@ import {
MediaLibrarySearchInputSchema, MediaLibrarySearchInputSchema,
MessageBlock as MessageBlockSchema, MessageBlock as MessageBlockSchema,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import type { import type { ActionApprovalRule, PlanCreateArgs, ShowCardArgs } from "@rakazo/core";
ActionApprovalRule,
PlanCreateArgs,
ShowCardArgs,
ToolCallStreak,
} from "@rakazo/core";
import { import {
appendTextSegment, appendTextSegment,
appendToolCallSegment, appendToolCallSegment,
@ -330,6 +325,7 @@ import {
} from "./model-vision.js"; } from "./model-vision.js";
import { notifyUserFromTool } from "./notify-user-tool.js"; import { notifyUserFromTool } from "./notify-user-tool.js";
import { LOCAL_PROVIDER_ID, localTierModels } from "./pi-local-provider.js"; import { LOCAL_PROVIDER_ID, localTierModels } from "./pi-local-provider.js";
import { clipToolResultText, TOOL_RESULT_TEXT_LIMIT } from "./pi-runtime-limits.js";
import type { PlotSpec } from "./plot-tool.js"; import type { PlotSpec } from "./plot-tool.js";
import { import {
assertPlotDataWithinLimits, assertPlotDataWithinLimits,
@ -403,7 +399,7 @@ import {
formatMiB, formatMiB,
materializeCurrentTurnFiles, materializeCurrentTurnFiles,
} from "./thread-artifacts.js"; } from "./thread-artifacts.js";
import { advanceToolCallLoopGuard } from "./tool-loop.js"; import { advanceToolCallLoopGuard, type ToolLoopGuardState } from "./tool-loop.js";
import { textContentArg } from "./tool-text.js"; import { textContentArg } from "./tool-text.js";
import { updateUserProfileFromTool } from "./user-profile-tools.js"; import { updateUserProfileFromTool } from "./user-profile-tools.js";
import { import {
@ -1091,6 +1087,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
let leaseValid = true; let leaseValid = true;
let lastLeaseCheckAt = 0; let lastLeaseCheckAt = 0;
let leaseCheckInFlight: Promise<boolean> | undefined;
let retainComputerLease = false; let retainComputerLease = false;
let screenRelease: { computer: ComputerRef; context: AdapterContext } | undefined; let screenRelease: { computer: ComputerRef; context: AdapterContext } | undefined;
let runAbortController: AbortController | null = null; let runAbortController: AbortController | null = null;
@ -1101,28 +1098,34 @@ export function createRunExecutor(deps: ExecutorDeps) {
runAbortController?.abort(); runAbortController?.abort();
void runtime.abort(runId).catch(() => undefined); void runtime.abort(runId).catch(() => undefined);
}; };
const verifyLeaseOwnership = async (): Promise<boolean> => { const verifyLeaseOwnership = (): Promise<boolean> => {
if (!leaseValid) return false; if (!leaseValid) return Promise.resolve(false);
const still = await deps.prisma.run.findUnique({ leaseCheckInFlight ??= (async () => {
where: { id: runId }, lastLeaseCheckAt = Date.now();
select: { status: true, leaseOwner: true, leaseFence: true, checkpoint: true }, const still = await deps.prisma.run.findUnique({
where: { id: runId },
select: { status: true, leaseOwner: true, leaseFence: true, checkpoint: true },
});
if (
!still ||
still.status === "cancelled" ||
still.leaseOwner !== workerId ||
still.leaseFence !== fence
) {
invalidateLostLease();
return false;
}
const releasedHold = takeoverCheckpointOf(still.checkpoint);
if (heldForTakeover && releasedHold) {
await requeueComputerRun(deps, runId, workerId, fence, releasedHold, false);
invalidateLostLease();
return false;
}
return true;
})().finally(() => {
leaseCheckInFlight = undefined;
}); });
if ( return leaseCheckInFlight;
!still ||
still.status === "cancelled" ||
still.leaseOwner !== workerId ||
still.leaseFence !== fence
) {
invalidateLostLease();
return false;
}
const releasedHold = takeoverCheckpointOf(still.checkpoint);
if (heldForTakeover && releasedHold) {
await requeueComputerRun(deps, runId, workerId, fence, releasedHold, false);
invalidateLostLease();
return false;
}
return true;
}; };
const leasePoll = setInterval(() => { const leasePoll = setInterval(() => {
void verifyLeaseOwnership().catch(() => invalidateLostLease()); void verifyLeaseOwnership().catch(() => invalidateLostLease());
@ -1224,12 +1227,14 @@ export function createRunExecutor(deps: ExecutorDeps) {
userId: run.userId, userId: run.userId,
}), }),
]); ]);
const githubIntegrationToken = await githubIntegrationTokenForBroker( const hasModelOverride = Boolean(bot.modelProvider && bot.modelId);
deps.prisma, const [githubIntegrationToken, overrideCredential, tierModel] = await Promise.all([
deps.secretStore, githubIntegrationTokenForBroker(deps.prisma, deps.secretStore, run.userId, settings),
run.userId, hasModelOverride && bot.modelProvider
settings, ? findModelCredential(deps.prisma, run, bot.modelProvider, bot.modelId)
); : Promise.resolve(null),
bot.modelTier ? findSpaceModelTier(deps.prisma, run, bot.modelTier) : Promise.resolve(null),
]);
const brokerWorkspacePath = const brokerWorkspacePath =
projectWorkspacePath ?? projectWorkspacePath ??
(bot.computer?.homeKey (bot.computer?.homeKey
@ -1256,14 +1261,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
runSecrets.push(...Object.values(agentEnvironment)); runSecrets.push(...Object.values(agentEnvironment));
// The broker variables are plumbing for the git shim; naming them invites the model to use them as a GitHub token. // The broker variables are plumbing for the git shim; naming them invites the model to use them as a GitHub token.
const agentEnvironmentInstruction = formatAgentEnvironmentInstruction(agentSecrets); const agentEnvironmentInstruction = formatAgentEnvironmentInstruction(agentSecrets);
const hasModelOverride = Boolean(bot.modelProvider && bot.modelId);
const overrideCredential =
hasModelOverride && bot.modelProvider
? await findModelCredential(deps.prisma, run, bot.modelProvider, bot.modelId)
: null;
const tierModel = bot.modelTier
? await findSpaceModelTier(deps.prisma, run, bot.modelTier)
: null;
runAbortController = new AbortController(); runAbortController = new AbortController();
if (!leaseValid) runAbortController.abort(); if (!leaseValid) runAbortController.abort();
if (deps.shutdownSignal?.aborted) runAbortController.abort(deps.shutdownSignal.reason); if (deps.shutdownSignal?.aborted) runAbortController.abort(deps.shutdownSignal.reason);
@ -1311,15 +1308,17 @@ export function createRunExecutor(deps: ExecutorDeps) {
: null; : null;
const semanticMemory: SemanticMemoryProvider | null = configuredMemory?.provider ?? null; const semanticMemory: SemanticMemoryProvider | null = configuredMemory?.provider ?? null;
await deps.events.append({ await Promise.all([
spaceId: run.spaceId, deps.events.append({
threadId: thread.id, spaceId: run.spaceId,
botId: bot.id, threadId: thread.id,
type: "run.started", botId: bot.id,
runId, type: "run.started",
payload: { trigger: run.trigger, routineId: run.routineId }, runId,
}); payload: { trigger: run.trigger, routineId: run.routineId },
await syncDelegatedTaskRun(deps, run); }),
syncDelegatedTaskRun(deps, run),
]);
const discoveredPromise = deps.connector const discoveredPromise = deps.connector
? deps.connector.discoverTools(context) ? deps.connector.discoverTools(context)
@ -1484,20 +1483,22 @@ export function createRunExecutor(deps: ExecutorDeps) {
} }
return; return;
} }
const resolved = await resolveModelKey( const [resolved] = await Promise.all([
deps, resolveModelKey(
run.userId, deps,
run.spaceId, run.userId,
credential, run.spaceId,
runModelProvider, credential,
runModelId, runModelProvider,
(values) => runSecrets.push(...values), runModelId,
); (values) => runSecrets.push(...values),
),
deps.prisma.run.updateMany({
where: { id: runId, status: "running", leaseOwner: workerId, leaseFence: fence },
data: { modelProvider: runModelProvider, modelId: runModelId },
}),
]);
runSecrets.push(...resolved.redact); runSecrets.push(...resolved.redact);
await deps.prisma.run.updateMany({
where: { id: runId, status: "running", leaseOwner: workerId, leaseFence: fence },
data: { modelProvider: runModelProvider, modelId: runModelId },
});
if (!bot.computer) throw new Error("Bot has no computer"); if (!bot.computer) throw new Error("Bot has no computer");
const storedComputer = bot.computer; const storedComputer = bot.computer;
const computerMode = parseComputerMode(storedComputer.scope); const computerMode = parseComputerMode(storedComputer.scope);
@ -1703,6 +1704,20 @@ export function createRunExecutor(deps: ExecutorDeps) {
.then((row) => row?.enabled ?? deploymentAutoReviewDefault()); .then((row) => row?.enabled ?? deploymentAutoReviewDefault());
return autoReviewPreferencePromise; return autoReviewPreferencePromise;
}; };
const checkerConfiguredByProvider = new Map<string, Promise<boolean>>();
const loadCheckerConfigured = (provider: string): Promise<boolean> => {
if (isAutoReviewCheckerConfigured({})) return Promise.resolve(true);
let pending = checkerConfiguredByProvider.get(provider);
if (!pending) {
pending = findModelCredential(
deps.prisma,
{ userId: run.userId, spaceId: run.spaceId },
provider,
).then(Boolean);
checkerConfiguredByProvider.set(provider, pending);
}
return pending;
};
const tools = [...builtins, ...exposedConnectorTools]; const tools = [...builtins, ...exposedConnectorTools];
const exposedToolNames = new Set(tools.map((tool) => tool.name)); const exposedToolNames = new Set(tools.map((tool) => tool.name));
const approvedEffectReplays = createApprovedEffectReplayQueue(approvedEffects); const approvedEffectReplays = createApprovedEffectReplayQueue(approvedEffects);
@ -1728,6 +1743,9 @@ export function createRunExecutor(deps: ExecutorDeps) {
: hostAccessNote : hostAccessNote
? ` ${hostAccessNote}` ? ` ${hostAccessNote}`
: ""; : "";
const linkedGitRemoteSuffix = spaceRecord?.gitRemoteUrl
? ` This space is linked to git repository ${spaceRecord.gitRemoteUrl}.${spaceRecord.gitDefaultBranch ? ` Default branch: ${spaceRecord.gitDefaultBranch}.` : ""}`
: "";
const workspaceInstruction = const workspaceInstruction =
(computerMode === "team" (computerMode === "team"
? projectWorkspacePath ? projectWorkspacePath
@ -1737,7 +1755,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
? 'This entire computer workspace is your private home. Relative file paths and shell working directories start at its root. This space\'s git repository is mounted at project/ (use path "project/..." in file tools and cwd: "project" in shell).' ? 'This entire computer workspace is your private home. Relative file paths and shell working directories start at its root. This space\'s git repository is mounted at project/ (use path "project/..." in file tools and cwd: "project" in shell).'
: "This entire computer workspace is your private home. Relative file paths and shell working directories start at its root.") + : "This entire computer workspace is your private home. Relative file paths and shell working directories start at its root.") +
desktopHostSuffix + desktopHostSuffix +
gitBrokerPolicySuffix; gitBrokerPolicySuffix +
linkedGitRemoteSuffix;
let assembled = ""; let assembled = "";
let currentTextSegment = ""; let currentTextSegment = "";
@ -1788,7 +1807,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
let pendingProgress = ""; let pendingProgress = "";
let lastProgressAt = 0; let lastProgressAt = 0;
let hasStreamedText = false; let hasStreamedText = false;
let toolCallStreak: ToolCallStreak = { key: undefined, count: 0 }; let toolCallStreak: ToolLoopGuardState = { key: undefined, count: 0 };
let lastComputerFrameId: string | undefined; let lastComputerFrameId: string | undefined;
let terminalCheckpointComplete = false; let terminalCheckpointComplete = false;
let approvalPausePending = false; let approvalPausePending = false;
@ -2129,16 +2148,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
: await loadAutoReviewPreference(); : await loadAutoReviewPreference();
const checker = requiresMandatoryApproval ? undefined : resolveAutoReviewChecker(); const checker = requiresMandatoryApproval ? undefined : resolveAutoReviewChecker();
const checkerConfigured = const checkerConfigured =
autoReviewPref && checker autoReviewPref && checker ? await loadCheckerConfigured(checker.provider) : false;
? isAutoReviewCheckerConfigured({}) ||
Boolean(
await findModelCredential(
deps.prisma,
{ userId: run.userId, spaceId: run.spaceId },
checker.provider,
),
)
: false;
const plan = requiresMandatoryApproval const plan = requiresMandatoryApproval
? "ask" ? "ask"
: planActionGate({ : planActionGate({
@ -2556,10 +2566,44 @@ export function createRunExecutor(deps: ExecutorDeps) {
new TextDecoder("utf-8", { fatal: true }).decode(bytes), new TextDecoder("utf-8", { fatal: true }).decode(bytes),
runSecrets, runSecrets,
); );
const rawStart = args.start_line ?? args.startLine;
const rawEnd = args.end_line ?? args.endLine;
if (rawStart !== undefined || rawEnd !== undefined) {
const lines = decoded.split(/\r?\n/);
const totalLines = lines.length;
const parsedStart = Number(rawStart);
const parsedEnd = Number(rawEnd);
const startLine =
rawStart !== undefined && Number.isFinite(parsedStart)
? Math.max(1, Math.min(Math.max(1, totalLines), Math.floor(parsedStart)))
: 1;
const endLine =
rawEnd !== undefined && Number.isFinite(parsedEnd)
? Math.max(1, Math.min(Math.max(1, totalLines), Math.floor(parsedEnd)))
: Math.max(1, totalLines);
if (endLine < startLine) {
return {
error: "end_line must be greater than or equal to start_line",
path: filePath,
};
}
const sliced = lines.slice(startLine - 1, endLine).join("\n");
return {
path: filePath,
content: clipToolResultText(sliced, TOOL_RESULT_TEXT_LIMIT),
startLine,
endLine,
totalLines,
};
}
const focusQuery = const focusQuery =
typeof args.query === "string" && args.query.trim() ? args.query.trim() : undefined; typeof args.query === "string" && args.query.trim() ? args.query.trim() : undefined;
if (focusQuery) { if (focusQuery) {
const extracted = extractQueryRelevantPageText(decoded, focusQuery, 12_000); const extracted = extractQueryRelevantPageText(
decoded,
focusQuery,
TOOL_RESULT_TEXT_LIMIT,
);
return { return {
path: filePath, path: filePath,
content: extracted.text, content: extracted.text,
@ -2596,6 +2640,103 @@ export function createRunExecutor(deps: ExecutorDeps) {
} }
return finish({ ok: true, path: filePath }); return finish({ ok: true, path: filePath });
} }
if (name === "edit_file") {
const filePath = String(args.path ?? "").trim();
if (!filePath) {
return { error: "path is required" };
}
const oldText = textContentArg(args.old_text, "");
const newText = textContentArg(args.new_text, "");
const replaceAll = Boolean(args.replace_all);
if (!oldText) {
return {
error: "old_text must not be empty; use write_file to create or overwrite a file",
path: filePath,
};
}
const storedPath = resolveStoredWorkspacePath(filePath);
let bytes: Uint8Array;
try {
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
maxBytes: MAX_MODEL_FILE_BYTES,
});
} catch (error) {
if (error instanceof Error && /exceeds \d+ bytes/.test(error.message)) {
return {
error: "file is too large for edit_file; patch it with shell instead",
path: filePath,
};
}
throw error;
}
if (bytes.byteLength > MAX_MODEL_FILE_BYTES) {
return {
error: "file is too large for edit_file; patch it with shell instead",
path: filePath,
size: bytes.byteLength,
};
}
let currentContent: string;
try {
currentContent = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return {
error: "file is not UTF-8 text",
path: filePath,
};
}
let needle = oldText;
let replacement = newText;
if (
!currentContent.includes(needle) &&
currentContent.includes("\r\n") &&
!needle.includes("\r\n")
) {
const crlfNeedle = needle.replaceAll("\n", "\r\n");
if (currentContent.includes(crlfNeedle)) {
needle = crlfNeedle;
replacement = replacement.replaceAll("\n", "\r\n");
}
}
const segments = currentContent.split(needle);
const matchCount = segments.length - 1;
if (matchCount === 0) {
return {
error:
"old_text was not found in file; call read_file first to verify exact text and whitespace",
path: filePath,
};
}
if (matchCount > 1 && !replaceAll) {
return {
error:
"old_text matched multiple locations; include more surrounding context lines or set replace_all: true",
path: filePath,
matches: matchCount,
};
}
const updatedContent = replaceAll
? segments.join(replacement)
: `${segments[0]}${replacement}${segments.slice(1).join(needle)}`;
const encoded = new TextEncoder().encode(updatedContent);
workspaceCheckpoint.markDirty();
await deps.sandbox.writeFile(
computer,
{
path: storedPath,
content: encoded,
},
context,
);
if (/\.svg$/i.test(filePath) && /<svg[\s>]/i.test(updatedContent)) {
writtenSvgFiles.set(storedPath, { filePath, content: encoded });
}
return finish({
ok: true,
path: filePath,
replacements: replaceAll ? matchCount : 1,
});
}
if (name === "render_plot") { if (name === "render_plot") {
if (args.charts !== undefined) { if (args.charts !== undefined) {
const query = typeof args.charts === "string" ? args.charts : undefined; const query = typeof args.charts === "string" ? args.charts : undefined;
@ -4549,7 +4690,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
if (event.type === "text") { if (event.type === "text") {
assembled += event.text; assembled += event.text;
currentTextSegment += event.text; currentTextSegment += event.text;
toolCallStreak = { key: undefined, count: 0 };
tryFlushPendingTools(); tryFlushPendingTools();
pendingProgress += progressRedactor.push(event.text); pendingProgress += progressRedactor.push(event.text);
const now = Date.now(); const now = Date.now();
@ -4563,7 +4703,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
await flushProgress(); await flushProgress();
} }
} else if (event.type === "progress") { } else if (event.type === "progress") {
toolCallStreak = { key: undefined, count: 0 };
// Flush batched text deltas first so an activity line cannot land // Flush batched text deltas first so an activity line cannot land
// ahead of text the model streamed before the tool call. // ahead of text the model streamed before the tool call.
if (pendingProgress) { if (pendingProgress) {
@ -5157,6 +5296,14 @@ export function createRunExecutor(deps: ExecutorDeps) {
getLogger().error("history.compact enqueue failed", error); getLogger().error("history.compact enqueue failed", error);
} }
} catch (error) { } catch (error) {
if (
approvalPausePending ||
!leaseValid ||
deps.shutdownSignal?.aborted ||
runAbortController?.signal.aborted
) {
return;
}
if (run.trigger === "routine") { if (run.trigger === "routine") {
const attemptCount = await deps.prisma.attempt.count({ where: { runId } }); const attemptCount = await deps.prisma.attempt.count({ where: { runId } });
const healed = await handleRoutineSelfHealing(deps, runId, attemptCount, error); const healed = await handleRoutineSelfHealing(deps, runId, attemptCount, error);

View File

@ -167,6 +167,7 @@ describe("builtin tools", () => {
expect(builtinAgentTools.map((t) => t.name)).toEqual( expect(builtinAgentTools.map((t) => t.name)).toEqual(
expect.arrayContaining([ expect.arrayContaining([
"write_file", "write_file",
"edit_file",
"attach_file", "attach_file",
"shell", "shell",
"remember", "remember",

View File

@ -531,6 +531,25 @@ export function createMediaRuntime(
select: { id: true }, select: { id: true },
}); });
wakeRunId = run.id; wakeRunId = run.id;
if (job.runId && typeof tx.delegatedTask?.updateMany === "function") {
const handedOff = await tx.delegatedTask.updateMany({
where: {
runId: job.runId,
status: { in: ["queued", "running", "waiting"] },
},
data: { runId: wakeRunId },
});
if (handedOff.count > 0) {
await tx.mediaJob.updateMany({
where: {
runId: job.runId,
continueAfter: true,
status: { in: ["queued", "running"] },
},
data: { runId: wakeRunId },
});
}
}
} }
} }
return { job, seq: card.seq, wakeRunId }; return { job, seq: card.seq, wakeRunId };

View File

@ -1,8 +1,9 @@
import type { MediaCapabilities, MediaRuntime, StartMediaJobInput } from "@rakazo/adapter-kit"; import type { MediaCapabilities, MediaRuntime, StartMediaJobInput } from "@rakazo/adapter-kit";
import { STUDIO_BOT_TOOLS } from "@rakazo/db"; import { STUDIO_BOT_TOOLS } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { loadDelegatedTaskInstruction, syncDelegatedTask } from "./delegated-tasks.js";
import { selectBuiltinToolsForRun, unknownBuiltinToolNames } from "./executor-tools.js"; import { selectBuiltinToolsForRun, unknownBuiltinToolNames } from "./executor-tools.js";
import { MediaJobRejectedError, mediaWakePrompt } from "./media-runtime.js"; import { MEDIA_WAKE_TRIGGER, MediaJobRejectedError, mediaWakePrompt } from "./media-runtime.js";
import { import {
executeMediaTool, executeMediaTool,
isMediaToolName, isMediaToolName,
@ -197,4 +198,69 @@ describe("media tools", () => {
}); });
expect(prompt).toContain("botanical-bottle.png (art_hero_1, attachments/art_hero_1.png)"); expect(prompt).toContain("botanical-bottle.png (art_hero_1, attachments/art_hero_1.png)");
}); });
it("keeps a delegated task waiting while a continue_after media job is open and loads instructions on the media wake run", async () => {
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
const tx = {
$queryRaw: vi.fn().mockResolvedValue([]),
delegatedTask: {
findUnique: vi.fn().mockResolvedValue({
id: "dtask-1",
status: "running",
threadId: "thread-1",
spaceId: "space-1",
messageId: null,
requesterBotId: "bot-lead",
assigneeBotId: "bot-brand",
}),
updateMany,
},
run: {
findUnique: vi.fn().mockResolvedValue({
status: "completed",
error: null,
threadId: "thread-1",
spaceId: "space-1",
sourceMessageId: null,
}),
},
mediaJob: {
count: vi.fn().mockResolvedValue(1),
},
};
const prisma = {
$transaction: vi.fn(async (cb: (client: typeof tx) => unknown) => cb(tx)),
delegatedTask: {
findUnique: vi.fn().mockResolvedValue({
title: "Brand identity",
brief: "Create logo and brand book",
deadlineAt: null,
requesterBotId: "bot-lead",
}),
},
bot: {
findUnique: vi.fn().mockResolvedValue({ name: "Coordinator" }),
},
};
await syncDelegatedTask(
{
prisma: prisma as never,
events: { notify: vi.fn() } as never,
jobs: { enqueue: vi.fn() } as never,
},
"run-brand-1",
);
expect(updateMany).toHaveBeenCalledWith({
where: { id: "dtask-1", status: "running" },
data: { status: "waiting" },
});
const instruction = await loadDelegatedTaskInstruction(prisma as never, {
id: "run-wake-1",
trigger: MEDIA_WAKE_TRIGGER,
});
expect(instruction).toContain("Brand identity");
});
}); });

View File

@ -78,7 +78,7 @@ export async function executeMediaTool(
jobId: started.jobId, jobId: started.jobId,
status: "queued", status: "queued",
note: wake note: wake
? "The job runs in the background. You are woken with its result when it ends; do not poll or wait." ? "The job runs in the background and its files are not in attachments/ yet. Queue any parallel jobs now, then end your turn; you will be woken automatically with the rendered attachments/<artifactId>.<ext> files to complete the deliverable. Do not poll or wait."
: "The job runs in the background and its card shows progress and the result to the user. Do not poll or wait.", : "The job runs in the background and its card shows progress and the result to the user. Do not poll or wait.",
}; };
} catch (error) { } catch (error) {

View File

@ -81,8 +81,8 @@ describe("local model provider", () => {
delete process.env.RAKAZO_LOCAL_CONTEXT_WINDOW; delete process.env.RAKAZO_LOCAL_CONTEXT_WINDOW;
delete process.env.RAKAZO_LOCAL_MAX_TOKENS; delete process.env.RAKAZO_LOCAL_MAX_TOKENS;
let model = localProvider()?.getModels()[0]; let model = localProvider()?.getModels()[0];
expect(model?.contextWindow).toBe(32_768); expect(model?.contextWindow).toBe(131_072);
expect(model?.maxTokens).toBe(4_096); expect(model?.maxTokens).toBe(16_384);
process.env.RAKAZO_LOCAL_CONTEXT_WINDOW = "8192"; process.env.RAKAZO_LOCAL_CONTEXT_WINDOW = "8192";
process.env.RAKAZO_LOCAL_MAX_TOKENS = "512"; process.env.RAKAZO_LOCAL_MAX_TOKENS = "512";

View File

@ -6,7 +6,12 @@ import {
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
import { type ModelTier, ModelTierSchema } from "@rakazo/contracts"; import {
DEFAULT_MODEL_CONTEXT_WINDOW,
DEFAULT_MODEL_MAX_TOKENS,
type ModelTier,
ModelTierSchema,
} from "@rakazo/contracts";
import { declaredVisionModelIds, inputModalities } from "./model-modalities.js"; import { declaredVisionModelIds, inputModalities } from "./model-modalities.js";
/** /**
* Local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp, MLX). * Local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp, MLX).
@ -31,8 +36,8 @@ export function localVisionModelIds(): ReadonlySet<string> {
} }
const DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1"; const DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1";
const DEFAULT_CONTEXT_WINDOW = 32_768; const DEFAULT_CONTEXT_WINDOW = DEFAULT_MODEL_CONTEXT_WINDOW;
const DEFAULT_MAX_TOKENS = 4_096; const DEFAULT_MAX_TOKENS = DEFAULT_MODEL_MAX_TOKENS;
export function localBaseUrl(): string { export function localBaseUrl(): string {
return checkedUrl(process.env.RAKAZO_LOCAL_MODELS_URL?.trim() || DEFAULT_BASE_URL); return checkedUrl(process.env.RAKAZO_LOCAL_MODELS_URL?.trim() || DEFAULT_BASE_URL);

View File

@ -26,7 +26,7 @@ import type {
AgentSteeringMessage, AgentSteeringMessage,
AgentToolExecutionResult, AgentToolExecutionResult,
} from "@rakazo/adapter-kit"; } from "@rakazo/adapter-kit";
import { DEFAULT_MODEL_CONTEXT_WINDOW } from "@rakazo/contracts"; import { DEFAULT_MODEL_CONTEXT_WINDOW, DEFAULT_MODEL_MAX_TOKENS } from "@rakazo/contracts";
import { DEFAULT_OPENROUTER_MODEL_ID } from "./deployment-model.js"; import { DEFAULT_OPENROUTER_MODEL_ID } from "./deployment-model.js";
import { PiRuntimeCredentialStore, toOAuthCredential } from "./pi-credentials.js"; import { PiRuntimeCredentialStore, toOAuthCredential } from "./pi-credentials.js";
import { registerLocalProvider } from "./pi-local-provider.js"; import { registerLocalProvider } from "./pi-local-provider.js";
@ -126,18 +126,38 @@ export function isVerificationToolCall(toolName: string, args: Record<string, un
export const CONSECUTIVE_TOOL_FAILURE_DIAGNOSTIC_HINT = export const CONSECUTIVE_TOOL_FAILURE_DIAGNOSTIC_HINT =
"\n\n[Diagnostic hint: This tool failed twice in a row. Do not retry the same command or arguments blindly — inspect the error details, check the current file or environment state first, or switch to an alternative approach.]"; "\n\n[Diagnostic hint: This tool failed twice in a row. Do not retry the same command or arguments blindly — inspect the error details, check the current file or environment state first, or switch to an alternative approach.]";
export function isToolResultError(result: unknown): boolean { function isErrorPayload(payload: unknown): boolean {
if (!result || typeof result !== "object") return false; if (!payload || typeof payload !== "object") return false;
if ("error" in result && (result as { error?: unknown }).error) return true; if ("error" in payload && Boolean((payload as { error?: unknown }).error)) return true;
if ( if (
"details" in result && "exitCode" in payload &&
result.details && typeof (payload as { exitCode?: unknown }).exitCode === "number" &&
typeof result.details === "object" && (payload as { exitCode: number }).exitCode !== 0
"error" in result.details &&
(result.details as { error?: unknown }).error
) { ) {
return true; return true;
} }
if (
"pages" in payload &&
Array.isArray((payload as { pages?: unknown }).pages) &&
(payload as { pages: unknown[] }).pages.length > 0 &&
(payload as { pages: unknown[] }).pages.every(
(page) =>
Boolean(page) &&
typeof page === "object" &&
"error" in (page as Record<string, unknown>) &&
Boolean((page as { error?: unknown }).error),
)
) {
return true;
}
return false;
}
export function isToolResultError(result: unknown): boolean {
if (isErrorPayload(result)) return true;
if (result && typeof result === "object" && "details" in result) {
return isErrorPayload((result as { details?: unknown }).details);
}
return false; return false;
} }
@ -243,8 +263,8 @@ export function configuredOpenRouterModel(id: string): Model<"openai-completions
reasoning: true, reasoning: true,
input: ["text"], input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 16_384, contextWindow: DEFAULT_MODEL_CONTEXT_WINDOW,
maxTokens: 4_096, maxTokens: DEFAULT_MODEL_MAX_TOKENS,
}; };
} }
@ -418,9 +438,31 @@ export function pruneComputerScreenshotContext(
return transformed ?? messages; return transformed ?? messages;
} }
const TRUNCATED_TOOL_REISSUE_SUFFIX =
"so its arguments may be truncated. Re-issue the tool call with complete arguments.";
const TRUNCATED_TOOL_RECOVERY_GUIDANCE =
"so its arguments were truncated. Do NOT re-issue the same oversized tool call. Prefer edit_file for targeted changes to existing files, or split large writes into smaller incremental steps.";
function rewriteTruncatedToolResultMessage(message: AgentMessage): AgentMessage {
if (message.role !== "toolResult" || !Array.isArray(message.content)) return message;
let changed = false;
const nextContent = message.content.map((part) => {
if (part.type === "text" && part.text.includes(TRUNCATED_TOOL_REISSUE_SUFFIX)) {
changed = true;
return {
...part,
text: part.text.replace(TRUNCATED_TOOL_REISSUE_SUFFIX, TRUNCATED_TOOL_RECOVERY_GUIDANCE),
};
}
return part;
});
return changed ? { ...message, content: nextContent } : message;
}
/** Discard cancelled tool results and tool calls from conversational history context */ /** Discard cancelled tool results and tool calls from conversational history context */
export function pruneCancelledToolResultsContext(messages: AgentMessage[]): AgentMessage[] { export function pruneCancelledToolResultsContext(messages: AgentMessage[]): AgentMessage[] {
const cancelledIds = new Set<string>(); const cancelledIds = new Set<string>();
let hasTruncatedToolMessage = false;
for (const message of messages) { for (const message of messages) {
if (message.role === "toolResult") { if (message.role === "toolResult") {
const details = const details =
@ -428,9 +470,17 @@ export function pruneCancelledToolResultsContext(messages: AgentMessage[]): Agen
if (details?.cancelled === true) { if (details?.cancelled === true) {
cancelledIds.add(message.toolCallId); cancelledIds.add(message.toolCallId);
} }
if (
Array.isArray(message.content) &&
message.content.some(
(part) => part.type === "text" && part.text.includes(TRUNCATED_TOOL_REISSUE_SUFFIX),
)
) {
hasTruncatedToolMessage = true;
}
} }
} }
if (cancelledIds.size === 0) return messages; if (cancelledIds.size === 0 && !hasTruncatedToolMessage) return messages;
const transformed: AgentMessage[] = []; const transformed: AgentMessage[] = [];
for (const message of messages) { for (const message of messages) {
@ -456,7 +506,7 @@ export function pruneCancelledToolResultsContext(messages: AgentMessage[]): Agen
continue; continue;
} }
} }
transformed.push(message); transformed.push(rewriteTruncatedToolResultMessage(message));
} }
return transformed; return transformed;
} }
@ -784,8 +834,9 @@ export function modelForCompletion(
const CONSERVATIVE_CHARS_PER_TOKEN = 3; const CONSERVATIVE_CHARS_PER_TOKEN = 3;
const MESSAGE_TOKEN_OVERHEAD = 12; const MESSAGE_TOKEN_OVERHEAD = 12;
const ESTIMATED_IMAGE_CHARS = 4_800; const ESTIMATED_IMAGE_CHARS = 4_800;
const RECENT_FULL_TOOL_RESULTS = 2; const RECENT_FULL_TOOL_RESULTS = 6;
const COMPACTED_TOOL_RESULT_CHARS = 800; const COMPACTED_TOOL_RESULT_CHARS = 2_400;
const EMERGENCY_COMPACTED_TOOL_RESULT_CHARS = 800;
const COMPACTED_TOOL_CALL_STRING_CHARS = 400; const COMPACTED_TOOL_CALL_STRING_CHARS = 400;
const MAX_CONTEXT_OVERFLOW_RETRIES = 3; const MAX_CONTEXT_OVERFLOW_RETRIES = 3;
@ -1103,7 +1154,7 @@ export function pruneContextToWindow<T extends AgentMessage>(
...msg, ...msg,
content: compactContentParts( content: compactContentParts(
msg.content, msg.content,
COMPACTED_TOOL_RESULT_CHARS, EMERGENCY_COMPACTED_TOOL_RESULT_CHARS,
"\n…[tool output truncated to fit context window]…\n", "\n…[tool output truncated to fit context window]…\n",
), ),
}; };
@ -1480,4 +1531,9 @@ export interface ToolHost {
consecutiveToolFailures?: number; consecutiveToolFailures?: number;
/** Tracks whether a code file mutation occurred without subsequent verification */ /** Tracks whether a code file mutation occurred without subsequent verification */
unverifiedCodeMutation?: boolean; unverifiedCodeMutation?: boolean;
/** Shared mid-step steering poller state across concurrent tools in this host */
steeringPoller?: {
timer?: NodeJS.Timeout;
inFlight: boolean;
};
} }

View File

@ -268,4 +268,22 @@ describe("Pi runtime cancellation", () => {
await stopping; await stopping;
await second.return!(); await second.return!();
}); });
it("finishes cleanly as stopped instead of throwing Request was aborted when abort sets errorMessage", async () => {
const controller = new AbortController();
const runtime = new PiAgentRuntime();
const stream = runtime.run(request, { signal: controller.signal })[Symbol.asyncIterator]();
await stream.next();
const agent = fake.state.agents[0]!;
controller.abort();
(agent.state as { errorMessage?: string }).errorMessage = "Request was aborted.";
agent.release.resolve();
const terminal = await stream.next();
expect(terminal).toEqual({
done: false,
value: { type: "done", text: "stopped" },
});
const end = await stream.next();
expect(end.done).toBe(true);
});
}); });

View File

@ -31,7 +31,7 @@ export function resolveModelStreamMaxRetries(provider: string, optionsMaxRetries
: MODEL_STREAM_MAX_RETRIES; : MODEL_STREAM_MAX_RETRIES;
} }
export const TOOL_RESULT_TEXT_LIMIT = 12_000; export const TOOL_RESULT_TEXT_LIMIT = 24_000;
export function clipToolResultText(text: string, limit: number = TOOL_RESULT_TEXT_LIMIT): string { export function clipToolResultText(text: string, limit: number = TOOL_RESULT_TEXT_LIMIT): string {
return text.length > limit ? `${text.slice(0, limit)}…` : text; return text.length > limit ? `${text.slice(0, limit)}…` : text;

View File

@ -464,8 +464,8 @@ describe("Pi agent thinking level", () => {
id: "stealth/ox-alpha", id: "stealth/ox-alpha",
provider: "openrouter", provider: "openrouter",
reasoning: true, reasoning: true,
contextWindow: 16_384, contextWindow: 131_072,
maxTokens: 4_096, maxTokens: 16_384,
}); });
// Unknown OpenRouter PI_DEFAULT_MODEL must not force thinking off (#114). // Unknown OpenRouter PI_DEFAULT_MODEL must not force thinking off (#114).
expect(levels).toEqual(["medium", "medium"]); expect(levels).toEqual(["medium", "medium"]);

View File

@ -1456,4 +1456,111 @@ describe("Pi connector tool dispatch", () => {
"call-1", "call-1",
); );
}); });
it("preserves optional query on read_file and normalizes edit_file arguments", async () => {
const readFileTool: ConnectorTool = {
name: "read_file",
description: "Read a UTF-8 file.",
inputSchema: {
type: "object",
properties: {
path: { type: "string" },
query: { type: "string" },
},
},
};
const editFileTool: ConnectorTool = {
name: "edit_file",
description: "Edit a UTF-8 file.",
inputSchema: {
type: "object",
properties: {
path: { type: "string" },
old_text: { type: "string" },
new_text: { type: "string" },
replace_all: { type: "boolean" },
},
},
};
fakeAgentState.invoke = {
name: "read_file",
args: { path: "styles/app.css", query: " .modal-header ", startLine: 10, end_line: 40 },
};
const executeTool = vi.fn(async () => ({ ok: true }));
const runtime = new PiAgentRuntime();
for await (const _event of runtime.run(
{
botId: "b",
threadId: "t",
runId: "r-read",
prompt: "find modal styles",
instructions: "Use read_file.",
history: [],
tools: [readFileTool],
model: { provider: "test", id: "dispatch-test-model" },
executeTool,
},
{
operationId: "4",
traceId: "4",
spaceId: "w",
userId: "u",
signal: new AbortController().signal,
},
)) {
// Exhaust stream
}
expect(executeTool).toHaveBeenCalledWith(
"read_file",
{ path: "styles/app.css", query: ".modal-header", start_line: 10, end_line: 40 },
"call-1",
);
executeTool.mockClear();
fakeAgentState.invoke = {
name: "edit_file",
args: {
filePath: "styles/app.css",
oldText: ".btn { color: red; }",
newText: ".btn { color: blue; }",
replaceAll: true,
},
};
for await (const _event of runtime.run(
{
botId: "b",
threadId: "t",
runId: "r-edit",
prompt: "update button color",
instructions: "Use edit_file.",
history: [],
tools: [editFileTool],
model: { provider: "test", id: "dispatch-test-model" },
executeTool,
},
{
operationId: "5",
traceId: "5",
spaceId: "w",
userId: "u",
signal: new AbortController().signal,
},
)) {
// Exhaust stream
}
expect(executeTool).toHaveBeenCalledWith(
"edit_file",
{
path: "styles/app.css",
old_text: ".btn { color: red; }",
new_text: ".btn { color: blue; }",
replace_all: true,
},
"call-1",
);
});
}); });

View File

@ -12,6 +12,7 @@ import {
isVerificationToolCall, isVerificationToolCall,
maxInputTokensForWindow, maxInputTokensForWindow,
parseContextOverflowDetails, parseContextOverflowDetails,
pruneCancelledToolResultsContext,
pruneContextToWindow, pruneContextToWindow,
raceSignal, raceSignal,
streamWithContextOverflowRecovery, streamWithContextOverflowRecovery,
@ -28,6 +29,9 @@ describe("describeToolActivity", () => {
expect(describeToolActivity("write_file", { path: "out.csv", content: "…" })).toBe( expect(describeToolActivity("write_file", { path: "out.csv", content: "…" })).toBe(
"Writing out.csv", "Writing out.csv",
); );
expect(
describeToolActivity("edit_file", { path: "styles/main.css", old_text: "a", new_text: "b" }),
).toBe("Editing styles/main.css");
expect(describeToolActivity("render_plot", { spec: {} })).toBe("Rendering a chart"); expect(describeToolActivity("render_plot", { spec: {} })).toBe("Rendering a chart");
expect(describeToolActivity("add_mcp_server", { name: "Linear" })).toBe( expect(describeToolActivity("add_mcp_server", { name: "Linear" })).toBe(
"Connecting MCP server: Linear", "Connecting MCP server: Linear",
@ -433,7 +437,54 @@ describe("adaptive thinking and tool loop diagnostics", () => {
it("detects tool result errors for consecutive-failure diagnostics", () => { it("detects tool result errors for consecutive-failure diagnostics", () => {
expect(isToolResultError({ error: "Command exited with code 1" })).toBe(true); expect(isToolResultError({ error: "Command exited with code 1" })).toBe(true);
expect(isToolResultError({ details: { error: "timeout" } })).toBe(true); expect(isToolResultError({ details: { error: "timeout" } })).toBe(true);
expect(isToolResultError({ stdout: "", stderr: "SyntaxError", exitCode: 1 })).toBe(true);
expect(isToolResultError({ details: { stdout: "", stderr: "fail", exitCode: 2 } })).toBe(true);
expect(isToolResultError({ stdout: "ok", stderr: "", exitCode: 0 })).toBe(false);
expect(
isToolResultError({
pages: [
{ url: "https://a.test", error: "404" },
{ url: "https://b.test", error: "500" },
],
}),
).toBe(true);
expect(
isToolResultError({
pages: [
{ url: "https://a.test", error: "404" },
{ url: "https://b.test", content: "ok" },
],
}),
).toBe(false);
expect(isToolResultError({ ok: true })).toBe(false); expect(isToolResultError({ ok: true })).toBe(false);
expect(CONSECUTIVE_TOOL_FAILURE_DIAGNOSTIC_HINT).toContain("failed twice in a row"); expect(CONSECUTIVE_TOOL_FAILURE_DIAGNOSTIC_HINT).toContain("failed twice in a row");
}); });
it("rewrites truncated tool-call reissue prompts into edit_file recovery guidance", () => {
const messages: AgentMessage[] = [
{
role: "toolResult",
toolCallId: "tc-trunc-1",
toolName: "write_file",
content: [
{
type: "text",
text: 'Tool call "write_file" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.',
},
],
isError: true,
timestamp: 1,
},
];
const transformed = pruneCancelledToolResultsContext(messages);
expect(transformed).toHaveLength(1);
const resultMsg = transformed[0] as Extract<AgentMessage, { role: "toolResult" }>;
expect(resultMsg.content[0]).toEqual({
type: "text",
text: expect.stringContaining("Prefer edit_file for targeted changes"),
});
expect((resultMsg.content[0] as { text: string }).text).not.toContain(
"Re-issue the tool call with complete arguments.",
);
});
}); });

View File

@ -55,7 +55,11 @@ import {
toPiImages, toPiImages,
withoutSteeringMessages, withoutSteeringMessages,
} from "./pi-model-config.js"; } from "./pi-model-config.js";
import { billedPromptTokens, clipToolResultText } from "./pi-runtime-limits.js"; import {
billedPromptTokens,
clipToolResultText,
TOOL_RESULT_TEXT_LIMIT,
} from "./pi-runtime-limits.js";
import type { PiSessionHandle, PiSessionRecorder } from "./pi-session.js"; import type { PiSessionHandle, PiSessionRecorder } from "./pi-session.js";
import { PiJsonlSessionRecorder } from "./pi-session.js"; import { PiJsonlSessionRecorder } from "./pi-session.js";
import { textContentArg } from "./tool-text.js"; import { textContentArg } from "./tool-text.js";
@ -294,11 +298,11 @@ export class PiAgentRuntime implements AgentRuntime {
}, },
), ),
prepareNextTurnWithContext: async () => { prepareNextTurnWithContext: async () => {
if ( const needsEscalation =
(toolCalls >= 2 || host.unverifiedCodeMutation) && Boolean(host.unverifiedCodeMutation) || (host.consecutiveToolFailures ?? 0) >= 2;
agent.state.thinkingLevel !== escalatedThinkingLevel const targetThinkingLevel = needsEscalation ? escalatedThinkingLevel : thinkingLevel;
) { if (agent.state.thinkingLevel !== targetThinkingLevel) {
agent.state.thinkingLevel = escalatedThinkingLevel; agent.state.thinkingLevel = targetThinkingLevel;
} }
if (!request.claimSteering) return undefined; if (!request.claimSteering) return undefined;
const steering = await request.claimSteering([...seenSteeringIds]); const steering = await request.claimSteering([...seenSteeringIds]);
@ -380,6 +384,19 @@ export class PiAgentRuntime implements AgentRuntime {
event.message.role === "assistant" && event.message.role === "assistant" &&
event.message.content.some((part) => part.type === "toolCall"); event.message.content.some((part) => part.type === "toolCall");
const hasToolResults = event.toolResults.length > 0; const hasToolResults = event.toolResults.length > 0;
const hitLengthDuringToolCall =
event.message.role === "assistant" &&
event.message.stopReason === "length" &&
hasToolCalls;
if (hitLengthDuringToolCall) {
host.consecutiveToolFailures = (host.consecutiveToolFailures ?? 0) + 1;
if (streamed && !streamed.endsWith("\n\n")) {
const separator = streamed.endsWith("\n") ? "\n" : "\n\n";
streamed += separator;
queue.push({ type: "text", text: separator });
}
}
// Text in a turn that also contains a tool call is narration, not a final // Text in a turn that also contains a tool call is narration, not a final
// response. Keep the run alive until a later text-only turn answers the user. // response. Keep the run alive until a later text-only turn answers the user.
@ -442,7 +459,14 @@ export class PiAgentRuntime implements AgentRuntime {
// Budget abort stops the agent underneath the model, which leaves // Budget abort stops the agent underneath the model, which leaves
// errorMessage set. Treat that as a soft stop so the turn still ends // errorMessage set. Treat that as a soft stop so the turn still ends
// with a durable assistant message instead of a failed run. // with a durable assistant message instead of a failed run.
// Likewise, a caller-initiated abort (user stop, lease loss, stream return)
// sets errorMessage = "Request was aborted." on the agent; finish cleanly
// as "stopped" instead of throwing a fake provider error.
const budgetExceeded = host.toolCallBudget.exceeded; const budgetExceeded = host.toolCallBudget.exceeded;
if (signal.aborted && !budgetExceeded) {
queue.push({ type: "done", text: "stopped" });
return;
}
const error = agent.state.errorMessage; const error = agent.state.errorMessage;
if (error && !budgetExceeded) { if (error && !budgetExceeded) {
throw new Error(sanitizeProviderError(model.provider, error)); throw new Error(sanitizeProviderError(model.provider, error));
@ -480,9 +504,14 @@ export class PiAgentRuntime implements AgentRuntime {
} }
queue.push(streamed.trim() ? { type: "done", text: streamed } : { type: "done" }); queue.push(streamed.trim() ? { type: "done", text: streamed } : { type: "done" });
} catch (error) { } catch (error) {
if (signal.aborted && !trackedBudget?.exceeded) {
queue.push({ type: "done", text: "stopped" });
return;
}
const message = sanitizeError(error instanceof Error ? error.message : String(error)); const message = sanitizeError(error instanceof Error ? error.message : String(error));
queue.fail(new Error(message)); queue.fail(new Error(message));
} finally { } finally {
if (resumeHost) stopHostSteeringPoller(resumeHost);
queue.close(); queue.close();
if (trackedBudget) { if (trackedBudget) {
const keepForResume = const keepForResume =
@ -518,6 +547,49 @@ export class PiAgentRuntime implements AgentRuntime {
} }
} }
function stopHostSteeringPoller(host: ToolHost): void {
if (host.steeringPoller?.timer) {
clearInterval(host.steeringPoller.timer);
host.steeringPoller.timer = undefined;
}
}
function ensureHostSteeringPoller(host: ToolHost): void {
// Nested subagents run inside the parent's active `run_subagent` tool call, whose
// root host poller already claims steering and cascades abort to nested tools.
if (host.depth > 0 || !host.request.claimSteering || host.signal.aborted) return;
const poller = (host.steeringPoller ??= { inFlight: false });
if (poller.timer) return;
poller.timer = setInterval(async () => {
if (host.signal.aborted || host.activeToolControllers.size === 0) {
stopHostSteeringPoller(host);
return;
}
if (poller.inFlight) return;
poller.inFlight = true;
try {
const steering = await host.request.claimSteering?.([...(host.seenSteeringIds ?? [])]);
if (steering && steering.length > 0) {
stopHostSteeringPoller(host);
host.seenSteeringIds?.push(...steering.map((item) => item.id));
for (const item of steering) {
const images = toPiImages(item.images);
host.steerAgent?.({
role: "user",
content: images.length ? [{ type: "text", text: item.text }, ...images] : item.text,
timestamp: Date.now(),
});
}
host.abortActiveTools("steering_redirection");
}
} catch {
// Background poll failure is non-fatal
} finally {
poller.inFlight = false;
}
}, 80);
}
function toAgentTools(toolDefs: readonly ConnectorTool[], host: ToolHost): AgentTool[] { function toAgentTools(toolDefs: readonly ConnectorTool[], host: ToolHost): AgentTool[] {
const names = normalizeAgentToolNames(toolDefs); const names = normalizeAgentToolNames(toolDefs);
return toolDefs.map((tool, index) => toAgentTool(tool, host, names[index]!)); return toolDefs.map((tool, index) => toAgentTool(tool, host, names[index]!));
@ -556,6 +628,7 @@ export function describeToolActivity(toolName: string, args: unknown): string {
if (toolName === "shell") return `Running: ${detail(record.command)}`; if (toolName === "shell") return `Running: ${detail(record.command)}`;
if (toolName === "read_file") return `Reading ${detail(record.path)}`; if (toolName === "read_file") return `Reading ${detail(record.path)}`;
if (toolName === "write_file") return `Writing ${detail(record.path)}`; if (toolName === "write_file") return `Writing ${detail(record.path)}`;
if (toolName === "edit_file") return `Editing ${detail(record.path)}`;
if (toolName === "list_files") return `Listing ${detail(record.path ?? ".")}`; if (toolName === "list_files") return `Listing ${detail(record.path ?? ".")}`;
if (toolName === "attach_file") return `Attaching ${detail(record.path)}`; if (toolName === "attach_file") return `Attaching ${detail(record.path)}`;
if (toolName === "search_media") return "Searching your photos and videos"; if (toolName === "search_media") return "Searching your photos and videos";
@ -749,6 +822,19 @@ function toAgentTool(tool: ConnectorTool, host: ToolHost, exposedName: string):
content: textContentArg(raw.content, ""), content: textContentArg(raw.content, ""),
}; };
} }
if (tool.name === "edit_file") {
return {
path: String(raw.path ?? raw.file_path ?? raw.filePath ?? ""),
old_text: textContentArg(raw.old_text ?? raw.oldText ?? raw.target ?? raw.search, ""),
new_text: textContentArg(
raw.new_text ?? raw.newText ?? raw.replacement ?? raw.replace,
"",
),
...(raw.replace_all !== undefined || raw.replaceAll !== undefined
? { replace_all: Boolean(raw.replace_all ?? raw.replaceAll) }
: {}),
};
}
if (tool.name === "computer_act") { if (tool.name === "computer_act") {
return { return {
actions: Array.isArray(raw.actions) ? raw.actions : [], actions: Array.isArray(raw.actions) ? raw.actions : [],
@ -759,7 +845,26 @@ function toAgentTool(tool: ConnectorTool, host: ToolHost, exposedName: string):
if (tool.name === "list_files") { if (tool.name === "list_files") {
return { path: String(raw.path ?? raw.dir ?? raw.directory ?? "") }; return { path: String(raw.path ?? raw.dir ?? raw.directory ?? "") };
} }
if (tool.name === "read_file" || tool.name === "open_path") { if (tool.name === "read_file") {
const query = typeof raw.query === "string" ? raw.query.trim() : "";
const rawStart = raw.start_line ?? raw.startLine;
const rawEnd = raw.end_line ?? raw.endLine;
const startLine =
rawStart !== undefined && rawStart !== null && Number.isFinite(Number(rawStart))
? Math.max(1, Math.floor(Number(rawStart)))
: undefined;
const endLine =
rawEnd !== undefined && rawEnd !== null && Number.isFinite(Number(rawEnd))
? Math.max(1, Math.floor(Number(rawEnd)))
: undefined;
return {
path: String(raw.path ?? raw.file_path ?? raw.filePath ?? ""),
...(query ? { query } : {}),
...(startLine !== undefined ? { start_line: startLine } : {}),
...(endLine !== undefined ? { end_line: endLine } : {}),
};
}
if (tool.name === "open_path") {
return { path: String(raw.path ?? raw.file_path ?? raw.filePath ?? "") }; return { path: String(raw.path ?? raw.file_path ?? raw.filePath ?? "") };
} }
if (tool.name === "launch_app") { if (tool.name === "launch_app") {
@ -840,41 +945,7 @@ function toAgentTool(tool: ConnectorTool, host: ToolHost, exposedName: string):
host.signal.addEventListener("abort", onHostAbort, { once: true }); host.signal.addEventListener("abort", onHostAbort, { once: true });
} }
host.activeToolControllers.set(executionId, toolController); host.activeToolControllers.set(executionId, toolController);
ensureHostSteeringPoller(host);
let steeringPoll: NodeJS.Timeout | undefined;
if (host.request.claimSteering) {
let steeringPollInFlight = false;
steeringPoll = setInterval(async () => {
if (toolController.signal.aborted) {
if (steeringPoll) clearInterval(steeringPoll);
return;
}
if (steeringPollInFlight) return;
steeringPollInFlight = true;
try {
const steering = await host.request.claimSteering?.([...(host.seenSteeringIds ?? [])]);
if (steering && steering.length > 0) {
if (steeringPoll) clearInterval(steeringPoll);
host.seenSteeringIds?.push(...steering.map((item) => item.id));
for (const item of steering) {
const images = toPiImages(item.images);
host.steerAgent?.({
role: "user",
content: images.length
? [{ type: "text", text: item.text }, ...images]
: item.text,
timestamp: Date.now(),
});
}
host.abortActiveTools("steering_redirection");
}
} catch {
// Background poll failure is non-fatal
} finally {
steeringPollInFlight = false;
}
}, 80);
}
try { try {
result = await (async () => { result = await (async () => {
@ -1033,9 +1104,11 @@ function toAgentTool(tool: ConnectorTool, host: ToolHost, exposedName: string):
failure = error; failure = error;
throw error; throw error;
} finally { } finally {
if (steeringPoll) clearInterval(steeringPoll);
host.signal.removeEventListener("abort", onHostAbort); host.signal.removeEventListener("abort", onHostAbort);
host.activeToolControllers.delete(executionId); host.activeToolControllers.delete(executionId);
if (host.activeToolControllers.size === 0) {
stopHostSteeringPoller(host);
}
endToolCall(host); endToolCall(host);
const completion: AgentToolCompletion = { const completion: AgentToolCompletion = {
name: tool.name, name: tool.name,
@ -1301,7 +1374,7 @@ async function executeSubagent(
budgetMessage && streamed.trim() budgetMessage && streamed.trim()
? `${streamed.trim()}\n\n${budgetMessage}` ? `${streamed.trim()}\n\n${budgetMessage}`
: budgetMessage || streamed || assistantText(nested.state.messages.at(-1)) || "done."; : budgetMessage || streamed || assistantText(nested.state.messages.at(-1)) || "done.";
const clipped = clipToolResultText(result, 12_000); const clipped = clipToolResultText(result, TOOL_RESULT_TEXT_LIMIT);
host.queue.push({ host.queue.push({
type: "subagent", type: "subagent",
agentId, agentId,
@ -1438,6 +1511,7 @@ export {
modelsForRequest, modelsForRequest,
parseContextOverflowDetails, parseContextOverflowDetails,
prepareRequestSecretArguments, prepareRequestSecretArguments,
pruneCancelledToolResultsContext,
pruneComputerScreenshotContext, pruneComputerScreenshotContext,
pruneContextToWindow, pruneContextToWindow,
reliableStreamOptions, reliableStreamOptions,

View File

@ -82,7 +82,7 @@ export async function attachWorkspaceFileToThread(
if (isSvg) { if (isSvg) {
const svgBytes = content instanceof Uint8Array ? content : await collectBytes(stream); const svgBytes = content instanceof Uint8Array ? content : await collectBytes(stream);
const { default: sharp } = await import("sharp"); const { default: sharp } = await import("sharp");
const pngBuffer = await sharp(Buffer.from(svgBytes), { density: 144 }).png().toBuffer(); const pngBuffer = await sharp(Buffer.from(svgBytes), { density: 288 }).png().toBuffer();
const pngBytes = new Uint8Array(pngBuffer.buffer, pngBuffer.byteOffset, pngBuffer.byteLength); const pngBytes = new Uint8Array(pngBuffer.buffer, pngBuffer.byteOffset, pngBuffer.byteLength);
name = rawName.replace(/\.svg$/i, ".png"); name = rawName.replace(/\.svg$/i, ".png");
mimeType = "image/png"; mimeType = "image/png";

View File

@ -28,4 +28,26 @@ describe("advanceToolCallLoopGuard", () => {
expect(stuck).toBe(true); expect(stuck).toBe(true);
expect(streak.count).toBe(6); expect(streak.count).toBe(6);
}); });
it("stops alternating ping-pong loops (A <-> B) with identical inputs", () => {
let streak = { key: undefined as string | undefined, count: 0 };
let stuck = false;
for (let cycle = 0; cycle < 4; cycle += 1) {
const readRes = advanceToolCallLoopGuard(streak, "read_file", { path: "style.css" });
streak = readRes.streak;
expect(readRes.stuck).toBe(false);
const editRes = advanceToolCallLoopGuard(streak, "edit_file", {
path: "style.css",
old_text: "a",
new_text: "b",
});
streak = editRes.streak;
stuck = editRes.stuck;
}
expect(stuck).toBe(true);
expect(streak.count).toBe(8);
});
}); });

View File

@ -2,15 +2,47 @@ import { type ToolCallStreak, trackToolCallStreak } from "@rakazo/core";
// Same tool, same arguments, this many times in a row means the agent is stuck, not paginating. // Same tool, same arguments, this many times in a row means the agent is stuck, not paginating.
const MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS = 6; const MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS = 6;
// Alternating A <-> B <-> A <-> B (4 full pairs = 8 calls) with identical inputs.
const MAX_ALTERNATING_PING_PONG_CALLS = 8;
export type ToolLoopGuardState = ToolCallStreak & {
prevKey?: string;
altKey?: string;
altCount?: number;
};
export function advanceToolCallLoopGuard( export function advanceToolCallLoopGuard(
streak: ToolCallStreak, streak: ToolLoopGuardState,
name: string, name: string,
args: unknown, args: unknown,
): { streak: ToolCallStreak; stuck: boolean } { ): { streak: ToolLoopGuardState; stuck: boolean } {
const next = trackToolCallStreak(streak, name, args); const next = trackToolCallStreak(streak, name, args);
if (next.count > 1) {
return {
streak: next,
stuck: next.count >= MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS,
};
}
const currentKey = next.key;
const isAlternating =
currentKey !== undefined &&
streak.key !== undefined &&
currentKey !== streak.key &&
streak.altKey === currentKey;
const altCount = isAlternating ? (streak.altCount ?? 2) + 1 : 2;
const stuck = altCount >= MAX_ALTERNATING_PING_PONG_CALLS;
const nextState: ToolLoopGuardState =
streak.key !== undefined
? {
...next,
...(stuck ? { count: altCount } : {}),
prevKey: streak.key,
altKey: streak.key,
altCount,
}
: next;
return { return {
streak: next, streak: nextState,
stuck: next.count >= MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS, stuck,
}; };
} }

View File

@ -5,6 +5,7 @@ export const MAX_WEB_FETCH_URLS = 3;
export const DEFAULT_WEB_FETCH_MAX_CHARS = 8_000; export const DEFAULT_WEB_FETCH_MAX_CHARS = 8_000;
export const MAX_WEB_FETCH_MAX_CHARS = 50_000; export const MAX_WEB_FETCH_MAX_CHARS = 50_000;
export const MIN_WEB_FETCH_MAX_CHARS = 100; export const MIN_WEB_FETCH_MAX_CHARS = 100;
export const DEFAULT_WEB_FETCH_TIMEOUT_MS = 15_000;
export function clampMaxResults(value: unknown): number { export function clampMaxResults(value: unknown): number {
const n = typeof value === "number" ? value : Number(value); const n = typeof value === "number" ? value : Number(value);

View File

@ -1,5 +1,10 @@
import type { AdapterContext, WebFetchProvider, WebSearchProvider } from "@rakazo/adapter-kit"; import type { AdapterContext, WebFetchProvider, WebSearchProvider } from "@rakazo/adapter-kit";
import { clampMaxChars, clampMaxResults, MAX_WEB_FETCH_URLS } from "./web-limits.js"; import {
clampMaxChars,
clampMaxResults,
DEFAULT_WEB_FETCH_TIMEOUT_MS,
MAX_WEB_FETCH_URLS,
} from "./web-limits.js";
import { import {
normalizeSearchCategory, normalizeSearchCategory,
normalizeSearchDomains, normalizeSearchDomains,
@ -40,6 +45,11 @@ export async function webSearchFromTool(
} }
} }
function createWebFetchSignal(parentSignal?: AbortSignal): AbortSignal {
const timeoutSignal = AbortSignal.timeout(DEFAULT_WEB_FETCH_TIMEOUT_MS);
return parentSignal ? AbortSignal.any([parentSignal, timeoutSignal]) : timeoutSignal;
}
export async function webFetchFromTool( export async function webFetchFromTool(
fetchProvider: WebFetchProvider, fetchProvider: WebFetchProvider,
context: AdapterContext, context: AdapterContext,
@ -69,7 +79,7 @@ export async function webFetchFromTool(
url: targetUrl, url: targetUrl,
...(query ? { query } : {}), ...(query ? { query } : {}),
maxChars, maxChars,
signal: context.signal, signal: createWebFetchSignal(context.signal),
}, },
context, context,
); );
@ -91,7 +101,7 @@ export async function webFetchFromTool(
url: singleUrl, url: singleUrl,
...(query ? { query } : {}), ...(query ? { query } : {}),
maxChars, maxChars,
signal: context.signal, signal: createWebFetchSignal(context.signal),
}, },
context, context,
); );

View File

@ -973,13 +973,13 @@ export const ThreadSnapshotSchema = z.object({
export type ThreadSnapshot = z.infer<typeof ThreadSnapshotSchema>; export type ThreadSnapshot = z.infer<typeof ThreadSnapshotSchema>;
/** Default maximum number of completion tokens for an OpenAI-compatible connection. */ /** Default maximum number of completion tokens for an OpenAI-compatible connection. */
export const DEFAULT_MODEL_MAX_TOKENS = 4_096; export const DEFAULT_MODEL_MAX_TOKENS = 16_384;
/** Largest completion-token limit exposed by model settings. */ /** Largest completion-token limit exposed by model settings. */
export const MAX_MODEL_MAX_TOKENS = 131_072; export const MAX_MODEL_MAX_TOKENS = 131_072;
/** Default context window for an OpenAI-compatible connection. */ /** Default context window for an OpenAI-compatible connection. */
export const DEFAULT_MODEL_CONTEXT_WINDOW = 32_768; export const DEFAULT_MODEL_CONTEXT_WINDOW = 131_072;
/** Largest context window exposed by model settings. */ /** Largest context window exposed by model settings. */
export const MAX_MODEL_CONTEXT_WINDOW = 1_048_576; export const MAX_MODEL_CONTEXT_WINDOW = 1_048_576;

View File

@ -509,6 +509,12 @@ export const appContract = {
) )
.output(z.object({ ok: z.literal(true) })), .output(z.object({ ok: z.literal(true) })),
stop: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })), stop: oc.input(threadTarget).output(z.object({ ok: z.literal(true) })),
resume: oc.input(threadTarget).output(
z.object({
ok: z.literal(true),
runIds: z.array(Id),
}),
),
sendNow: oc.input(threadTarget.safeExtend({ messageId: Id.optional() })).output( sendNow: oc.input(threadTarget.safeExtend({ messageId: Id.optional() })).output(
z.object({ z.object({
ok: z.literal(true), ok: z.literal(true),

View File

@ -9,6 +9,7 @@ const APPROVAL_EXEMPT_TOOLS = new Set([
"list_files", "list_files",
"read_file", "read_file",
"write_file", "write_file",
"edit_file",
"shell", "shell",
"open_path", "open_path",
"launch_app", "launch_app",

View File

@ -577,6 +577,22 @@ describe("trackToolCallStreak", () => {
{ key: 'SLACK_FETCH_CONVERSATION_HISTORY:{"cursor":"a"}', count: 1 }, { key: 'SLACK_FETCH_CONVERSATION_HISTORY:{"cursor":"a"}', count: 1 },
); );
}); });
it("canonicalizes object key order when tracking streaks", () => {
const first = trackToolCallStreak({ key: undefined, count: 0 }, "write_file", {
path: "style.css",
content: "body {}",
});
expect(
trackToolCallStreak(first, "write_file", {
content: "body {}",
path: "style.css",
}),
).toEqual({
key: 'write_file:{"content":"body {}","path":"style.css"}',
count: 2,
});
});
}); });
describe("trackToolNameStreak", () => { describe("trackToolNameStreak", () => {

View File

@ -283,12 +283,25 @@ export function appendToolStep(steps: readonly ToolStep[], toolName: string): To
export type ToolCallStreak = { key: string | undefined; count: number }; export type ToolCallStreak = { key: string | undefined; count: number };
function canonicalizeStreakArgs(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalizeStreakArgs);
if (value && typeof value === "object") {
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
const nested = (value as Record<string, unknown>)[key];
if (nested !== undefined) sorted[key] = canonicalizeStreakArgs(nested);
}
return sorted;
}
return value;
}
export function trackToolCallStreak( export function trackToolCallStreak(
streak: ToolCallStreak, streak: ToolCallStreak,
name: string, name: string,
args: unknown, args: unknown,
): ToolCallStreak { ): ToolCallStreak {
const key = `${name}:${JSON.stringify(args)}`; const key = `${name}:${JSON.stringify(canonicalizeStreakArgs(args))}`;
return key === streak.key ? { key, count: streak.count + 1 } : { key, count: 1 }; return key === streak.key ? { key, count: streak.count + 1 } : { key, count: 1 };
} }

View File

@ -93,3 +93,22 @@ export function matchVoiceByName(voices: VoiceInfo[], query: string): VoiceInfo
return label.includes(suffix) || idSuffix.includes(suffix) || suffix.includes(idSuffix); return label.includes(suffix) || idSuffix.includes(suffix) || suffix.includes(idSuffix);
}); });
} }
/**
* Deterministic German voice assignment per bot so different agents in a project/group
* speak with distinct German voices when no explicit German voiceId is set.
*/
export function germanVoiceForBot(
seed: string | undefined,
currentVoiceId?: string | null,
): string {
if (currentVoiceId && /^d[fm]_/i.test(currentVoiceId)) return currentVoiceId;
const presets = ["df_eva", "dm_thorsten", "df_ramona", "dm_karlsson", "df_kerstin"] as const;
if (!seed) return presets[0];
let hash = 2166136261;
for (let i = 0; i < seed.length; i += 1) {
hash ^= seed.charCodeAt(i);
hash = Math.imul(hash, 16777619) >>> 0;
}
return presets[hash % presets.length] ?? presets[0];
}

View File

@ -396,6 +396,7 @@ export function createRepos(prisma: PrismaClient) {
instructions: string; instructions: string;
notifyOnFinish: boolean; notifyOnFinish: boolean;
autoSpeak?: boolean; autoSpeak?: boolean;
voiceId?: string | null;
color?: string; color?: string;
parentBotId?: string | null; parentBotId?: string | null;
computerMode?: ComputerMode; computerMode?: ComputerMode;
@ -466,6 +467,18 @@ export function createRepos(prisma: PrismaClient) {
where: { spaceId: actor.spaceId, userId: actor.userId }, where: { spaceId: actor.spaceId, userId: actor.userId },
_max: { position: true }, _max: { position: true },
}); });
const nextPosition = (positions._max.position ?? -1) + 1;
const germanVoices = [
"df_eva",
"dm_thorsten",
"df_ramona",
"dm_karlsson",
"df_kerstin",
] as const;
const defaultVoiceId =
input.voiceId !== undefined
? input.voiceId
: (germanVoices[nextPosition % germanVoices.length] ?? "df_eva");
const teamComputer = await ensureComputerRecord(tx, { const teamComputer = await ensureComputerRecord(tx, {
mode: "team", mode: "team",
spaceId: actor.spaceId, spaceId: actor.spaceId,
@ -482,8 +495,9 @@ export function createRepos(prisma: PrismaClient) {
instructions: input.instructions, instructions: input.instructions,
notifyOnFinish: input.notifyOnFinish, notifyOnFinish: input.notifyOnFinish,
autoSpeak: input.autoSpeak ?? true, autoSpeak: input.autoSpeak ?? true,
voiceId: defaultVoiceId,
color, color,
position: (positions._max.position ?? -1) + 1, position: nextPosition,
parentBotId: input.parentBotId ?? null, parentBotId: input.parentBotId ?? null,
computerId: teamComputer.id, computerId: teamComputer.id,
spawnKey: input.spawnKey, spawnKey: input.spawnKey,

View File

@ -35,14 +35,15 @@ export const STUDIO_BOT_DEFAULTS = {
name: "Studio", name: "Studio",
title: "Media producer", title: "Media producer",
description: description:
"Makes and art-directs images, brand visuals, video and audio: generates key visuals on ComfyUI, cuts, reframes and exports for every format, transcribes and subtitles.", "Makes and art-directs logos, brand marks, mockups, images, video and audio: generates signets, key visuals and campaign assets on ComfyUI, cuts, reframes and exports for every format, transcribes and subtitles.",
instructions: [ instructions: [
"You make, art-direct, and edit images, video and audio with the media tools, then hand the result back in a sentence or two.", "You make, art-direct, and edit logos, brand visuals, images, video and audio with the media tools, then hand the result back in a sentence or two.",
"", "",
"Work like a creative director on images and visual campaigns:", "Work like a flagship creative director on logos, brand identity, and visual campaigns:",
"- Write richly art-directed generate_image prompts: name the subject, physical materials and textures, camera and focal length (e.g. 50mm f/1.4 editorial portrait, 90mm macro studio product shot, medium-format analog grain), lighting setup (diffused north window light, warm chiaroscuro, crisp rim light), color palette, and composition. Pair with a clean negative_prompt that excludes watermarks, gibberish text, plastic skin, and stock-photo clichés.", "- For logos, signets, monograms, and emblems: call generate_image with aspect square and count 2–4, directing razor-sharp minimalist vector-style marks, clever negative space, balanced geometric proportions, or luxury debossed/screenprinted marks on a pure solid contrast background, paired with a clean negative_prompt that excludes 3D bevels, drop shadows, lens flare, photorealistic clutter, watermarks, and amateur clipart.",
"- Write richly art-directed generate_image prompts for photography and mockups: name the subject, physical materials and textures, camera and focal length (e.g. 50mm f/1.4 editorial portrait, 90mm macro studio product shot, medium-format analog grain), lighting setup (diffused north window light, warm chiaroscuro, crisp rim light), color palette, and composition. Pair with a clean negative_prompt that excludes watermarks, gibberish text, plastic skin, and stock-photo clichés.",
"- Give every brand or campaign its own visual world: check memory first so you never repeat another project's palette, lighting mood, or aesthetic.", "- Give every brand or campaign its own visual world: check memory first so you never repeat another project's palette, lighting mood, or aesthetic.",
"- Pick the aspect preset that fits the placement (landscape 16:9 for hero banners, portrait 4:5 or vertical 9:16 for editorial and social, square 1:1 for emblems and packshots) and use count (2–4) when exploring directions.", "- Pick the aspect preset that fits the placement (landscape 16:9 for hero banners, portrait 4:5 or vertical 9:16 for editorial and social, square 1:1 for logos, emblems, and packshots) and use count (2–4) when exploring directions.",
"- Refine strong frames with edit_image or source_artifact_id and strength instead of starting over from scratch.", "- Refine strong frames with edit_image or source_artifact_id and strength instead of starting over from scratch.",
"", "",
"Work like an editor on video and audio:", "Work like an editor on video and audio:",