Summarize project conversations and retry contended handoffs
Group threads, which hold every project's main conversation, were never compacted. Runs there saw only the last 200 raw messages, so the original brief and earlier decisions fell out of context. The lead bot now summarizes them like a bot thread; the summary stays local. A group handoff now retries when its transaction hits a deadlock, so a specialist that finished its work no longer fails at the hand-back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011VfFvSskgrByEU75QLyreE
This commit is contained in:
parent
aa989f50b3
commit
8f56426ea7
@ -13,6 +13,7 @@ import {
|
|||||||
lockOwnedGroup,
|
lockOwnedGroup,
|
||||||
type PrismaClient,
|
type PrismaClient,
|
||||||
touchGroupUpdatedAt,
|
touchGroupUpdatedAt,
|
||||||
|
withTransactionRetry,
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
import { getLogger } from "@rakazo/logging";
|
import { getLogger } from "@rakazo/logging";
|
||||||
import type { ExecutorDeps } from "./executor.js";
|
import type { ExecutorDeps } from "./executor.js";
|
||||||
@ -30,7 +31,10 @@ export async function handoffToGroupBot(
|
|||||||
input: { bot_id?: string; confirm_name?: string; message: string },
|
input: { bot_id?: string; confirm_name?: string; message: string },
|
||||||
) {
|
) {
|
||||||
const deliveryKey = `group-handoff:${run.id}`;
|
const deliveryKey = `group-handoff:${run.id}`;
|
||||||
const committed = await deps.prisma.$transaction(async (tx) => {
|
// 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 {
|
try {
|
||||||
await lockOwnedGroup(tx, run, groupId);
|
await lockOwnedGroup(tx, run, groupId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -120,7 +124,8 @@ export async function handoffToGroupBot(
|
|||||||
const sourceHandoff = sourceBlocks.find(
|
const sourceHandoff = sourceBlocks.find(
|
||||||
(block): block is Extract<MessageBlock, { kind: "handoff" }> => block.kind === "handoff",
|
(block): block is Extract<MessageBlock, { kind: "handoff" }> => block.kind === "handoff",
|
||||||
);
|
);
|
||||||
const leadInvolved = leadBotId !== null && (targetId === leadBotId || run.botId === leadBotId);
|
const leadInvolved =
|
||||||
|
leadBotId !== null && (targetId === leadBotId || run.botId === leadBotId);
|
||||||
if (sourceHandoff?.fromBotId === targetId && !leadInvolved) {
|
if (sourceHandoff?.fromBotId === targetId && !leadInvolved) {
|
||||||
return {
|
return {
|
||||||
error:
|
error:
|
||||||
@ -203,7 +208,8 @@ export async function handoffToGroupBot(
|
|||||||
eventSeq: event.seq,
|
eventSeq: event.seq,
|
||||||
toLead: targetId === leadBotId,
|
toLead: targetId === leadBotId,
|
||||||
} as const;
|
} as const;
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
if ("error" in committed) return committed;
|
if ("error" in committed) return committed;
|
||||||
await deps.events.notify(run.threadId, committed.eventSeq).catch((error) => {
|
await deps.events.notify(run.threadId, committed.eventSeq).catch((error) => {
|
||||||
getLogger().error("group handoff realtime notification", error);
|
getLogger().error("group handoff realtime notification", error);
|
||||||
|
|||||||
@ -241,6 +241,7 @@ function compactionHarness(
|
|||||||
defaultMemoryScope: string;
|
defaultMemoryScope: string;
|
||||||
} | null;
|
} | null;
|
||||||
timeZone?: string | null;
|
timeZone?: string | null;
|
||||||
|
groupLeadBotId?: string | null;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const messages: HarnessMessage[] =
|
const messages: HarnessMessage[] =
|
||||||
@ -252,7 +253,8 @@ function compactionHarness(
|
|||||||
}));
|
}));
|
||||||
const thread = {
|
const thread = {
|
||||||
id: "thread-1",
|
id: "thread-1",
|
||||||
botId: "bot-1",
|
botId: options.groupLeadBotId === undefined ? ("bot-1" as string | null) : null,
|
||||||
|
group: options.groupLeadBotId === undefined ? null : { leadBotId: options.groupLeadBotId },
|
||||||
spaceId: "workspace-1",
|
spaceId: "workspace-1",
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
nextEventSeq: 0,
|
nextEventSeq: 0,
|
||||||
@ -390,6 +392,33 @@ function compactionHarness(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("compactHistory", () => {
|
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 () => {
|
it("anchors the summarizer in the thread owner's time zone", async () => {
|
||||||
const harness = compactionHarness({
|
const harness = compactionHarness({
|
||||||
deploymentModelKey: "openrouter-key",
|
deploymentModelKey: "openrouter-key",
|
||||||
|
|||||||
@ -147,8 +147,14 @@ export interface CompactHistoryDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function compactHistory(deps: CompactHistoryDeps, threadId: string): Promise<void> {
|
export async function compactHistory(deps: CompactHistoryDeps, threadId: string): Promise<void> {
|
||||||
const thread = await deps.prisma.thread.findUniqueOrThrow({ where: { id: threadId } });
|
const thread = await deps.prisma.thread.findUniqueOrThrow({
|
||||||
if (!thread.botId) return;
|
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 previousCursor = thread.historyCompactedUpToSeq;
|
const previousCursor = thread.historyCompactedUpToSeq;
|
||||||
const previousGeneration = thread.historyCompactionGeneration;
|
const previousGeneration = thread.historyCompactionGeneration;
|
||||||
const previousSummary = thread.historyCompactionSummary?.trim() || null;
|
const previousSummary = thread.historyCompactionSummary?.trim() || null;
|
||||||
@ -267,7 +273,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
|||||||
? await deps.resolveModel({
|
? await deps.resolveModel({
|
||||||
userId: thread.userId,
|
userId: thread.userId,
|
||||||
spaceId: thread.spaceId,
|
spaceId: thread.spaceId,
|
||||||
botId: thread.botId,
|
botId: summarizerBotId,
|
||||||
})
|
})
|
||||||
: deps.deploymentModelKey
|
: deps.deploymentModelKey
|
||||||
? {
|
? {
|
||||||
@ -296,7 +302,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
|||||||
let runtimeReportedFailure = false;
|
let runtimeReportedFailure = false;
|
||||||
for await (const event of deps.runtime.run(
|
for await (const event of deps.runtime.run(
|
||||||
{
|
{
|
||||||
botId: thread.botId,
|
botId: summarizerBotId,
|
||||||
threadId,
|
threadId,
|
||||||
runId: `compact:${threadId}:${fromSeqExclusive}`,
|
runId: `compact:${threadId}:${fromSeqExclusive}`,
|
||||||
prompt,
|
prompt,
|
||||||
@ -353,6 +359,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
|||||||
// Local compaction is first-party behavior and must not depend on an optional external store.
|
// 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.
|
// Saving only after the compare-and-set also prevents losing workers from creating duplicates.
|
||||||
let semanticMemory: ConfiguredMemoryProvider | null = null;
|
let semanticMemory: ConfiguredMemoryProvider | null = null;
|
||||||
|
if (thread.botId) {
|
||||||
try {
|
try {
|
||||||
semanticMemory = await deps.memoryProviders.resolve(
|
semanticMemory = await deps.memoryProviders.resolve(
|
||||||
memoryProviderResolveRequest(thread.spaceId, thread.userId),
|
memoryProviderResolveRequest(thread.spaceId, thread.userId),
|
||||||
@ -360,13 +367,14 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
getLogger().error("Failed to load semantic memory provider for history compaction", error);
|
getLogger().error("Failed to load semantic memory provider for history compaction", error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let externalSaveAttempted = false;
|
let externalSaveAttempted = false;
|
||||||
const memoryContext = {
|
const memoryContext = {
|
||||||
operationId: `history-compact:${threadId}`,
|
operationId: `history-compact:${threadId}`,
|
||||||
traceId: `history-compact:${threadId}`,
|
traceId: `history-compact:${threadId}`,
|
||||||
spaceId: thread.spaceId,
|
spaceId: thread.spaceId,
|
||||||
userId: thread.userId,
|
userId: thread.userId,
|
||||||
botId: thread.botId,
|
botId: summarizerBotId,
|
||||||
signal: new AbortController().signal,
|
signal: new AbortController().signal,
|
||||||
};
|
};
|
||||||
if (semanticMemory) {
|
if (semanticMemory) {
|
||||||
@ -376,7 +384,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
|||||||
{
|
{
|
||||||
content: summary,
|
content: summary,
|
||||||
scope: "isolated",
|
scope: "isolated",
|
||||||
botId: thread.botId,
|
botId: summarizerBotId,
|
||||||
source: { kind: "history", generation: previousGeneration },
|
source: { kind: "history", generation: previousGeneration },
|
||||||
},
|
},
|
||||||
memoryContext,
|
memoryContext,
|
||||||
@ -407,7 +415,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const removed = await semanticMemory.provider.purgeHistory(
|
const removed = await semanticMemory.provider.purgeHistory(
|
||||||
{ botId: thread.botId, generations: [previousGeneration] },
|
{ botId: summarizerBotId, generations: [previousGeneration] },
|
||||||
memoryContext,
|
memoryContext,
|
||||||
);
|
);
|
||||||
if (!removed.ok) {
|
if (!removed.ok) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user