Runs dropped the bot's name, title and description whenever custom instructions existed, always stated the time in UTC, and knew the user only through free-form durable memory. Groups with a lead, like the main conversation, still got the peer-group lines that forbid handing work back. And every group follow-up run failed during setup: the inbound handoff lookup selected a `bot` relation that Message does not have, which Prisma rejects. - Instructions now open with the bot's identity, then its own instructions, a main-agent role paragraph for the main bot, the current time in the user's time zone with its offset (UTC when unknown), and a `<user>` block built from loadUserContext: profile fields and the active facts visible in the run's space, escaped, clipped and framed as data rather than instructions. Runs that reply in a shared messaging channel get only the name, zone, locale, language and style, matching how those runs already leave out durable memory. - run_subagent helpers receive the identity, time and user block through a new optional AgentRunRequest.subagentContext instead of nothing. - Groups with an active lead get lead and specialist lines and matching inbound handoff instructions. Members may hand a stage back to the lead and the lead may delegate the next stage; the bounce rules still apply between members. Ordinary groups keep today's lines. - The teammate directory is rendered only when the run has message_bot, which group runs never have, and tool guidance names a tool only when the run has it. - The auto-review judge, history compaction and the team chat judge read the time in the user's zone, and schedule_create defaults to it. - The inbound handoff sender is read from the handoff block and looked up among the user's bots; a Postgres test covers the follow-up run, the prompt it receives, and handing the result back to the lead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
251 lines
8.4 KiB
TypeScript
251 lines
8.4 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;
|
|
}
|
|
// 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 (!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 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",
|
|
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;
|
|
});
|
|
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: committed.toLead
|
|
? "Handed back to the lead. End this turn without narrating it; the lead decides the next step."
|
|
: "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.",
|
|
};
|
|
}
|
|
|
|
/** The group roster instructions for `self`, plus the group's lead when it is an active member. */
|
|
export async function loadGroupContext(
|
|
prisma: PrismaClient,
|
|
groupId: string,
|
|
self: { id: string; name: string },
|
|
): Promise<{ instructions: string; lead?: { id: string; name: 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;
|
|
const members = group.members.map((member) => member.bot);
|
|
const lead = members.find((member) => member.id === group.leadBotId);
|
|
return {
|
|
instructions: renderGroupMembersContext(group.name, members, self, group.leadBotId),
|
|
...(lead ? { lead: { id: lead.id, name: lead.name } } : {}),
|
|
};
|
|
}
|