fix(sandboxes,adapters): allow cloning git repositories and link space git remote in prompts
This commit is contained in:
parent
8a98bfe79f
commit
5168518759
@ -273,6 +273,7 @@ import {
|
||||
reactToThreadMessage,
|
||||
removeQueuedMessage,
|
||||
resolveThreadTarget,
|
||||
resumeThreadRuns,
|
||||
sendQueuedThreadMessagesNow,
|
||||
sendThreadMessage,
|
||||
setThreadUnreadState,
|
||||
@ -2064,6 +2065,16 @@ export function createRouter(deps: RouterDeps) {
|
||||
await stopThreadRuns(deps, context.actor, target);
|
||||
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 }) => {
|
||||
if ((await modelSetup(deps, context.actor)).needsModel) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
|
||||
|
||||
@ -2,16 +2,17 @@ import type { SandboxProvider } from "@rakazo/adapter-kit";
|
||||
import type { Actor } from "@rakazo/contracts";
|
||||
import type { PrismaClient } from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ThreadTarget } from "./thread-target.js";
|
||||
import {
|
||||
cancelSupersededQueuedRuns,
|
||||
followUpGroupThreadMessage,
|
||||
groupDefaultBotId,
|
||||
reactToThreadMessage,
|
||||
resolveGroupFollowUpBotId,
|
||||
resumeThreadRuns,
|
||||
sendQueuedThreadMessagesNow,
|
||||
sendThreadMessage,
|
||||
stopThreadRuns,
|
||||
type ThreadTarget,
|
||||
threadHead,
|
||||
threadSnapshot,
|
||||
} from "./thread-target.js";
|
||||
@ -2228,3 +2229,83 @@ describe("stopThreadRuns", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1307,6 +1307,106 @@ export async function stopThreadRuns(
|
||||
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
|
||||
* holding up the queue without discarding unclaimed steering messages, create a
|
||||
|
||||
@ -147,7 +147,11 @@ export async function persistVoiceCredential(
|
||||
let voiceId = input.voiceId?.trim() ?? "";
|
||||
if (!voiceId) {
|
||||
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 cred = await withSerializableRetry(() =>
|
||||
|
||||
@ -336,6 +336,7 @@ function Thread() {
|
||||
const [replyTarget, setReplyTarget] = useState<MobileMessage | null>(null);
|
||||
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [stopped, setStopped] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [markdownPreview, setMarkdownPreview] = useState<MarkdownArtifactPreviewTarget | null>(
|
||||
@ -1134,6 +1135,7 @@ function Thread() {
|
||||
setReplyTarget(null);
|
||||
setAttachmentNotice(null);
|
||||
setError(null);
|
||||
setStopped(false);
|
||||
}, [threadKey]);
|
||||
|
||||
function updateDraft(value: string) {
|
||||
@ -1228,6 +1230,7 @@ function Thread() {
|
||||
if (initialBotTarget) cancelFocusPrompt(initialBotTarget);
|
||||
};
|
||||
setSending(true);
|
||||
setStopped(false);
|
||||
setError(null);
|
||||
try {
|
||||
if (plan.shouldRunRoutines) {
|
||||
@ -1337,12 +1340,16 @@ function Thread() {
|
||||
const targetGroupId = groupId;
|
||||
if ((!targetBotId && !targetGroupId) || sending) return;
|
||||
setSending(true);
|
||||
setStopped(false);
|
||||
setError(null);
|
||||
try {
|
||||
await rpc(
|
||||
"threads/stop",
|
||||
targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! },
|
||||
);
|
||||
if (isCurrentTarget(targetBotId, targetGroupId)) {
|
||||
setStopped(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isCurrentTarget(targetBotId, targetGroupId)) {
|
||||
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);
|
||||
|
||||
async function sendQueuedNow(message: MobileMessage) {
|
||||
@ -1925,7 +1956,70 @@ function Thread() {
|
||||
>
|
||||
{error ? <Text style={{ color: tokens.mutedForeground, marginTop: 12 }}>{error}</Text> : null}
|
||||
{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}
|
||||
<View style={{ flex: 1, position: "relative" }}>
|
||||
{showPinnedPage ? (
|
||||
|
||||
@ -413,6 +413,7 @@ export function ShellPage() {
|
||||
const inputQueueRef = useRef(new OptimisticInputQueue());
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<OptimisticMessage[]>([]);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [stoppedThreadKey, setStoppedThreadKey] = 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).
|
||||
@ -1245,15 +1246,29 @@ export function ShellPage() {
|
||||
"df_kerstin",
|
||||
] as const;
|
||||
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) {
|
||||
hash = (hash * 31 + botId.charCodeAt(i)) >>> 0;
|
||||
hash ^= botId.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619) >>> 0;
|
||||
}
|
||||
return germanVoices[hash % germanVoices.length] ?? "df_eva";
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubSpeech = speaker.subscribe((state) => {
|
||||
setSpeakingMessageId(state.status === "idle" ? null : (state.messageId ?? null));
|
||||
});
|
||||
return () => {
|
||||
unsubSpeech();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void rpc.voice
|
||||
.status()
|
||||
@ -1277,27 +1292,18 @@ export function ShellPage() {
|
||||
setVoiceStatus(status);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
const unsubSpeech = speaker.subscribe((state) => {
|
||||
setSpeakingMessageId(state.status === "idle" ? null : (state.messageId ?? null));
|
||||
});
|
||||
return () => {
|
||||
unsubSpeech();
|
||||
};
|
||||
}, []);
|
||||
}, [currentSpaceId]);
|
||||
|
||||
const migratedBotIdsRef = useRef(new Set<string>());
|
||||
useEffect(() => {
|
||||
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(
|
||||
(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;
|
||||
for (const b of staleBots) migratedBotIdsRef.current.add(b.id);
|
||||
void Promise.all(
|
||||
staleBots.map((b) =>
|
||||
rpc.bots
|
||||
@ -1325,7 +1331,13 @@ export function ShellPage() {
|
||||
if (!threadKey || !currentSnapshot) return;
|
||||
const lastBot = [...currentSnapshot.messages]
|
||||
.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) {
|
||||
autoSpokenBotId.current = threadKey;
|
||||
autoSpoken.current = lastBot?.id ?? null;
|
||||
@ -1344,12 +1356,6 @@ export function ShellPage() {
|
||||
autoSpoken.current = lastBot?.id ?? null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
currentSnapshot.run &&
|
||||
["running", "queued", "leased"].includes(currentSnapshot.run.status)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!lastBot || lastBot.id === autoSpoken.current) return;
|
||||
const text = spokenSummaryFromBlocks(lastBot.blocks);
|
||||
if (!text) return;
|
||||
@ -2592,6 +2598,7 @@ export function ShellPage() {
|
||||
hasAttachments: attachments.length > 0,
|
||||
});
|
||||
if (plan.isNoOp) return;
|
||||
setStoppedThreadKey(null);
|
||||
const reroutedToGroup = Boolean(
|
||||
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.
|
||||
if (activeGroupId.current === groupTarget) {
|
||||
setStoppedThreadKey(`group:${groupTarget}`);
|
||||
updateSnapshot((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
|
||||
// blocked while the API is already idle.
|
||||
if (activeBotId.current === botTarget) {
|
||||
setStoppedThreadKey(`bot:${botTarget}`);
|
||||
updateSnapshot((prev) =>
|
||||
!prev || (prev.botId !== botTarget && prev.botId) ? prev : clearActiveThreadRuns(prev),
|
||||
);
|
||||
@ -2915,6 +2924,45 @@ export function ShellPage() {
|
||||
setSending(false);
|
||||
}
|
||||
}, [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 id = activeBotId.current;
|
||||
if (!id || teachBusy) return;
|
||||
@ -4333,7 +4381,7 @@ export function ShellPage() {
|
||||
voiceReady={Boolean(voiceStatus?.ready)}
|
||||
speakingMessageId={speakingMessageId}
|
||||
onSpeak={speakMessage}
|
||||
onRestartTask={sendMessage}
|
||||
onRestartTask={resumeRun}
|
||||
onSendQueuedNow={sendQueuedMessageNow}
|
||||
onRemoveQueuedMessage={removeQueuedMessage}
|
||||
/>
|
||||
@ -4366,6 +4414,13 @@ export function ShellPage() {
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onSend={sendMessage}
|
||||
onStop={stopRun}
|
||||
stopped={
|
||||
!composerRunning &&
|
||||
stoppedThreadKey ===
|
||||
(inGroup ? `group:${groupId}` : `bot:${active?.id}`)
|
||||
}
|
||||
onResume={resumeRun}
|
||||
onDismissStopped={() => setStoppedThreadKey(null)}
|
||||
onVoice={
|
||||
active || inGroup
|
||||
? () => {
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
ModelTierSchema,
|
||||
normalizeBotTags,
|
||||
} from "@rakazo/contracts";
|
||||
import { GERMAN_VOICE_PRESETS, germanVoiceForBot, voicesForLocale } from "@rakazo/core";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@ -37,6 +38,7 @@ import { connectedModelOptions } from "../../lib/connected-models";
|
||||
import { lazyWithRecovery } from "../../lib/lazy-recovery";
|
||||
import { modelTierLabel } from "../../lib/model-tier-label";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { readUiLocale } from "../../lib/ui-locale";
|
||||
import { BotCapabilitiesFields } from "./bot-capabilities";
|
||||
|
||||
const ScratchpadSection = lazyWithRecovery("ScratchpadSection", () =>
|
||||
@ -280,8 +282,8 @@ export function BotSettings({
|
||||
const [computerMode, setComputerMode] = useState(bot.computerMode);
|
||||
const [memoryScope, setMemoryScope] = useState(bot.memoryScope);
|
||||
const [autoSpeak, setAutoSpeak] = useState(bot.autoSpeak);
|
||||
const [voiceId, setVoiceId] = useState(bot.voiceId ?? "");
|
||||
const [voices, setVoices] = useState<VoiceInfo[]>([]);
|
||||
const [voiceId, setVoiceId] = useState(bot.voiceId || germanVoiceForBot(bot.id, bot.voiceId));
|
||||
const [voices, setVoices] = useState<VoiceInfo[]>(GERMAN_VOICE_PRESETS);
|
||||
const [modelKey, setModelKey] = useState(
|
||||
bot.modelTier
|
||||
? tierOptionKey(bot.modelTier)
|
||||
@ -314,8 +316,15 @@ export function BotSettings({
|
||||
useEffect(() => {
|
||||
void rpc.voice
|
||||
.voices({})
|
||||
.then(setVoices)
|
||||
.catch(() => setVoices([]));
|
||||
.then((listed) => {
|
||||
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
|
||||
.builtinTools({ botId: bot.id })
|
||||
.then(setToolNames)
|
||||
@ -675,7 +684,7 @@ export function BotSettings({
|
||||
<NativeSelectOption value="">{t`Account default`}</NativeSelectOption>
|
||||
{voices.map((voice) => (
|
||||
<NativeSelectOption key={voice.id} value={voice.id}>
|
||||
{voice.label}
|
||||
{voice.description ? `${voice.label} (${voice.description})` : voice.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
@ -31,6 +31,7 @@ import {
|
||||
Play,
|
||||
Plus,
|
||||
Puzzle,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Square,
|
||||
X,
|
||||
@ -83,6 +84,9 @@ export const Composer = memo(function Composer({
|
||||
onConnectModel,
|
||||
onAutopilot,
|
||||
activePlan,
|
||||
stopped = false,
|
||||
onResume,
|
||||
onDismissStopped,
|
||||
}: {
|
||||
draftKey?: string;
|
||||
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. */
|
||||
onAutopilot?: (goal: string, until?: string) => Promise<void>;
|
||||
activePlan?: ComposerActivePlan | null;
|
||||
stopped?: boolean;
|
||||
onResume?: () => void | Promise<void>;
|
||||
onDismissStopped?: () => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
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"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
aria-label={t`Dismiss error`}
|
||||
@ -497,6 +516,39 @@ export const Composer = memo(function Composer({
|
||||
<X strokeWidth={2} className="size-icon-sm" />
|
||||
</button>
|
||||
</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}
|
||||
{replyTarget ? (
|
||||
<div
|
||||
@ -650,7 +702,7 @@ export const Composer = memo(function Composer({
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{activePlan ? <ComposerTaskPill activePlan={activePlan} /> : null}
|
||||
{activePlan ? <ComposerTaskPill activePlan={activePlan} onResume={onResume} /> : null}
|
||||
<div
|
||||
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"
|
||||
|
||||
@ -40,6 +40,8 @@ import {
|
||||
Maximize2,
|
||||
MinusCircle,
|
||||
Moon,
|
||||
PauseCircle,
|
||||
RotateCcw,
|
||||
Sun,
|
||||
X,
|
||||
ZoomIn,
|
||||
@ -1365,7 +1367,13 @@ export function extractActivePlanState(
|
||||
* 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.
|
||||
*/
|
||||
export function ComposerTaskPill({ activePlan }: { activePlan: ComposerActivePlan }) {
|
||||
export function ComposerTaskPill({
|
||||
activePlan,
|
||||
onResume,
|
||||
}: {
|
||||
activePlan: ComposerActivePlan;
|
||||
onResume?: () => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const drawerId = useId();
|
||||
const { block, steps, running, startedAt } = activePlan;
|
||||
@ -1431,6 +1439,11 @@ export function ComposerTaskPill({ activePlan }: { activePlan: ComposerActivePla
|
||||
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"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-auto mb-1.5 h-1 w-8 rounded-full bg-border transition-colors"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="composer-task-pill-toggle"
|
||||
@ -1463,21 +1476,17 @@ export function ComposerTaskPill({ activePlan }: { activePlan: ComposerActivePla
|
||||
}
|
||||
setExpanded((current) => !current);
|
||||
}}
|
||||
className="w-full cursor-pointer text-start outline-none"
|
||||
className="min-w-0 flex-1 cursor-pointer text-start outline-none"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-auto mb-1.5 h-1 w-8 rounded-full bg-border transition-colors"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="shrink-0">
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="size-4 text-success" aria-hidden="true" />
|
||||
) : activeStep?.status === "in_progress" || running ? (
|
||||
) : running ? (
|
||||
<Loader2 className="size-4 animate-spin text-warning" aria-hidden="true" />
|
||||
) : (
|
||||
<Circle className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
<PauseCircle className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
@ -1544,6 +1553,18 @@ export function ComposerTaskPill({ activePlan }: { activePlan: ComposerActivePla
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{!running && !isCompleted && onResume ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="composer-task-pill-resume"
|
||||
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 cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="size-3" aria-hidden="true" />
|
||||
<Trans>Resume</Trans>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={drawerId}
|
||||
@ -1587,8 +1608,10 @@ export function ComposerTaskPill({ activePlan }: { activePlan: ComposerActivePla
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{done ? (
|
||||
<CheckCircle2 className="size-4 text-success" aria-hidden="true" />
|
||||
) : active ? (
|
||||
) : active && running ? (
|
||||
<Loader2 className="size-4 animate-spin text-warning" aria-hidden="true" />
|
||||
) : active ? (
|
||||
<PauseCircle className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
) : skipped ? (
|
||||
<MinusCircle className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
) : (
|
||||
|
||||
@ -52,6 +52,8 @@ describe("github broker host git", () => {
|
||||
it("maps computer paths onto the host workspace", () => {
|
||||
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, "~")).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, "/tmp")).toBeNull();
|
||||
expect(hostPathFor(session, "/home/rakazoX")).toBeNull();
|
||||
@ -153,6 +155,19 @@ describe("github broker remotes", () => {
|
||||
repo,
|
||||
),
|
||||
).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 {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@ -38,8 +38,13 @@ const GIT_CREDENTIAL_ARGS = [
|
||||
/** 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 {
|
||||
const root = session.containerRoot.replace(/\/$/, "");
|
||||
if (containerPath !== root && !containerPath.startsWith(`${root}/`)) return null;
|
||||
const resolved = path.resolve(session.workspacePath, path.relative(root, containerPath));
|
||||
const normalized = containerPath.startsWith("~/")
|
||||
? `${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);
|
||||
return inside.startsWith("..") || path.isAbsolute(inside) ? null : resolved;
|
||||
}
|
||||
@ -195,6 +200,8 @@ export async function assertRemoteAllowed(
|
||||
cwd = session.workspacePath,
|
||||
) {
|
||||
if (!session.allowedRemoteUrl) return;
|
||||
const sub = argv[1];
|
||||
if (sub !== "push") return;
|
||||
const remoteArg = argv.find((part, index) => index > 1 && !part.startsWith("-"));
|
||||
const candidate = remoteArg
|
||||
? normalizeGitRemoteUrl(remoteArg)
|
||||
@ -202,7 +209,7 @@ export async function assertRemoteAllowed(
|
||||
: ((await readRemoteUrl(session, remoteArg, cwd)) ?? remoteArg)
|
||||
: await readRemoteUrl(session, "origin", cwd);
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -79,12 +79,14 @@ const SKILLS: Array<{ name: string; description: string; body: string }> = [
|
||||
- Laconic / Sovereign: 2–4 words of quiet, unmistakable conviction.
|
||||
- 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).
|
||||
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.
|
||||
4. Hybrid Logo System (Wordmark + Signet + Lockups). Never deliver a crude single-primitive SVG icon. Construct a three-part logo suite:
|
||||
- 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.
|
||||
- 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.
|
||||
- Three Lockups: Primary horizontal/stacked lockup, 1:1 monogram/favicon mark, and monochrome inverted variants tested on both light Canvas and dark Ink backgrounds.
|
||||
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.`,
|
||||
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:
|
||||
- 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.
|
||||
- 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.
|
||||
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:
|
||||
- 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.
|
||||
- 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",
|
||||
|
||||
@ -177,7 +177,7 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
{
|
||||
name: "read_file",
|
||||
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: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@ -187,6 +187,14 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
description:
|
||||
"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"],
|
||||
},
|
||||
@ -194,7 +202,7 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
{
|
||||
name: "write_file",
|
||||
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: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@ -204,6 +212,30 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
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",
|
||||
description:
|
||||
@ -681,7 +713,7 @@ export const builtinAgentTools: ConnectorTool[] = [
|
||||
{
|
||||
name: "generate_image",
|
||||
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),
|
||||
},
|
||||
{
|
||||
|
||||
@ -77,6 +77,12 @@ describe("createComfyUiProvider", () => {
|
||||
expect(Object.values(texts).map((node) => node.inputs.text)).toEqual(
|
||||
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 () => {
|
||||
|
||||
@ -57,7 +57,7 @@ const VIDEO_STEPS = 30;
|
||||
const DEFAULT_STRENGTH = 0.6;
|
||||
const DEFAULT_NEGATIVE_PROMPTS = {
|
||||
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:
|
||||
"worst quality, inconsistent motion, blurry, jittery, distorted, plastic skin, stock footage, watermark",
|
||||
};
|
||||
|
||||
@ -300,7 +300,7 @@ export async function loadDelegatedTaskInstruction(
|
||||
prisma: PrismaClient,
|
||||
run: { id: string; trigger: string },
|
||||
): 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({
|
||||
where: { runId: run.id },
|
||||
select: { title: true, brief: true, deadlineAt: true, requesterBotId: true },
|
||||
@ -323,7 +323,7 @@ export async function syncDelegatedTaskRun(
|
||||
deps: DelegationDeps,
|
||||
run: { id: string; trigger: string },
|
||||
): 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) => {
|
||||
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 },
|
||||
});
|
||||
// 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;
|
||||
await lockThreads(tx, run ? [task.threadId, run.threadId] : [task.threadId]);
|
||||
const terminal = !isOpenDelegatedTaskStatus(status);
|
||||
|
||||
@ -337,4 +337,77 @@ describe("bot capabilities in runs", () => {
|
||||
expect(results[0]).toEqual({ error: "Only the main agent can schedule for another bot." });
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -302,6 +302,7 @@ describe("formatWorkingPrinciples", () => {
|
||||
|
||||
const withImage = formatWorkingPrinciples(new Set(["generate_image"]));
|
||||
expect(withImage).toContain("generate_image");
|
||||
expect(withImage).toContain("logos, brand marks, signets, monograms");
|
||||
expect(withImage).toContain("continue_after true");
|
||||
expect(withImage).toContain("attachments/<artifactId>.<ext>");
|
||||
});
|
||||
@ -315,4 +316,13 @@ describe("formatWorkingPrinciples", () => {
|
||||
expect(withAttach).toContain("chat gallery and preview pane");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.",
|
||||
"- 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")
|
||||
? "- 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,
|
||||
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."
|
||||
: 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.",
|
||||
"- When a request has a real problem, say so and propose the better option, then follow the user's decision.",
|
||||
toolNames.has("skill_read")
|
||||
|
||||
@ -29,12 +29,7 @@ import {
|
||||
MediaLibrarySearchInputSchema,
|
||||
MessageBlock as MessageBlockSchema,
|
||||
} from "@rakazo/contracts";
|
||||
import type {
|
||||
ActionApprovalRule,
|
||||
PlanCreateArgs,
|
||||
ShowCardArgs,
|
||||
ToolCallStreak,
|
||||
} from "@rakazo/core";
|
||||
import type { ActionApprovalRule, PlanCreateArgs, ShowCardArgs } from "@rakazo/core";
|
||||
import {
|
||||
appendTextSegment,
|
||||
appendToolCallSegment,
|
||||
@ -330,6 +325,7 @@ import {
|
||||
} from "./model-vision.js";
|
||||
import { notifyUserFromTool } from "./notify-user-tool.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 {
|
||||
assertPlotDataWithinLimits,
|
||||
@ -403,7 +399,7 @@ import {
|
||||
formatMiB,
|
||||
materializeCurrentTurnFiles,
|
||||
} 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 { updateUserProfileFromTool } from "./user-profile-tools.js";
|
||||
import {
|
||||
@ -1091,6 +1087,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
|
||||
let leaseValid = true;
|
||||
let lastLeaseCheckAt = 0;
|
||||
let leaseCheckInFlight: Promise<boolean> | undefined;
|
||||
let retainComputerLease = false;
|
||||
let screenRelease: { computer: ComputerRef; context: AdapterContext } | undefined;
|
||||
let runAbortController: AbortController | null = null;
|
||||
@ -1101,8 +1098,10 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
runAbortController?.abort();
|
||||
void runtime.abort(runId).catch(() => undefined);
|
||||
};
|
||||
const verifyLeaseOwnership = async (): Promise<boolean> => {
|
||||
if (!leaseValid) return false;
|
||||
const verifyLeaseOwnership = (): Promise<boolean> => {
|
||||
if (!leaseValid) return Promise.resolve(false);
|
||||
leaseCheckInFlight ??= (async () => {
|
||||
lastLeaseCheckAt = Date.now();
|
||||
const still = await deps.prisma.run.findUnique({
|
||||
where: { id: runId },
|
||||
select: { status: true, leaseOwner: true, leaseFence: true, checkpoint: true },
|
||||
@ -1123,6 +1122,10 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => {
|
||||
leaseCheckInFlight = undefined;
|
||||
});
|
||||
return leaseCheckInFlight;
|
||||
};
|
||||
const leasePoll = setInterval(() => {
|
||||
void verifyLeaseOwnership().catch(() => invalidateLostLease());
|
||||
@ -1224,12 +1227,14 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
userId: run.userId,
|
||||
}),
|
||||
]);
|
||||
const githubIntegrationToken = await githubIntegrationTokenForBroker(
|
||||
deps.prisma,
|
||||
deps.secretStore,
|
||||
run.userId,
|
||||
settings,
|
||||
);
|
||||
const hasModelOverride = Boolean(bot.modelProvider && bot.modelId);
|
||||
const [githubIntegrationToken, overrideCredential, tierModel] = await Promise.all([
|
||||
githubIntegrationTokenForBroker(deps.prisma, deps.secretStore, run.userId, settings),
|
||||
hasModelOverride && bot.modelProvider
|
||||
? findModelCredential(deps.prisma, run, bot.modelProvider, bot.modelId)
|
||||
: Promise.resolve(null),
|
||||
bot.modelTier ? findSpaceModelTier(deps.prisma, run, bot.modelTier) : Promise.resolve(null),
|
||||
]);
|
||||
const brokerWorkspacePath =
|
||||
projectWorkspacePath ??
|
||||
(bot.computer?.homeKey
|
||||
@ -1256,14 +1261,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
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.
|
||||
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();
|
||||
if (!leaseValid) runAbortController.abort();
|
||||
if (deps.shutdownSignal?.aborted) runAbortController.abort(deps.shutdownSignal.reason);
|
||||
@ -1311,15 +1308,17 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
: null;
|
||||
const semanticMemory: SemanticMemoryProvider | null = configuredMemory?.provider ?? null;
|
||||
|
||||
await deps.events.append({
|
||||
await Promise.all([
|
||||
deps.events.append({
|
||||
spaceId: run.spaceId,
|
||||
threadId: thread.id,
|
||||
botId: bot.id,
|
||||
type: "run.started",
|
||||
runId,
|
||||
payload: { trigger: run.trigger, routineId: run.routineId },
|
||||
});
|
||||
await syncDelegatedTaskRun(deps, run);
|
||||
}),
|
||||
syncDelegatedTaskRun(deps, run),
|
||||
]);
|
||||
|
||||
const discoveredPromise = deps.connector
|
||||
? deps.connector.discoverTools(context)
|
||||
@ -1484,7 +1483,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const resolved = await resolveModelKey(
|
||||
const [resolved] = await Promise.all([
|
||||
resolveModelKey(
|
||||
deps,
|
||||
run.userId,
|
||||
run.spaceId,
|
||||
@ -1492,12 +1492,13 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
runModelProvider,
|
||||
runModelId,
|
||||
(values) => runSecrets.push(...values),
|
||||
);
|
||||
runSecrets.push(...resolved.redact);
|
||||
await deps.prisma.run.updateMany({
|
||||
),
|
||||
deps.prisma.run.updateMany({
|
||||
where: { id: runId, status: "running", leaseOwner: workerId, leaseFence: fence },
|
||||
data: { modelProvider: runModelProvider, modelId: runModelId },
|
||||
});
|
||||
}),
|
||||
]);
|
||||
runSecrets.push(...resolved.redact);
|
||||
if (!bot.computer) throw new Error("Bot has no computer");
|
||||
const storedComputer = bot.computer;
|
||||
const computerMode = parseComputerMode(storedComputer.scope);
|
||||
@ -1703,6 +1704,20 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
.then((row) => row?.enabled ?? deploymentAutoReviewDefault());
|
||||
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 exposedToolNames = new Set(tools.map((tool) => tool.name));
|
||||
const approvedEffectReplays = createApprovedEffectReplayQueue(approvedEffects);
|
||||
@ -1728,6 +1743,9 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
: hostAccessNote
|
||||
? ` ${hostAccessNote}`
|
||||
: "";
|
||||
const linkedGitRemoteSuffix = spaceRecord?.gitRemoteUrl
|
||||
? ` This space is linked to git repository ${spaceRecord.gitRemoteUrl}.${spaceRecord.gitDefaultBranch ? ` Default branch: ${spaceRecord.gitDefaultBranch}.` : ""}`
|
||||
: "";
|
||||
const workspaceInstruction =
|
||||
(computerMode === "team"
|
||||
? 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.") +
|
||||
desktopHostSuffix +
|
||||
gitBrokerPolicySuffix;
|
||||
gitBrokerPolicySuffix +
|
||||
linkedGitRemoteSuffix;
|
||||
|
||||
let assembled = "";
|
||||
let currentTextSegment = "";
|
||||
@ -1788,7 +1807,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
let pendingProgress = "";
|
||||
let lastProgressAt = 0;
|
||||
let hasStreamedText = false;
|
||||
let toolCallStreak: ToolCallStreak = { key: undefined, count: 0 };
|
||||
let toolCallStreak: ToolLoopGuardState = { key: undefined, count: 0 };
|
||||
let lastComputerFrameId: string | undefined;
|
||||
let terminalCheckpointComplete = false;
|
||||
let approvalPausePending = false;
|
||||
@ -2129,16 +2148,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
: await loadAutoReviewPreference();
|
||||
const checker = requiresMandatoryApproval ? undefined : resolveAutoReviewChecker();
|
||||
const checkerConfigured =
|
||||
autoReviewPref && checker
|
||||
? isAutoReviewCheckerConfigured({}) ||
|
||||
Boolean(
|
||||
await findModelCredential(
|
||||
deps.prisma,
|
||||
{ userId: run.userId, spaceId: run.spaceId },
|
||||
checker.provider,
|
||||
),
|
||||
)
|
||||
: false;
|
||||
autoReviewPref && checker ? await loadCheckerConfigured(checker.provider) : false;
|
||||
const plan = requiresMandatoryApproval
|
||||
? "ask"
|
||||
: planActionGate({
|
||||
@ -2556,10 +2566,44 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||||
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 =
|
||||
typeof args.query === "string" && args.query.trim() ? args.query.trim() : undefined;
|
||||
if (focusQuery) {
|
||||
const extracted = extractQueryRelevantPageText(decoded, focusQuery, 12_000);
|
||||
const extracted = extractQueryRelevantPageText(
|
||||
decoded,
|
||||
focusQuery,
|
||||
TOOL_RESULT_TEXT_LIMIT,
|
||||
);
|
||||
return {
|
||||
path: filePath,
|
||||
content: extracted.text,
|
||||
@ -2596,6 +2640,103 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
}
|
||||
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 (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") {
|
||||
assembled += event.text;
|
||||
currentTextSegment += event.text;
|
||||
toolCallStreak = { key: undefined, count: 0 };
|
||||
tryFlushPendingTools();
|
||||
pendingProgress += progressRedactor.push(event.text);
|
||||
const now = Date.now();
|
||||
@ -4563,7 +4703,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
await flushProgress();
|
||||
}
|
||||
} else if (event.type === "progress") {
|
||||
toolCallStreak = { key: undefined, count: 0 };
|
||||
// Flush batched text deltas first so an activity line cannot land
|
||||
// ahead of text the model streamed before the tool call.
|
||||
if (pendingProgress) {
|
||||
@ -5157,6 +5296,14 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
getLogger().error("history.compact enqueue failed", error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
approvalPausePending ||
|
||||
!leaseValid ||
|
||||
deps.shutdownSignal?.aborted ||
|
||||
runAbortController?.signal.aborted
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (run.trigger === "routine") {
|
||||
const attemptCount = await deps.prisma.attempt.count({ where: { runId } });
|
||||
const healed = await handleRoutineSelfHealing(deps, runId, attemptCount, error);
|
||||
|
||||
@ -167,6 +167,7 @@ describe("builtin tools", () => {
|
||||
expect(builtinAgentTools.map((t) => t.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"attach_file",
|
||||
"shell",
|
||||
"remember",
|
||||
|
||||
@ -531,6 +531,25 @@ export function createMediaRuntime(
|
||||
select: { id: true },
|
||||
});
|
||||
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 };
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { MediaCapabilities, MediaRuntime, StartMediaJobInput } from "@rakazo/adapter-kit";
|
||||
import { STUDIO_BOT_TOOLS } from "@rakazo/db";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadDelegatedTaskInstruction, syncDelegatedTask } from "./delegated-tasks.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 {
|
||||
executeMediaTool,
|
||||
isMediaToolName,
|
||||
@ -197,4 +198,69 @@ describe("media tools", () => {
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -78,7 +78,7 @@ export async function executeMediaTool(
|
||||
jobId: started.jobId,
|
||||
status: "queued",
|
||||
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.",
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@ -81,8 +81,8 @@ describe("local model provider", () => {
|
||||
delete process.env.RAKAZO_LOCAL_CONTEXT_WINDOW;
|
||||
delete process.env.RAKAZO_LOCAL_MAX_TOKENS;
|
||||
let model = localProvider()?.getModels()[0];
|
||||
expect(model?.contextWindow).toBe(32_768);
|
||||
expect(model?.maxTokens).toBe(4_096);
|
||||
expect(model?.contextWindow).toBe(131_072);
|
||||
expect(model?.maxTokens).toBe(16_384);
|
||||
|
||||
process.env.RAKAZO_LOCAL_CONTEXT_WINDOW = "8192";
|
||||
process.env.RAKAZO_LOCAL_MAX_TOKENS = "512";
|
||||
|
||||
@ -6,7 +6,12 @@ import {
|
||||
} from "@earendil-works/pi-ai";
|
||||
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";
|
||||
/**
|
||||
* 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_CONTEXT_WINDOW = 32_768;
|
||||
const DEFAULT_MAX_TOKENS = 4_096;
|
||||
const DEFAULT_CONTEXT_WINDOW = DEFAULT_MODEL_CONTEXT_WINDOW;
|
||||
const DEFAULT_MAX_TOKENS = DEFAULT_MODEL_MAX_TOKENS;
|
||||
|
||||
export function localBaseUrl(): string {
|
||||
return checkedUrl(process.env.RAKAZO_LOCAL_MODELS_URL?.trim() || DEFAULT_BASE_URL);
|
||||
|
||||
@ -26,7 +26,7 @@ import type {
|
||||
AgentSteeringMessage,
|
||||
AgentToolExecutionResult,
|
||||
} 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 { PiRuntimeCredentialStore, toOAuthCredential } from "./pi-credentials.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 =
|
||||
"\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 {
|
||||
if (!result || typeof result !== "object") return false;
|
||||
if ("error" in result && (result as { error?: unknown }).error) return true;
|
||||
function isErrorPayload(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
if ("error" in payload && Boolean((payload as { error?: unknown }).error)) return true;
|
||||
if (
|
||||
"details" in result &&
|
||||
result.details &&
|
||||
typeof result.details === "object" &&
|
||||
"error" in result.details &&
|
||||
(result.details as { error?: unknown }).error
|
||||
"exitCode" in payload &&
|
||||
typeof (payload as { exitCode?: unknown }).exitCode === "number" &&
|
||||
(payload as { exitCode: number }).exitCode !== 0
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -243,8 +263,8 @@ export function configuredOpenRouterModel(id: string): Model<"openai-completions
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 16_384,
|
||||
maxTokens: 4_096,
|
||||
contextWindow: DEFAULT_MODEL_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MODEL_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
@ -418,9 +438,31 @@ export function pruneComputerScreenshotContext(
|
||||
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 */
|
||||
export function pruneCancelledToolResultsContext(messages: AgentMessage[]): AgentMessage[] {
|
||||
const cancelledIds = new Set<string>();
|
||||
let hasTruncatedToolMessage = false;
|
||||
for (const message of messages) {
|
||||
if (message.role === "toolResult") {
|
||||
const details =
|
||||
@ -428,9 +470,17 @@ export function pruneCancelledToolResultsContext(messages: AgentMessage[]): Agen
|
||||
if (details?.cancelled === true) {
|
||||
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[] = [];
|
||||
for (const message of messages) {
|
||||
@ -456,7 +506,7 @@ export function pruneCancelledToolResultsContext(messages: AgentMessage[]): Agen
|
||||
continue;
|
||||
}
|
||||
}
|
||||
transformed.push(message);
|
||||
transformed.push(rewriteTruncatedToolResultMessage(message));
|
||||
}
|
||||
return transformed;
|
||||
}
|
||||
@ -784,8 +834,9 @@ export function modelForCompletion(
|
||||
const CONSERVATIVE_CHARS_PER_TOKEN = 3;
|
||||
const MESSAGE_TOKEN_OVERHEAD = 12;
|
||||
const ESTIMATED_IMAGE_CHARS = 4_800;
|
||||
const RECENT_FULL_TOOL_RESULTS = 2;
|
||||
const COMPACTED_TOOL_RESULT_CHARS = 800;
|
||||
const RECENT_FULL_TOOL_RESULTS = 6;
|
||||
const COMPACTED_TOOL_RESULT_CHARS = 2_400;
|
||||
const EMERGENCY_COMPACTED_TOOL_RESULT_CHARS = 800;
|
||||
const COMPACTED_TOOL_CALL_STRING_CHARS = 400;
|
||||
const MAX_CONTEXT_OVERFLOW_RETRIES = 3;
|
||||
|
||||
@ -1103,7 +1154,7 @@ export function pruneContextToWindow<T extends AgentMessage>(
|
||||
...msg,
|
||||
content: compactContentParts(
|
||||
msg.content,
|
||||
COMPACTED_TOOL_RESULT_CHARS,
|
||||
EMERGENCY_COMPACTED_TOOL_RESULT_CHARS,
|
||||
"\n…[tool output truncated to fit context window]…\n",
|
||||
),
|
||||
};
|
||||
@ -1480,4 +1531,9 @@ export interface ToolHost {
|
||||
consecutiveToolFailures?: number;
|
||||
/** Tracks whether a code file mutation occurred without subsequent verification */
|
||||
unverifiedCodeMutation?: boolean;
|
||||
/** Shared mid-step steering poller state across concurrent tools in this host */
|
||||
steeringPoller?: {
|
||||
timer?: NodeJS.Timeout;
|
||||
inFlight: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@ -268,4 +268,22 @@ describe("Pi runtime cancellation", () => {
|
||||
await stopping;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -31,7 +31,7 @@ export function resolveModelStreamMaxRetries(provider: string, optionsMaxRetries
|
||||
: 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 {
|
||||
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
||||
|
||||
@ -464,8 +464,8 @@ describe("Pi agent thinking level", () => {
|
||||
id: "stealth/ox-alpha",
|
||||
provider: "openrouter",
|
||||
reasoning: true,
|
||||
contextWindow: 16_384,
|
||||
maxTokens: 4_096,
|
||||
contextWindow: 131_072,
|
||||
maxTokens: 16_384,
|
||||
});
|
||||
// Unknown OpenRouter PI_DEFAULT_MODEL must not force thinking off (#114).
|
||||
expect(levels).toEqual(["medium", "medium"]);
|
||||
|
||||
@ -1456,4 +1456,111 @@ describe("Pi connector tool dispatch", () => {
|
||||
"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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
isVerificationToolCall,
|
||||
maxInputTokensForWindow,
|
||||
parseContextOverflowDetails,
|
||||
pruneCancelledToolResultsContext,
|
||||
pruneContextToWindow,
|
||||
raceSignal,
|
||||
streamWithContextOverflowRecovery,
|
||||
@ -28,6 +29,9 @@ describe("describeToolActivity", () => {
|
||||
expect(describeToolActivity("write_file", { path: "out.csv", content: "…" })).toBe(
|
||||
"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("add_mcp_server", { name: "Linear" })).toBe(
|
||||
"Connecting MCP server: Linear",
|
||||
@ -433,7 +437,54 @@ describe("adaptive thinking and tool loop diagnostics", () => {
|
||||
it("detects tool result errors for consecutive-failure diagnostics", () => {
|
||||
expect(isToolResultError({ error: "Command exited with code 1" })).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(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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -55,7 +55,11 @@ import {
|
||||
toPiImages,
|
||||
withoutSteeringMessages,
|
||||
} 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 { PiJsonlSessionRecorder } from "./pi-session.js";
|
||||
import { textContentArg } from "./tool-text.js";
|
||||
@ -294,11 +298,11 @@ export class PiAgentRuntime implements AgentRuntime {
|
||||
},
|
||||
),
|
||||
prepareNextTurnWithContext: async () => {
|
||||
if (
|
||||
(toolCalls >= 2 || host.unverifiedCodeMutation) &&
|
||||
agent.state.thinkingLevel !== escalatedThinkingLevel
|
||||
) {
|
||||
agent.state.thinkingLevel = escalatedThinkingLevel;
|
||||
const needsEscalation =
|
||||
Boolean(host.unverifiedCodeMutation) || (host.consecutiveToolFailures ?? 0) >= 2;
|
||||
const targetThinkingLevel = needsEscalation ? escalatedThinkingLevel : thinkingLevel;
|
||||
if (agent.state.thinkingLevel !== targetThinkingLevel) {
|
||||
agent.state.thinkingLevel = targetThinkingLevel;
|
||||
}
|
||||
if (!request.claimSteering) return undefined;
|
||||
const steering = await request.claimSteering([...seenSteeringIds]);
|
||||
@ -380,6 +384,19 @@ export class PiAgentRuntime implements AgentRuntime {
|
||||
event.message.role === "assistant" &&
|
||||
event.message.content.some((part) => part.type === "toolCall");
|
||||
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
|
||||
// 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
|
||||
// errorMessage set. Treat that as a soft stop so the turn still ends
|
||||
// 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;
|
||||
if (signal.aborted && !budgetExceeded) {
|
||||
queue.push({ type: "done", text: "stopped" });
|
||||
return;
|
||||
}
|
||||
const error = agent.state.errorMessage;
|
||||
if (error && !budgetExceeded) {
|
||||
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" });
|
||||
} catch (error) {
|
||||
if (signal.aborted && !trackedBudget?.exceeded) {
|
||||
queue.push({ type: "done", text: "stopped" });
|
||||
return;
|
||||
}
|
||||
const message = sanitizeError(error instanceof Error ? error.message : String(error));
|
||||
queue.fail(new Error(message));
|
||||
} finally {
|
||||
if (resumeHost) stopHostSteeringPoller(resumeHost);
|
||||
queue.close();
|
||||
if (trackedBudget) {
|
||||
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[] {
|
||||
const names = normalizeAgentToolNames(toolDefs);
|
||||
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 === "read_file") return `Reading ${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 === "attach_file") return `Attaching ${detail(record.path)}`;
|
||||
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, ""),
|
||||
};
|
||||
}
|
||||
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") {
|
||||
return {
|
||||
actions: Array.isArray(raw.actions) ? raw.actions : [],
|
||||
@ -759,7 +845,26 @@ function toAgentTool(tool: ConnectorTool, host: ToolHost, exposedName: string):
|
||||
if (tool.name === "list_files") {
|
||||
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 ?? "") };
|
||||
}
|
||||
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.activeToolControllers.set(executionId, toolController);
|
||||
|
||||
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);
|
||||
}
|
||||
ensureHostSteeringPoller(host);
|
||||
|
||||
try {
|
||||
result = await (async () => {
|
||||
@ -1033,9 +1104,11 @@ function toAgentTool(tool: ConnectorTool, host: ToolHost, exposedName: string):
|
||||
failure = error;
|
||||
throw error;
|
||||
} finally {
|
||||
if (steeringPoll) clearInterval(steeringPoll);
|
||||
host.signal.removeEventListener("abort", onHostAbort);
|
||||
host.activeToolControllers.delete(executionId);
|
||||
if (host.activeToolControllers.size === 0) {
|
||||
stopHostSteeringPoller(host);
|
||||
}
|
||||
endToolCall(host);
|
||||
const completion: AgentToolCompletion = {
|
||||
name: tool.name,
|
||||
@ -1301,7 +1374,7 @@ async function executeSubagent(
|
||||
budgetMessage && streamed.trim()
|
||||
? `${streamed.trim()}\n\n${budgetMessage}`
|
||||
: 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({
|
||||
type: "subagent",
|
||||
agentId,
|
||||
@ -1438,6 +1511,7 @@ export {
|
||||
modelsForRequest,
|
||||
parseContextOverflowDetails,
|
||||
prepareRequestSecretArguments,
|
||||
pruneCancelledToolResultsContext,
|
||||
pruneComputerScreenshotContext,
|
||||
pruneContextToWindow,
|
||||
reliableStreamOptions,
|
||||
|
||||
@ -82,7 +82,7 @@ export async function attachWorkspaceFileToThread(
|
||||
if (isSvg) {
|
||||
const svgBytes = content instanceof Uint8Array ? content : await collectBytes(stream);
|
||||
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);
|
||||
name = rawName.replace(/\.svg$/i, ".png");
|
||||
mimeType = "image/png";
|
||||
|
||||
@ -28,4 +28,26 @@ describe("advanceToolCallLoopGuard", () => {
|
||||
expect(stuck).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.
|
||||
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(
|
||||
streak: ToolCallStreak,
|
||||
streak: ToolLoopGuardState,
|
||||
name: string,
|
||||
args: unknown,
|
||||
): { streak: ToolCallStreak; stuck: boolean } {
|
||||
): { streak: ToolLoopGuardState; stuck: boolean } {
|
||||
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 {
|
||||
streak: nextState,
|
||||
stuck,
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ export const MAX_WEB_FETCH_URLS = 3;
|
||||
export const DEFAULT_WEB_FETCH_MAX_CHARS = 8_000;
|
||||
export const MAX_WEB_FETCH_MAX_CHARS = 50_000;
|
||||
export const MIN_WEB_FETCH_MAX_CHARS = 100;
|
||||
export const DEFAULT_WEB_FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function clampMaxResults(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
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 {
|
||||
normalizeSearchCategory,
|
||||
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(
|
||||
fetchProvider: WebFetchProvider,
|
||||
context: AdapterContext,
|
||||
@ -69,7 +79,7 @@ export async function webFetchFromTool(
|
||||
url: targetUrl,
|
||||
...(query ? { query } : {}),
|
||||
maxChars,
|
||||
signal: context.signal,
|
||||
signal: createWebFetchSignal(context.signal),
|
||||
},
|
||||
context,
|
||||
);
|
||||
@ -91,7 +101,7 @@ export async function webFetchFromTool(
|
||||
url: singleUrl,
|
||||
...(query ? { query } : {}),
|
||||
maxChars,
|
||||
signal: context.signal,
|
||||
signal: createWebFetchSignal(context.signal),
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
@ -973,13 +973,13 @@ export const ThreadSnapshotSchema = z.object({
|
||||
export type ThreadSnapshot = z.infer<typeof ThreadSnapshotSchema>;
|
||||
|
||||
/** 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. */
|
||||
export const MAX_MODEL_MAX_TOKENS = 131_072;
|
||||
|
||||
/** 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. */
|
||||
export const MAX_MODEL_CONTEXT_WINDOW = 1_048_576;
|
||||
|
||||
@ -509,6 +509,12 @@ export const appContract = {
|
||||
)
|
||||
.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(
|
||||
z.object({
|
||||
ok: z.literal(true),
|
||||
|
||||
@ -9,6 +9,7 @@ const APPROVAL_EXEMPT_TOOLS = new Set([
|
||||
"list_files",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"shell",
|
||||
"open_path",
|
||||
"launch_app",
|
||||
|
||||
@ -577,6 +577,22 @@ describe("trackToolCallStreak", () => {
|
||||
{ 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", () => {
|
||||
|
||||
@ -283,12 +283,25 @@ export function appendToolStep(steps: readonly ToolStep[], toolName: string): To
|
||||
|
||||
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(
|
||||
streak: ToolCallStreak,
|
||||
name: string,
|
||||
args: unknown,
|
||||
): 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 };
|
||||
}
|
||||
|
||||
|
||||
@ -93,3 +93,22 @@ export function matchVoiceByName(voices: VoiceInfo[], query: string): VoiceInfo
|
||||
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];
|
||||
}
|
||||
|
||||
@ -396,6 +396,7 @@ export function createRepos(prisma: PrismaClient) {
|
||||
instructions: string;
|
||||
notifyOnFinish: boolean;
|
||||
autoSpeak?: boolean;
|
||||
voiceId?: string | null;
|
||||
color?: string;
|
||||
parentBotId?: string | null;
|
||||
computerMode?: ComputerMode;
|
||||
@ -466,6 +467,18 @@ export function createRepos(prisma: PrismaClient) {
|
||||
where: { spaceId: actor.spaceId, userId: actor.userId },
|
||||
_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, {
|
||||
mode: "team",
|
||||
spaceId: actor.spaceId,
|
||||
@ -482,8 +495,9 @@ export function createRepos(prisma: PrismaClient) {
|
||||
instructions: input.instructions,
|
||||
notifyOnFinish: input.notifyOnFinish,
|
||||
autoSpeak: input.autoSpeak ?? true,
|
||||
voiceId: defaultVoiceId,
|
||||
color,
|
||||
position: (positions._max.position ?? -1) + 1,
|
||||
position: nextPosition,
|
||||
parentBotId: input.parentBotId ?? null,
|
||||
computerId: teamComputer.id,
|
||||
spawnKey: input.spawnKey,
|
||||
|
||||
@ -35,14 +35,15 @@ export const STUDIO_BOT_DEFAULTS = {
|
||||
name: "Studio",
|
||||
title: "Media producer",
|
||||
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: [
|
||||
"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:",
|
||||
"- 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.",
|
||||
"Work like a flagship creative director on logos, brand identity, and visual campaigns:",
|
||||
"- 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.",
|
||||
"- 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.",
|
||||
"",
|
||||
"Work like an editor on video and audio:",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user