Compare commits
No commits in common. "8f56426ea72fb178a58a2b2370430e95db1eea69" and "9a753e9adbf1ff509723a2516d0c6976e5e33d73" have entirely different histories.
8f56426ea7
...
9a753e9adb
@ -1,36 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { announcesUnfinishedWork } from "./announced-work.js";
|
||||
|
||||
describe("announcesUnfinishedWork", () => {
|
||||
it.each([
|
||||
"Nein, noch kein separates Repository. Lass mich das jetzt anlegen.",
|
||||
"Fertig: Vite+React, 6 Sektionen.\n\nNoch offen: Deployment. Ich arbeite gerade am Build und melde mich.",
|
||||
"Dependencies installiert. Jetzt bauen und testen:",
|
||||
"Ich lege jetzt das Repo an.",
|
||||
"Jetzt baue ich die Seite.",
|
||||
"The domain is free. I'll now deploy the page.",
|
||||
"Let me check the server logs first.",
|
||||
"I'm working on the build right now.",
|
||||
])("flags %j", (text) => {
|
||||
expect(announcesUnfinishedWork(text)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Die Landingpage ist live unter http://example.test:8082/ und funktioniert.",
|
||||
"Hier sind alle drei Logo-Varianten.\n\nLass mich wissen, welche dir gefällt.",
|
||||
"Done. Let me know if you want changes.",
|
||||
"Ich starte jetzt einen neuen Task beim Grafiker. Welchen Stil möchtest du?",
|
||||
"Sag mir Bescheid, wenn ich weitermachen soll.",
|
||||
"",
|
||||
])("accepts %j", (text) => {
|
||||
expect(announcesUnfinishedWork(text)).toBe(false);
|
||||
});
|
||||
|
||||
it("only looks at the closing paragraph", () => {
|
||||
expect(
|
||||
announcesUnfinishedWork(
|
||||
"Lass mich kurz zusammenfassen.\n\nDie Seite ist gebaut, gepusht und live.",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Local models often end a turn by announcing the next step ("Lass mich das jetzt anlegen",
|
||||
* "I'll now deploy it") without calling a tool. Nothing runs after a turn ends, so the user is
|
||||
* left waiting on work that never happens. This spots such endings so the runtime can push the
|
||||
* agent once more to either do the work or report honestly.
|
||||
*/
|
||||
|
||||
/** Phrases that look like announcements but hand the turn to the user. */
|
||||
const HANDS_BACK =
|
||||
/\b(?:lass(?:t)? (?:mich|uns) (?:es )?wissen|sag(?:t)? (?:mir )?bescheid|gib (?:mir )?bescheid|let me know|tell me)\b/gi;
|
||||
|
||||
const ANNOUNCES_WORK = [
|
||||
// German: "Lass mich …", "Ich lege jetzt …", "Jetzt baue ich …", "Ich arbeite gerade am Build".
|
||||
/\blass(?:t)? (?:mich|uns)\b/i,
|
||||
/\bich (?:werde|starte|mache|baue|lege|erstelle|prüfe|kümmere mich|schaue|deploye|beginne|fange|schreibe|richte|lade|teste)\b[^.!?\n]*\b(?:jetzt|gleich|nun|sofort|als nächstes|direkt)\b/i,
|
||||
/\b(?:jetzt|nun|gleich|als nächstes)\s+(?:lege|baue|erstelle|mache|starte|deploye|prüfe|schreibe|kümmere|richte|teste|lade)\s+ich\b/i,
|
||||
/\bich arbeite (?:gerade|jetzt|noch|daran)\b/i,
|
||||
/\bbin (?:gerade |jetzt )?dabei\b/i,
|
||||
// English.
|
||||
/\blet me\b/i,
|
||||
/\bi(?:'ll| will) (?:now|start|go ahead|get started|create|build|deploy|set up|write|fix|run)\b/i,
|
||||
/\bnow i(?:'ll| will)\b/i,
|
||||
/\bnext,? i(?:'ll| will)\b/i,
|
||||
/\bi(?:'m| am) (?:now |currently |still )?(?:working on|starting|building|creating|setting up|deploying)\b/i,
|
||||
];
|
||||
|
||||
/** Last paragraph of the reply, where a closing announcement sits. */
|
||||
function closingText(text: string): string {
|
||||
const paragraphs = text
|
||||
.trim()
|
||||
.split(/\n\s*\n/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
return (paragraphs.at(-1) ?? "").slice(-400);
|
||||
}
|
||||
|
||||
export function announcesUnfinishedWork(text: string): boolean {
|
||||
const closing = closingText(text);
|
||||
if (!closing) return false;
|
||||
// A question hands the turn to the user; waiting for the answer is right.
|
||||
if (/\?\s*\W*$/u.test(closing)) return false;
|
||||
// "Jetzt bauen und testen:" — the turn ends where a tool call should follow.
|
||||
if (/:\s*$/.test(closing)) return true;
|
||||
const withoutHandBack = closing.replace(HANDS_BACK, "");
|
||||
return ANNOUNCES_WORK.some((pattern) => pattern.test(withoutHandBack));
|
||||
}
|
||||
|
||||
export const MAX_ANNOUNCED_WORK_CONTINUATIONS = 2;
|
||||
|
||||
export const ANNOUNCED_WORK_CONTINUATION_PROMPT =
|
||||
"Your last message announced work you have not done, and nothing runs after your turn ends. Do that work now with your tools. If you cannot, tell the user plainly what is done, what is still open, and what you need from them.";
|
||||
@ -13,7 +13,6 @@ import {
|
||||
lockOwnedGroup,
|
||||
type PrismaClient,
|
||||
touchGroupUpdatedAt,
|
||||
withTransactionRetry,
|
||||
} from "@rakazo/db";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
import type { ExecutorDeps } from "./executor.js";
|
||||
@ -31,185 +30,180 @@ export async function handoffToGroupBot(
|
||||
input: { bot_id?: string; confirm_name?: string; message: string },
|
||||
) {
|
||||
const deliveryKey = `group-handoff:${run.id}`;
|
||||
// Runs finishing on the same thread contend for its event sequence; a deadlock here used to
|
||||
// fail a specialist whose work was already done.
|
||||
const committed = await withTransactionRetry(() =>
|
||||
deps.prisma.$transaction(async (tx) => {
|
||||
try {
|
||||
await lockOwnedGroup(tx, run, groupId);
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationError)
|
||||
return { error: "group is no longer available" } as const;
|
||||
throw error;
|
||||
}
|
||||
const [group, activeSource] = await Promise.all([
|
||||
tx.chatGroup.findFirst({
|
||||
where: { id: groupId, archivedAt: null, thread: { id: run.threadId } },
|
||||
include: {
|
||||
members: {
|
||||
where: { bot: { archivedAt: null } },
|
||||
include: { bot: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
tx.run.findFirst({
|
||||
where: {
|
||||
id: run.id,
|
||||
spaceId: run.spaceId,
|
||||
threadId: run.threadId,
|
||||
botId: run.botId,
|
||||
userId: run.userId,
|
||||
status: "running",
|
||||
},
|
||||
select: { id: true, sourceMessage: { select: { blocks: true } } },
|
||||
}),
|
||||
]);
|
||||
if (!group || !activeSource) return { error: "source run is no longer active" } as const;
|
||||
if (!group.members.some((member) => member.bot.id === run.botId)) {
|
||||
return { error: "source bot is no longer a group member" } as const;
|
||||
}
|
||||
// In a group with a lead, members hand results back to the lead and the lead delegates
|
||||
// the next stage, so the bounce rules below only apply between members.
|
||||
const leadBotId = group.members.some((member) => member.bot.id === group.leadBotId)
|
||||
? group.leadBotId
|
||||
: null;
|
||||
|
||||
const existing = await tx.message.findUnique({
|
||||
where: { threadId_clientNonce: { threadId: run.threadId, clientNonce: deliveryKey } },
|
||||
select: {
|
||||
sourceRuns: {
|
||||
const committed = await deps.prisma.$transaction(async (tx) => {
|
||||
try {
|
||||
await lockOwnedGroup(tx, run, groupId);
|
||||
} catch (error) {
|
||||
if (error instanceof IsolationError)
|
||||
return { error: "group is no longer available" } as const;
|
||||
throw error;
|
||||
}
|
||||
const [group, activeSource] = await Promise.all([
|
||||
tx.chatGroup.findFirst({
|
||||
where: { id: groupId, archivedAt: null, thread: { id: run.threadId } },
|
||||
include: {
|
||||
members: {
|
||||
where: { bot: { archivedAt: null } },
|
||||
include: { bot: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 1,
|
||||
select: { id: true, botId: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
tx.run.findFirst({
|
||||
where: {
|
||||
id: run.id,
|
||||
spaceId: run.spaceId,
|
||||
threadId: run.threadId,
|
||||
botId: run.botId,
|
||||
userId: run.userId,
|
||||
status: "running",
|
||||
},
|
||||
select: { id: true, sourceMessage: { select: { blocks: true } } },
|
||||
}),
|
||||
]);
|
||||
if (!group || !activeSource) return { error: "source run is no longer active" } as const;
|
||||
if (!group.members.some((member) => member.bot.id === run.botId)) {
|
||||
return { error: "source bot is no longer a group member" } as const;
|
||||
}
|
||||
// In a group with a lead, members hand results back to the lead and the lead delegates
|
||||
// the next stage, so the bounce rules below only apply between members.
|
||||
const leadBotId = group.members.some((member) => member.bot.id === group.leadBotId)
|
||||
? group.leadBotId
|
||||
: null;
|
||||
|
||||
const existing = await tx.message.findUnique({
|
||||
where: { threadId_clientNonce: { threadId: run.threadId, clientNonce: deliveryKey } },
|
||||
select: {
|
||||
sourceRuns: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 1,
|
||||
select: { id: true, botId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
const nextRun = existing.sourceRuns[0];
|
||||
const event = await tx.event.findFirst({
|
||||
where: { threadId: run.threadId, runId: run.id, type: "group.handoff" },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true },
|
||||
});
|
||||
if (existing) {
|
||||
const nextRun = existing.sourceRuns[0];
|
||||
const event = await tx.event.findFirst({
|
||||
where: { threadId: run.threadId, runId: run.id, type: "group.handoff" },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true },
|
||||
});
|
||||
if (!nextRun || !event) return { error: "recorded handoff is incomplete" } as const;
|
||||
return {
|
||||
ok: true,
|
||||
botId: nextRun.botId,
|
||||
runId: nextRun.id,
|
||||
eventSeq: event.seq,
|
||||
toLead: nextRun.botId === leadBotId,
|
||||
} as const;
|
||||
}
|
||||
if (!nextRun || !event) return { error: "recorded handoff is incomplete" } as const;
|
||||
return {
|
||||
ok: true,
|
||||
botId: nextRun.botId,
|
||||
runId: nextRun.id,
|
||||
eventSeq: event.seq,
|
||||
toLead: nextRun.botId === leadBotId,
|
||||
} as const;
|
||||
}
|
||||
|
||||
let targetId = input.bot_id?.trim();
|
||||
if (!targetId && input.confirm_name?.trim()) {
|
||||
const name = input.confirm_name.trim().toLowerCase();
|
||||
targetId = group.members.find((member) => member.bot.name.toLowerCase() === name)?.bot.id;
|
||||
}
|
||||
if (!targetId) return { error: "handoff target bot is required" } as const;
|
||||
if (targetId === run.botId) return { error: "cannot hand off to yourself" } as const;
|
||||
if (!group.members.some((member) => member.bot.id === targetId)) {
|
||||
return { error: "handoff target is not a group member" } as const;
|
||||
}
|
||||
let targetId = input.bot_id?.trim();
|
||||
if (!targetId && input.confirm_name?.trim()) {
|
||||
const name = input.confirm_name.trim().toLowerCase();
|
||||
targetId = group.members.find((member) => member.bot.name.toLowerCase() === name)?.bot.id;
|
||||
}
|
||||
if (!targetId) return { error: "handoff target bot is required" } as const;
|
||||
if (targetId === run.botId) return { error: "cannot hand off to yourself" } as const;
|
||||
if (!group.members.some((member) => member.bot.id === targetId)) {
|
||||
return { error: "handoff target is not a group member" } as const;
|
||||
}
|
||||
|
||||
let sourceBlocks: MessageBlock[] = [];
|
||||
if (activeSource.sourceMessage) {
|
||||
const parsedSource = MessageBlock.array().safeParse(activeSource.sourceMessage.blocks);
|
||||
if (!parsedSource.success) {
|
||||
return { error: "cannot verify the group handoff chain" } as const;
|
||||
}
|
||||
sourceBlocks = parsedSource.data;
|
||||
}
|
||||
const sourceHandoff = sourceBlocks.find(
|
||||
(block): block is Extract<MessageBlock, { kind: "handoff" }> => block.kind === "handoff",
|
||||
);
|
||||
const leadInvolved =
|
||||
leadBotId !== null && (targetId === leadBotId || run.botId === leadBotId);
|
||||
if (sourceHandoff?.fromBotId === targetId && !leadInvolved) {
|
||||
return {
|
||||
error:
|
||||
"do not hand this stage back to its sender; post the result in the shared thread instead",
|
||||
} as const;
|
||||
}
|
||||
if (
|
||||
run.botId !== leadBotId &&
|
||||
sourceHandoff?.toBotId === run.botId &&
|
||||
isSameStageHandoffBounce(sourceHandoff.text, input.message)
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
"you own this handed-off stage; workshop with the user and post your concept here instead of re-delegating the same task",
|
||||
} as const;
|
||||
}
|
||||
const hop = nextBotMessageHop(sourceHandoff?.hop);
|
||||
if (botMessageHopExhausted(hop)) {
|
||||
return {
|
||||
error:
|
||||
"group handoff limit reached for this chain; finish the current stage in the shared thread instead",
|
||||
} as const;
|
||||
let sourceBlocks: MessageBlock[] = [];
|
||||
if (activeSource.sourceMessage) {
|
||||
const parsedSource = MessageBlock.array().safeParse(activeSource.sourceMessage.blocks);
|
||||
if (!parsedSource.success) {
|
||||
return { error: "cannot verify the group handoff chain" } as const;
|
||||
}
|
||||
sourceBlocks = parsedSource.data;
|
||||
}
|
||||
const sourceHandoff = sourceBlocks.find(
|
||||
(block): block is Extract<MessageBlock, { kind: "handoff" }> => block.kind === "handoff",
|
||||
);
|
||||
const leadInvolved = leadBotId !== null && (targetId === leadBotId || run.botId === leadBotId);
|
||||
if (sourceHandoff?.fromBotId === targetId && !leadInvolved) {
|
||||
return {
|
||||
error:
|
||||
"do not hand this stage back to its sender; post the result in the shared thread instead",
|
||||
} as const;
|
||||
}
|
||||
if (
|
||||
run.botId !== leadBotId &&
|
||||
sourceHandoff?.toBotId === run.botId &&
|
||||
isSameStageHandoffBounce(sourceHandoff.text, input.message)
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
"you own this handed-off stage; workshop with the user and post your concept here instead of re-delegating the same task",
|
||||
} as const;
|
||||
}
|
||||
const hop = nextBotMessageHop(sourceHandoff?.hop);
|
||||
if (botMessageHopExhausted(hop)) {
|
||||
return {
|
||||
error:
|
||||
"group handoff limit reached for this chain; finish the current stage in the shared thread instead",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const handoffBlock: MessageBlock = {
|
||||
kind: "handoff",
|
||||
const handoffBlock: MessageBlock = {
|
||||
kind: "handoff",
|
||||
fromBotId: run.botId,
|
||||
toBotId: targetId,
|
||||
text: input.message,
|
||||
hop,
|
||||
};
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: run.threadId,
|
||||
role: "bot",
|
||||
blocks: [handoffBlock],
|
||||
botId: run.botId,
|
||||
runId: run.id,
|
||||
clientNonce: deliveryKey,
|
||||
});
|
||||
const task = await tx.task.create({
|
||||
data: {
|
||||
spaceId: run.spaceId,
|
||||
botId: targetId,
|
||||
threadId: run.threadId,
|
||||
userId: run.userId,
|
||||
prompt: input.message,
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
const nextRun = await tx.run.create({
|
||||
data: {
|
||||
spaceId: run.spaceId,
|
||||
botId: targetId,
|
||||
threadId: run.threadId,
|
||||
taskId: task.id,
|
||||
userId: run.userId,
|
||||
status: "queued",
|
||||
trigger: "follow_up",
|
||||
sourceMessageId: message.id,
|
||||
},
|
||||
});
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: run.spaceId,
|
||||
threadId: run.threadId,
|
||||
botId: run.botId,
|
||||
type: "group.handoff",
|
||||
runId: run.id,
|
||||
payload: {
|
||||
messageId: message.id,
|
||||
fromBotId: run.botId,
|
||||
toBotId: targetId,
|
||||
text: input.message,
|
||||
hop,
|
||||
};
|
||||
const message = await createThreadMessageInTransaction(tx, {
|
||||
threadId: run.threadId,
|
||||
role: "bot",
|
||||
blocks: [handoffBlock],
|
||||
botId: run.botId,
|
||||
runId: run.id,
|
||||
clientNonce: deliveryKey,
|
||||
});
|
||||
const task = await tx.task.create({
|
||||
data: {
|
||||
spaceId: run.spaceId,
|
||||
botId: targetId,
|
||||
threadId: run.threadId,
|
||||
userId: run.userId,
|
||||
prompt: input.message,
|
||||
status: "queued",
|
||||
},
|
||||
});
|
||||
const nextRun = await tx.run.create({
|
||||
data: {
|
||||
spaceId: run.spaceId,
|
||||
botId: targetId,
|
||||
threadId: run.threadId,
|
||||
taskId: task.id,
|
||||
userId: run.userId,
|
||||
status: "queued",
|
||||
trigger: "follow_up",
|
||||
sourceMessageId: message.id,
|
||||
},
|
||||
});
|
||||
const event = await appendEventInTransaction(tx, {
|
||||
spaceId: run.spaceId,
|
||||
threadId: run.threadId,
|
||||
botId: run.botId,
|
||||
type: "group.handoff",
|
||||
runId: run.id,
|
||||
payload: {
|
||||
messageId: message.id,
|
||||
fromBotId: run.botId,
|
||||
toBotId: targetId,
|
||||
text: input.message,
|
||||
},
|
||||
});
|
||||
await touchGroupUpdatedAt(tx, groupId);
|
||||
return {
|
||||
ok: true,
|
||||
botId: targetId,
|
||||
runId: nextRun.id,
|
||||
eventSeq: event.seq,
|
||||
toLead: targetId === leadBotId,
|
||||
} as const;
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
await touchGroupUpdatedAt(tx, groupId);
|
||||
return {
|
||||
ok: true,
|
||||
botId: targetId,
|
||||
runId: nextRun.id,
|
||||
eventSeq: event.seq,
|
||||
toLead: targetId === leadBotId,
|
||||
} as const;
|
||||
});
|
||||
if ("error" in committed) return committed;
|
||||
await deps.events.notify(run.threadId, committed.eventSeq).catch((error) => {
|
||||
getLogger().error("group handoff realtime notification", error);
|
||||
|
||||
@ -241,7 +241,6 @@ function compactionHarness(
|
||||
defaultMemoryScope: string;
|
||||
} | null;
|
||||
timeZone?: string | null;
|
||||
groupLeadBotId?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const messages: HarnessMessage[] =
|
||||
@ -253,8 +252,7 @@ function compactionHarness(
|
||||
}));
|
||||
const thread = {
|
||||
id: "thread-1",
|
||||
botId: options.groupLeadBotId === undefined ? ("bot-1" as string | null) : null,
|
||||
group: options.groupLeadBotId === undefined ? null : { leadBotId: options.groupLeadBotId },
|
||||
botId: "bot-1",
|
||||
spaceId: "workspace-1",
|
||||
userId: "user-1",
|
||||
nextEventSeq: 0,
|
||||
@ -392,33 +390,6 @@ function compactionHarness(
|
||||
}
|
||||
|
||||
describe("compactHistory", () => {
|
||||
it("summarizes a group thread with its lead and keeps the summary local", async () => {
|
||||
const harness = compactionHarness({
|
||||
deploymentModelKey: "openrouter-key",
|
||||
groupLeadBotId: "lead-bot",
|
||||
});
|
||||
|
||||
await compactHistory(harness.deps, "thread-1");
|
||||
|
||||
const [request] = harness.runtime.run.mock.calls[0]!;
|
||||
expect(request.botId).toBe("lead-bot");
|
||||
expect(harness.thread.historyCompactedUpToSeq).toBe(49);
|
||||
expect(harness.thread.historyCompactionSummary).toBe("Summary of 50 messages.");
|
||||
expect(harness.memoryProviders.resolve).not.toHaveBeenCalled();
|
||||
expect(harness.saveMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a group thread without a lead", async () => {
|
||||
const harness = compactionHarness({
|
||||
deploymentModelKey: "openrouter-key",
|
||||
groupLeadBotId: null,
|
||||
});
|
||||
|
||||
await compactHistory(harness.deps, "thread-1");
|
||||
|
||||
expect(harness.runtime.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("anchors the summarizer in the thread owner's time zone", async () => {
|
||||
const harness = compactionHarness({
|
||||
deploymentModelKey: "openrouter-key",
|
||||
|
||||
@ -147,14 +147,8 @@ export interface CompactHistoryDeps {
|
||||
}
|
||||
|
||||
export async function compactHistory(deps: CompactHistoryDeps, threadId: string): Promise<void> {
|
||||
const thread = await deps.prisma.thread.findUniqueOrThrow({
|
||||
where: { id: threadId },
|
||||
include: { group: { select: { leadBotId: true } } },
|
||||
});
|
||||
// A group thread (a project's main conversation included) is summarized by its lead. Its
|
||||
// summary stays local: the external memory store is keyed by one bot's own thread.
|
||||
const summarizerBotId = thread.botId ?? thread.group?.leadBotId;
|
||||
if (!summarizerBotId) return;
|
||||
const thread = await deps.prisma.thread.findUniqueOrThrow({ where: { id: threadId } });
|
||||
if (!thread.botId) return;
|
||||
const previousCursor = thread.historyCompactedUpToSeq;
|
||||
const previousGeneration = thread.historyCompactionGeneration;
|
||||
const previousSummary = thread.historyCompactionSummary?.trim() || null;
|
||||
@ -273,7 +267,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
||||
? await deps.resolveModel({
|
||||
userId: thread.userId,
|
||||
spaceId: thread.spaceId,
|
||||
botId: summarizerBotId,
|
||||
botId: thread.botId,
|
||||
})
|
||||
: deps.deploymentModelKey
|
||||
? {
|
||||
@ -302,7 +296,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
||||
let runtimeReportedFailure = false;
|
||||
for await (const event of deps.runtime.run(
|
||||
{
|
||||
botId: summarizerBotId,
|
||||
botId: thread.botId,
|
||||
threadId,
|
||||
runId: `compact:${threadId}:${fromSeqExclusive}`,
|
||||
prompt,
|
||||
@ -359,14 +353,12 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
||||
// Local compaction is first-party behavior and must not depend on an optional external store.
|
||||
// Saving only after the compare-and-set also prevents losing workers from creating duplicates.
|
||||
let semanticMemory: ConfiguredMemoryProvider | null = null;
|
||||
if (thread.botId) {
|
||||
try {
|
||||
semanticMemory = await deps.memoryProviders.resolve(
|
||||
memoryProviderResolveRequest(thread.spaceId, thread.userId),
|
||||
);
|
||||
} catch (error) {
|
||||
getLogger().error("Failed to load semantic memory provider for history compaction", error);
|
||||
}
|
||||
try {
|
||||
semanticMemory = await deps.memoryProviders.resolve(
|
||||
memoryProviderResolveRequest(thread.spaceId, thread.userId),
|
||||
);
|
||||
} catch (error) {
|
||||
getLogger().error("Failed to load semantic memory provider for history compaction", error);
|
||||
}
|
||||
let externalSaveAttempted = false;
|
||||
const memoryContext = {
|
||||
@ -374,7 +366,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
||||
traceId: `history-compact:${threadId}`,
|
||||
spaceId: thread.spaceId,
|
||||
userId: thread.userId,
|
||||
botId: summarizerBotId,
|
||||
botId: thread.botId,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
if (semanticMemory) {
|
||||
@ -384,7 +376,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
||||
{
|
||||
content: summary,
|
||||
scope: "isolated",
|
||||
botId: summarizerBotId,
|
||||
botId: thread.botId,
|
||||
source: { kind: "history", generation: previousGeneration },
|
||||
},
|
||||
memoryContext,
|
||||
@ -415,7 +407,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
||||
) {
|
||||
try {
|
||||
const removed = await semanticMemory.provider.purgeHistory(
|
||||
{ botId: summarizerBotId, generations: [previousGeneration] },
|
||||
{ botId: thread.botId, generations: [previousGeneration] },
|
||||
memoryContext,
|
||||
);
|
||||
if (!removed.ok) {
|
||||
|
||||
@ -7,7 +7,6 @@ const fakeAgentState = vi.hoisted(() => ({
|
||||
| "empty"
|
||||
| "two-boundaries"
|
||||
| "silent-continuation"
|
||||
| "announce"
|
||||
| "subagent-limit"
|
||||
| "parent-limit"
|
||||
| "parent-parallel"
|
||||
@ -135,35 +134,6 @@ vi.mock("@earendil-works/pi-agent-core", () => ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (fakeAgentState.mode === "announce") {
|
||||
const announce = (text: string) => {
|
||||
this.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "text_delta", delta: text },
|
||||
});
|
||||
this.emit({
|
||||
type: "message_end",
|
||||
message: { role: "assistant", content: [{ type: "text", text }] },
|
||||
});
|
||||
this.emit({
|
||||
type: "turn_end",
|
||||
message: { role: "assistant", content: [{ type: "text", text }] },
|
||||
toolResults: [],
|
||||
});
|
||||
};
|
||||
// Keeps announcing; the runtime nudges a bounded number of times, then lets it end.
|
||||
announce("Lass mich das Repo jetzt anlegen.");
|
||||
while (fakeAgentState.followUpMessages.length > fakeAgentState.promptInputs.length - 1) {
|
||||
fakeAgentState.promptInputs.push("follow-up");
|
||||
announce(
|
||||
fakeAgentState.emitFinalAfterFollowUp
|
||||
? "Das Repo ist angelegt."
|
||||
: "Ich lege das Repo jetzt an.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fakeAgentState.mode === "parent-limit") {
|
||||
const shell = this.tools.find((tool) => tool.name === "shell");
|
||||
if (!shell) throw new Error("shell was not exposed");
|
||||
@ -761,72 +731,6 @@ describe("Pi connector tool dispatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("pushes once more when a text-only turn announces work it did not do", async () => {
|
||||
fakeAgentState.mode = "announce";
|
||||
const runtime = new PiAgentRuntime();
|
||||
const events: unknown[] = [];
|
||||
for await (const event of runtime.run(
|
||||
{
|
||||
botId: "b",
|
||||
threadId: "t",
|
||||
runId: "announce",
|
||||
prompt: "leg ein Repo an",
|
||||
instructions: "Use your tools.",
|
||||
history: [],
|
||||
tools: [destinationTool],
|
||||
model: { provider: "test", id: "dispatch-test-model" },
|
||||
executeTool: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
{
|
||||
operationId: "announce",
|
||||
traceId: "announce",
|
||||
spaceId: "w",
|
||||
userId: "u",
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(fakeAgentState.followUpMessages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: expect.stringContaining("nothing runs after your turn ends"),
|
||||
}),
|
||||
]);
|
||||
expect(events).toContainEqual({ type: "text", text: "Das Repo ist angelegt." });
|
||||
});
|
||||
|
||||
it("stops nudging an agent that keeps announcing", async () => {
|
||||
fakeAgentState.mode = "announce";
|
||||
fakeAgentState.emitFinalAfterFollowUp = false;
|
||||
const runtime = new PiAgentRuntime();
|
||||
for await (const _event of runtime.run(
|
||||
{
|
||||
botId: "b",
|
||||
threadId: "t",
|
||||
runId: "announce-loop",
|
||||
prompt: "leg ein Repo an",
|
||||
instructions: "Use your tools.",
|
||||
history: [],
|
||||
tools: [destinationTool],
|
||||
model: { provider: "test", id: "dispatch-test-model" },
|
||||
executeTool: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
{
|
||||
operationId: "announce-loop",
|
||||
traceId: "announce-loop",
|
||||
spaceId: "w",
|
||||
userId: "u",
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
)) {
|
||||
// drain
|
||||
}
|
||||
|
||||
expect(fakeAgentState.followUpMessages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not wait for a slow tool completion hook", async () => {
|
||||
let resolveAudit!: () => void;
|
||||
const audit = new Promise<void>((resolve) => {
|
||||
|
||||
@ -12,11 +12,6 @@ import type {
|
||||
} from "@rakazo/adapter-kit";
|
||||
import { MODEL_TIERS, ModelTierSchema } from "@rakazo/contracts";
|
||||
import { getLogger } from "@rakazo/logging";
|
||||
import {
|
||||
ANNOUNCED_WORK_CONTINUATION_PROMPT,
|
||||
announcesUnfinishedWork,
|
||||
MAX_ANNOUNCED_WORK_CONTINUATIONS,
|
||||
} from "./announced-work.js";
|
||||
import { isToolPauseResult } from "./approval-effect.js";
|
||||
import { DELEGATION_TOOL_NAMES, SUBAGENT_MODEL_TIER_DESCRIPTION } from "./builtin-tools.js";
|
||||
import { CATALOG_EXECUTE, prepareCatalogExecuteArguments } from "./lazy-tool-catalog.js";
|
||||
@ -346,7 +341,6 @@ export class PiAgentRuntime implements AgentRuntime {
|
||||
let toolCalls = 0;
|
||||
let toolActivityShowing = false;
|
||||
let silentToolContinuations = 0;
|
||||
let announcedWorkContinuations = 0;
|
||||
let toolWorkPendingFinal = false;
|
||||
agent.subscribe(async (event) => {
|
||||
if (event.type === "message_end") {
|
||||
@ -411,22 +405,6 @@ export class PiAgentRuntime implements AgentRuntime {
|
||||
});
|
||||
}
|
||||
}
|
||||
// A text-only turn that announces the next step ends the run with nothing done.
|
||||
if (
|
||||
!hasToolCalls &&
|
||||
!hasToolResults &&
|
||||
!host.pausePending &&
|
||||
tools.length > 0 &&
|
||||
announcedWorkContinuations < MAX_ANNOUNCED_WORK_CONTINUATIONS &&
|
||||
announcesUnfinishedWork(messageText)
|
||||
) {
|
||||
announcedWorkContinuations += 1;
|
||||
agent.followUp({
|
||||
role: "user",
|
||||
content: ANNOUNCED_WORK_CONTINUATION_PROMPT,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event.type === "message_end" && event.message.role === "assistant") {
|
||||
const text = assistantText(event.message);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user