evOo-local/packages/adapters/src/group-handoff.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

228 lines
7.3 KiB
TypeScript

import { runContinueJob } from "@rakazo/adapter-kit";
import { MessageBlock } from "@rakazo/contracts";
import {
botMessageHopExhausted,
isSameStageHandoffBounce,
nextBotMessageHop,
renderGroupMembersContext,
} from "@rakazo/core";
import {
appendEventInTransaction,
createThreadMessageInTransaction,
IsolationError,
lockOwnedGroup,
type PrismaClient,
touchGroupUpdatedAt,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import type { ExecutorDeps } from "./executor.js";
export async function handoffToGroupBot(
deps: Pick<ExecutorDeps, "prisma" | "events" | "jobs">,
run: {
id: string;
spaceId: string;
threadId: string;
botId: string;
userId: string;
},
groupId: string,
input: { bot_id?: string; confirm_name?: string; message: string },
) {
const deliveryKey = `group-handoff:${run.id}`;
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" },
},
},
}),
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;
}
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 (!nextRun || !event) return { error: "recorded handoff is incomplete" } as const;
return { ok: true, botId: nextRun.botId, runId: nextRun.id, eventSeq: event.seq } 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",
);
if (sourceHandoff?.fromBotId === targetId) {
return {
error:
"do not hand this stage back to its sender; post the result in the shared thread instead",
} as const;
}
if (
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",
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 } 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);
});
await deps.jobs.enqueue(runContinueJob(committed.runId)).catch((error) => {
// The queued run is durable and the job reconciler will repair a missed immediate wake.
getLogger().error("group handoff enqueue", error);
});
return {
ok: true,
botId: committed.botId,
runId: committed.runId,
note: "Handoff recorded. End this turn without narrating it; the next bot owns the next stage. Only hand off when their specialty fits — never dump your-lane work upward to executives.",
};
}
export async function loadGroupContext(
prisma: PrismaClient,
groupId: string,
self: { id: string; name: string },
): Promise<string | undefined> {
const group = await prisma.chatGroup.findUnique({
where: { id: groupId },
include: {
members: {
where: { bot: { archivedAt: null } },
include: {
bot: { select: { id: true, name: true, title: true, description: true } },
},
orderBy: { createdAt: "asc" },
},
},
});
if (!group) return undefined;
return renderGroupMembersContext(
group.name,
group.members.map((member) => member.bot),
self,
);
}