feat: recruit library agents into project conversations and scan project folders (#206)
* media: check ffmpeg filter support before subtitle operations * sandbox: handle synchronous spawn errors safely in desktop sandbox * Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency. What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens. Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally. * Update translation catalogs Re-extracted catalogs for the new and moved strings; the catalog sync test passes. * Add project plan and test readiness notes Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes. * fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps * fix(ci): restore space deletion guard, browser toggle, and run-steps contracts * fix(ci): restore model resolution errors, group stage, and onboarding flow * fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs * fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding * test(web): wait for avatar and rig visibility in oo-avatar motion spec * test(web): wait for persisted message IDs in message-hover-actions pagination spec * feat: recruit library agents into project conversations and scan project folders * test(web): expect repo description in GithubRepoPicker onChange * Onboarding: always drop the local voice when the voice step is skipped Skipping waited only for the latest voice load and disconnected only when the client saw the connect succeed. An earlier or client-failed connect could still persist the local credential, so a user who skipped voice was left with one. Wait for every load and always disconnect. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs * Shell: hide the optimistic copy once the transcript shows the send A message sent while the bot works streamed into the transcript before its send request resolved, so the same text showed twice until the optimistic bubble was removed. Drop an optimistic message as soon as a persisted user message with the same text appears. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ca869561ac
commit
0b3ce11df8
@ -122,6 +122,7 @@ import {
|
|||||||
isValidAvatarValue,
|
isValidAvatarValue,
|
||||||
nextCronDateAcrossStrict,
|
nextCronDateAcrossStrict,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
|
import { readProjectSummary } from "@rakazo/core/node/project-summary";
|
||||||
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||||
import {
|
import {
|
||||||
BotSectionNameConflictError,
|
BotSectionNameConflictError,
|
||||||
@ -170,6 +171,7 @@ import {
|
|||||||
savePushSubscription,
|
savePushSubscription,
|
||||||
selectSpaceModelPreference,
|
selectSpaceModelPreference,
|
||||||
selectSpaceVoicePreference,
|
selectSpaceVoicePreference,
|
||||||
|
syncProjectMembers,
|
||||||
updateNotificationSettings,
|
updateNotificationSettings,
|
||||||
updateSpaceForOwner,
|
updateSpaceForOwner,
|
||||||
wireProjectWorkspace,
|
wireProjectWorkspace,
|
||||||
@ -777,14 +779,17 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: null);
|
: null);
|
||||||
if (input.goal?.trim()) {
|
// Without a stated goal, the linked folder's README or manifest says what this is.
|
||||||
|
const goal =
|
||||||
|
input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null);
|
||||||
|
if (goal) {
|
||||||
await deps.prisma.memoryDocument.create({
|
await deps.prisma.memoryDocument.create({
|
||||||
data: {
|
data: {
|
||||||
spaceId: space.id,
|
spaceId: space.id,
|
||||||
userId: context.actor.userId,
|
userId: context.actor.userId,
|
||||||
scope: "user",
|
scope: "user",
|
||||||
path: "PROJECT_GOAL.md",
|
path: "PROJECT_GOAL.md",
|
||||||
content: input.goal.trim(),
|
content: goal,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -805,7 +810,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
canDelete: true,
|
canDelete: true,
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
workspace,
|
workspace,
|
||||||
goal: input.goal?.trim() ?? null,
|
goal,
|
||||||
bots: bots.map(spaceBotDto),
|
bots: bots.map(spaceBotDto),
|
||||||
groups: groups.map(spaceGroupDto),
|
groups: groups.map(spaceGroupDto),
|
||||||
externalConversations: [],
|
externalConversations: [],
|
||||||
@ -1644,6 +1649,10 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
input.name,
|
input.name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await syncProjectMembers(deps.prisma, {
|
||||||
|
userId: context.actor.userId,
|
||||||
|
libraryBotId: existing.id,
|
||||||
|
});
|
||||||
const bots = await repos.listBots(context.actor);
|
const bots = await repos.listBots(context.actor);
|
||||||
const bot = bots.find((b) => b.id === input.botId);
|
const bot = bots.find((b) => b.id === input.botId);
|
||||||
if (!bot) throw new IsolationError();
|
if (!bot) throw new IsolationError();
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import {
|
|||||||
rpc,
|
rpc,
|
||||||
selectedSpaceId,
|
selectedSpaceId,
|
||||||
selectInitialSpace,
|
selectInitialSpace,
|
||||||
|
selectLibrarySpace,
|
||||||
selectSpace,
|
selectSpace,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { mobileShellIconSizes, mobileTokens, resolveMobileAppearance } from "../lib/appearance";
|
import { mobileShellIconSizes, mobileTokens, resolveMobileAppearance } from "../lib/appearance";
|
||||||
@ -411,6 +412,7 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
creatingBotRef.current = true;
|
creatingBotRef.current = true;
|
||||||
try {
|
try {
|
||||||
|
await selectLibrarySpace();
|
||||||
// Authoritative roster so a slow home fetch does not treat later bots as first.
|
// Authoritative roster so a slow home fetch does not treat later bots as first.
|
||||||
// A failed list is unknown — use the delayed path rather than assuming first.
|
// A failed list is unknown — use the delayed path rather than assuming first.
|
||||||
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
|
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { Stack, useRouter } from "expo-router";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Pressable, ScrollView, Text, TextInput } from "react-native";
|
import { Pressable, ScrollView, Text, TextInput } from "react-native";
|
||||||
import { ComputerModePicker } from "../components/computer-mode-picker";
|
import { ComputerModePicker } from "../components/computer-mode-picker";
|
||||||
import { type MobileBot, rpc } from "../lib/api";
|
import { type MobileBot, rpc, selectLibrarySpace } from "../lib/api";
|
||||||
import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt";
|
import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt";
|
||||||
import { useI18n } from "../lib/i18n";
|
import { useI18n } from "../lib/i18n";
|
||||||
import { useMobileTokens } from "../lib/native";
|
import { useMobileTokens } from "../lib/native";
|
||||||
@ -42,6 +42,7 @@ export default function NewBot() {
|
|||||||
setPending(true);
|
setPending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
await selectLibrarySpace();
|
||||||
// Failed list is unknown — delay focus rather than treating the bot as first.
|
// Failed list is unknown — delay focus rather than treating the bot as first.
|
||||||
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
|
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
|
||||||
const isFirstBot = existing !== null && existing.length === 0;
|
const isFirstBot = existing !== null && existing.length === 0;
|
||||||
|
|||||||
@ -144,6 +144,16 @@ export function selectedSpaceId(): string | null {
|
|||||||
return cachedSpaceId || null;
|
return cachedSpaceId || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agents live in the home library, where a project's coordinator recruits them from; select it
|
||||||
|
* before creating one so the new agent and its chat share the request scope.
|
||||||
|
*/
|
||||||
|
export async function selectLibrarySpace(): Promise<void> {
|
||||||
|
const navigation = await rpc<MobileSpaceNavigation>("spaces/list");
|
||||||
|
const home = navigation.spaces.find((space) => space.isDefault)?.id;
|
||||||
|
if (home && home !== cachedSpaceId) await selectSpace(home);
|
||||||
|
}
|
||||||
|
|
||||||
/** Keep requests usable after the server deleted the selected Space but native
|
/** Keep requests usable after the server deleted the selected Space but native
|
||||||
* storage could not replace it. Neutralize any same-endpoint rollback before
|
* storage could not replace it. Neutralize any same-endpoint rollback before
|
||||||
* writing the replacement selection so a cleanup failure cannot leave the new
|
* writing the replacement selection so a cleanup failure cannot leave the new
|
||||||
|
|||||||
@ -45,12 +45,20 @@ test("a new project opens straight into the conversation with its own main agent
|
|||||||
await captureScreenshot(page, testInfo, "project-main-conversation");
|
await captureScreenshot(page, testInfo, "project-main-conversation");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a project's main agent hands work to the project's specialist", async ({
|
test("a project's main agent recruits a library agent into the project conversation", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `project-delegate-${stamp}@rakazo.test`, "password12", "Project Delegate");
|
await signup(page, `project-delegate-${stamp}@rakazo.test`, "password12", "Project Delegate");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
// The agent is created once, in the home library.
|
||||||
|
const designer = await rpc<{ id: string }>(page, "bots/create", {
|
||||||
|
name: "Designer",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
instructions: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
});
|
||||||
|
|
||||||
const projekte = page.locator(".sidebar-projekte");
|
const projekte = page.locator(".sidebar-projekte");
|
||||||
await projekte.getByTestId("create-project-trigger").click();
|
await projekte.getByTestId("create-project-trigger").click();
|
||||||
@ -61,13 +69,6 @@ test("a project's main agent hands work to the project's specialist", async ({
|
|||||||
await waitForChatComposer(page);
|
await waitForChatComposer(page);
|
||||||
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
|
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
|
||||||
|
|
||||||
const designer = await rpc<{ id: string }>(page, "bots/create", {
|
|
||||||
name: "Designer",
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
instructions: "",
|
|
||||||
notifyOnFinish: true,
|
|
||||||
});
|
|
||||||
const composer = page.getByRole("combobox", { name: "Message" });
|
const composer = page.getByRole("combobox", { name: "Message" });
|
||||||
await composer.fill("delegate to the bot named Designer: sketch a landing page");
|
await composer.fill("delegate to the bot named Designer: sketch a landing page");
|
||||||
await composer.press("Enter");
|
await composer.press("Enter");
|
||||||
@ -80,9 +81,14 @@ test("a project's main agent hands work to the project's specialist", async ({
|
|||||||
await expect(
|
await expect(
|
||||||
transcript.getByText("closing the task: sketch a landing page.").first(),
|
transcript.getByText("closing the task: sketch a landing page.").first(),
|
||||||
).toBeVisible({ timeout: 60_000 });
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
// The project's own Designer joined the conversation; the library agent stayed home.
|
||||||
|
const projectBots = await rpc<Array<{ id: string; name: string }>>(page, "bots/list", {});
|
||||||
|
const member = projectBots.find((bot) => bot.name === "Designer");
|
||||||
|
expect(member?.id).toBeTruthy();
|
||||||
|
expect(member?.id).not.toBe(designer.id);
|
||||||
const snapshot = await rpc<{ members?: Array<{ botId: string }> }>(page, "threads/get", {
|
const snapshot = await rpc<{ members?: Array<{ botId: string }> }>(page, "threads/get", {
|
||||||
groupId,
|
groupId,
|
||||||
});
|
});
|
||||||
expect(snapshot.members?.map((member) => member.botId)).toContain(designer.id);
|
expect(snapshot.members?.map((entry) => entry.botId)).toContain(member?.id);
|
||||||
await captureScreenshot(page, testInfo, "project-main-agent-delegates");
|
await captureScreenshot(page, testInfo, "project-main-agent-delegates");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -110,6 +110,7 @@ describe("GithubRepoPicker", () => {
|
|||||||
expect(onChange).toHaveBeenCalledWith("https://github.com/octocat/Spoon-Knife", {
|
expect(onChange).toHaveBeenCalledWith("https://github.com/octocat/Spoon-Knife", {
|
||||||
name: "Spoon-Knife",
|
name: "Spoon-Knife",
|
||||||
fullName: "octocat/Spoon-Knife",
|
fullName: "octocat/Spoon-Knife",
|
||||||
|
description: "This repo is for spoon knife",
|
||||||
});
|
});
|
||||||
expect(onSuggestProjectName).toHaveBeenCalledWith("Spoon-Knife");
|
expect(onSuggestProjectName).toHaveBeenCalledWith("Spoon-Knife");
|
||||||
expect(onSuggestWorkspacePath).toHaveBeenCalledWith("~/Projects/Spoon-Knife");
|
expect(onSuggestWorkspacePath).toHaveBeenCalledWith("~/Projects/Spoon-Knife");
|
||||||
|
|||||||
@ -15,7 +15,10 @@ export interface GithubRepoItem {
|
|||||||
|
|
||||||
export interface GithubRepoPickerProps {
|
export interface GithubRepoPickerProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (url: string, repo?: { name: string; fullName: string }) => void;
|
onChange: (
|
||||||
|
url: string,
|
||||||
|
repo?: { name: string; fullName: string; description?: string | null },
|
||||||
|
) => void;
|
||||||
onSuggestProjectName?: (name: string) => void;
|
onSuggestProjectName?: (name: string) => void;
|
||||||
onSuggestWorkspacePath?: (path: string) => void;
|
onSuggestWorkspacePath?: (path: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@ -81,7 +84,11 @@ export function GithubRepoPicker({
|
|||||||
}, [repos, searchQuery]);
|
}, [repos, searchQuery]);
|
||||||
|
|
||||||
const handleSelectRepo = (repo: GithubRepoItem) => {
|
const handleSelectRepo = (repo: GithubRepoItem) => {
|
||||||
onChange(repo.url, { name: repo.name, fullName: repo.fullName });
|
onChange(repo.url, {
|
||||||
|
name: repo.name,
|
||||||
|
fullName: repo.fullName,
|
||||||
|
description: repo.description,
|
||||||
|
});
|
||||||
if (onSuggestProjectName) {
|
if (onSuggestProjectName) {
|
||||||
onSuggestProjectName(repo.name);
|
onSuggestProjectName(repo.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2705,11 +2705,24 @@ export function ShellPage() {
|
|||||||
computerMode: ComputerMode;
|
computerMode: ComputerMode;
|
||||||
}) {
|
}) {
|
||||||
const isFirstBot = botsRef.current.length === 0;
|
const isFirstBot = botsRef.current.length === 0;
|
||||||
const bot = await rpc.bots.create({
|
// Agents live in the home library; a project's coordinator recruits them from there.
|
||||||
...normalizeCreateBotProfile(input),
|
const bot = await rpc.bots.create(
|
||||||
notifyOnFinish: true,
|
{
|
||||||
computerMode: input.computerMode,
|
...normalizeCreateBotProfile(input),
|
||||||
});
|
notifyOnFinish: true,
|
||||||
|
computerMode: input.computerMode,
|
||||||
|
},
|
||||||
|
{ context: { spaceId: homeSpaceId } },
|
||||||
|
);
|
||||||
|
if (bot.spaceId !== (selectedSpaceId() ?? bootstrapMe?.spaceId)) {
|
||||||
|
// A project was open; follow the new agent into the library.
|
||||||
|
setPanel(null);
|
||||||
|
if (selectSpace(bot.spaceId)) {
|
||||||
|
await rpc.onboarding.start({ botId: bot.id }).catch(() => undefined);
|
||||||
|
window.location.assign(`/app/${bot.id}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBots((current) =>
|
setBots((current) =>
|
||||||
current.some((item) => item.id === bot.id) ? current : [bot, ...current],
|
current.some((item) => item.id === bot.id) ? current : [bot, ...current],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -259,6 +259,10 @@ export function NewSpaceDialog({
|
|||||||
if (repo?.name && !name.trim()) {
|
if (repo?.name && !name.trim()) {
|
||||||
setName(repo.name);
|
setName(repo.name);
|
||||||
}
|
}
|
||||||
|
// The repo's own description tells the coordinator what this project is.
|
||||||
|
if (repo?.description?.trim() && !goal.trim()) {
|
||||||
|
setGoal(repo.description.trim());
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onSuggestProjectName={(suggestedName) => {
|
onSuggestProjectName={(suggestedName) => {
|
||||||
if (!name.trim()) setName(suggestedName);
|
if (!name.trim()) setName(suggestedName);
|
||||||
|
|||||||
@ -21,7 +21,13 @@ The product is organized as **Unterhaltungen** (see `VOICE.md`). **Team** lists
|
|||||||
|------|-----|
|
|------|-----|
|
||||||
| 1:1 Unterhaltung | One Oo |
|
| 1:1 Unterhaltung | One Oo |
|
||||||
| Gruppenunterhaltung | Multiple Oos (+ humans later) — **fun factor essential** (see `docs/GROUP-CONVERSATIONS.md`) |
|
| Gruppenunterhaltung | Multiple Oos (+ humans later) — **fun factor essential** (see `docs/GROUP-CONVERSATIONS.md`) |
|
||||||
| Projekte | Group conversations in **Ordner** (`BotSection` folders) |
|
| Projekte | One group conversation per project, led by its **Projektkoordinator** |
|
||||||
|
|
||||||
|
### Projects recruit from the library
|
||||||
|
|
||||||
|
- Oos are created once, in the home space: that is the **library**. Each has one job.
|
||||||
|
- A project is a space with a fixed coordinator (`Bot.kind="main"`) and one main group conversation. A linked folder's README or manifest, or the GitHub repo's description, tells the coordinator what the project is (`PROJECT_GOAL.md`) when the user gave no goal.
|
||||||
|
- The coordinator recruits the Oos a request needs with `delegate_task`, briefing them on the task card. A recruited Oo gets a project member (`spawnKey` `recruit:<libraryBotId>`, `packages/db/src/project-recruits.ts`) that joins the conversation. The member keeps the Oo's persona and picks up later edits to it. It works with the project's files, memory, computer and connections. The Oo's home memory stays home.
|
||||||
|
|
||||||
## Deepest personalization
|
## Deepest personalization
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import {
|
|||||||
createThreadMessageInTransaction,
|
createThreadMessageInTransaction,
|
||||||
ensureMainConversation,
|
ensureMainConversation,
|
||||||
joinMainConversation,
|
joinMainConversation,
|
||||||
|
listDelegationCandidates,
|
||||||
|
recruitIntoProject,
|
||||||
touchGroupUpdatedAt,
|
touchGroupUpdatedAt,
|
||||||
withTransactionRetry,
|
withTransactionRetry,
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
@ -54,9 +56,10 @@ async function notifyThreads(deps: DelegationDeps, seqs: ThreadSeq[]) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hands work from a main bot to a specialist as a tracked task. The card is posted in the main
|
* Hands work from a main bot to a specialist as a tracked task. The card is posted in the main
|
||||||
* conversation of the main bot's space; a specialist of that space joins it and works there, one
|
* conversation of the main bot's space; a specialist of that space joins it and works there. A
|
||||||
* of another space works in its own chat. Replays of the same tool call return the task created
|
* project recruits a library agent from the home space: its project member joins and works there.
|
||||||
* first.
|
* A specialist of another project works in its own chat. Replays of the same tool call return the
|
||||||
|
* task created first.
|
||||||
*/
|
*/
|
||||||
export async function delegateTask(
|
export async function delegateTask(
|
||||||
deps: DelegationDeps,
|
deps: DelegationDeps,
|
||||||
@ -114,32 +117,32 @@ export async function delegateTask(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = await deps.prisma.bot.findMany({
|
|
||||||
where: {
|
|
||||||
userId: run.userId,
|
|
||||||
// Main bots lead their own space and are never delegated to.
|
|
||||||
kind: "specialist",
|
|
||||||
archivedAt: null,
|
|
||||||
thread: { isNot: null },
|
|
||||||
space: { memberships: { some: { userId: run.userId } } },
|
|
||||||
},
|
|
||||||
select: { id: true, name: true, spaceId: true, thread: { select: { id: true } } },
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
|
||||||
// A name shared across spaces resolves to the specialist next to the main conversation.
|
// A name shared across spaces resolves to the specialist next to the main conversation.
|
||||||
const ordered = [
|
const ordered = await listDelegationCandidates(deps.prisma, {
|
||||||
...candidates.filter((bot) => bot.spaceId === conversation.spaceId),
|
userId: run.userId,
|
||||||
...candidates.filter((bot) => bot.spaceId !== conversation.spaceId),
|
spaceId: conversation.spaceId,
|
||||||
];
|
});
|
||||||
const target =
|
const resolved =
|
||||||
ordered.find((bot) => bot.id === requested) ?? resolveBotAddress(ordered, { name: requested });
|
ordered.find((bot) => bot.id === requested) ?? resolveBotAddress(ordered, { name: requested });
|
||||||
if (!target?.thread) {
|
if (!resolved?.thread) {
|
||||||
if (requested === sender.id || requested.toLowerCase() === sender.name.toLowerCase()) {
|
if (requested === sender.id || requested.toLowerCase() === sender.name.toLowerCase()) {
|
||||||
return { error: "you cannot delegate to yourself" };
|
return { error: "you cannot delegate to yourself" };
|
||||||
}
|
}
|
||||||
return { error: "no specialist found with that name or id" };
|
return { error: "no specialist found with that name or id" };
|
||||||
}
|
}
|
||||||
const targetThreadId = target.thread.id;
|
// A project recruits a library agent: its project member joins and works in this conversation.
|
||||||
|
const recruited =
|
||||||
|
resolved.spaceId !== conversation.spaceId && resolved.space.isDefault
|
||||||
|
? await recruitIntoProject(deps.prisma, {
|
||||||
|
userId: run.userId,
|
||||||
|
spaceId: conversation.spaceId,
|
||||||
|
libraryBotId: resolved.id,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const target = recruited
|
||||||
|
? { id: recruited.id, name: recruited.name, spaceId: recruited.spaceId }
|
||||||
|
: resolved;
|
||||||
|
const targetThreadId = recruited ? recruited.threadId : resolved.thread.id;
|
||||||
const inConversation = target.spaceId === conversation.spaceId;
|
const inConversation = target.spaceId === conversation.spaceId;
|
||||||
if (inConversation) {
|
if (inConversation) {
|
||||||
await joinMainConversation(deps.prisma, {
|
await joinMainConversation(deps.prisma, {
|
||||||
|
|||||||
@ -86,6 +86,7 @@ import {
|
|||||||
findSpaceModelTier,
|
findSpaceModelTier,
|
||||||
InvalidSpaceNameError,
|
InvalidSpaceNameError,
|
||||||
isTooManyDatabaseConnections,
|
isTooManyDatabaseConnections,
|
||||||
|
listDelegationCandidates,
|
||||||
listSpaceUserBotIds,
|
listSpaceUserBotIds,
|
||||||
loadCoachSummary,
|
loadCoachSummary,
|
||||||
loadRunHistoryMessages,
|
loadRunHistoryMessages,
|
||||||
@ -4040,39 +4041,20 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
const botDirectory = exposedToolNames.has("delegate_task")
|
const botDirectory = exposedToolNames.has("delegate_task")
|
||||||
? renderSpecialistDirectory(
|
? renderSpecialistDirectory(
|
||||||
(
|
(
|
||||||
await deps.prisma.bot.findMany({
|
await listDelegationCandidates(deps.prisma, {
|
||||||
where: {
|
userId: run.userId,
|
||||||
userId: run.userId,
|
spaceId: run.spaceId,
|
||||||
// Main bots lead their own space and are never delegated to.
|
|
||||||
kind: "specialist",
|
|
||||||
archivedAt: null,
|
|
||||||
thread: { isNot: null },
|
|
||||||
space: { memberships: { some: { userId: run.userId } } },
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
title: true,
|
|
||||||
description: true,
|
|
||||||
spaceId: true,
|
|
||||||
space: { select: { name: true } },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
take: BOT_DIRECTORY_LIMIT * 2,
|
take: BOT_DIRECTORY_LIMIT * 2,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
// Specialists next to this conversation first, as delegate_task resolves names.
|
|
||||||
.sort(
|
|
||||||
(left, right) =>
|
|
||||||
Number(right.spaceId === run.spaceId) - Number(left.spaceId === run.spaceId),
|
|
||||||
)
|
|
||||||
.slice(0, BOT_DIRECTORY_LIMIT)
|
.slice(0, BOT_DIRECTORY_LIMIT)
|
||||||
.map((peer) => ({
|
.map((peer) => ({
|
||||||
id: peer.id,
|
id: peer.id,
|
||||||
name: peer.name,
|
name: peer.name,
|
||||||
title: peer.title,
|
title: peer.title,
|
||||||
description: peer.description,
|
description: peer.description,
|
||||||
...(peer.spaceId === run.spaceId ? {} : { spaceName: peer.space.name }),
|
// Library agents join this conversation; only other projects' carry a label.
|
||||||
|
...(peer.joinsHere ? {} : { spaceName: peer.space.name }),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
handoff: exposedToolNames.has("handoff_to_bot"),
|
handoff: exposedToolNames.has("handoff_to_bot"),
|
||||||
|
|||||||
@ -29,6 +29,11 @@
|
|||||||
"development": "./src/git-checkout.ts",
|
"development": "./src/git-checkout.ts",
|
||||||
"default": "./src/git-checkout.ts"
|
"default": "./src/git-checkout.ts"
|
||||||
},
|
},
|
||||||
|
"./node/project-summary": {
|
||||||
|
"types": "./src/project-summary.ts",
|
||||||
|
"development": "./src/project-summary.ts",
|
||||||
|
"default": "./src/project-summary.ts"
|
||||||
|
},
|
||||||
"./plot": {
|
"./plot": {
|
||||||
"types": "./src/plot/plot-spec.ts",
|
"types": "./src/plot/plot-spec.ts",
|
||||||
"development": "./src/plot/plot-spec.ts",
|
"development": "./src/plot/plot-spec.ts",
|
||||||
|
|||||||
71
packages/core/src/project-summary.test.ts
Normal file
71
packages/core/src/project-summary.test.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
PROJECT_SUMMARY_MAX_LENGTH,
|
||||||
|
readProjectSummary,
|
||||||
|
summarizeProjectFiles,
|
||||||
|
} from "./project-summary.js";
|
||||||
|
|
||||||
|
describe("summarizeProjectFiles", () => {
|
||||||
|
it("takes the README's first prose paragraph and skips headings, badges and code", () => {
|
||||||
|
const readme = [
|
||||||
|
"# Sugar Rush",
|
||||||
|
"",
|
||||||
|
"[](https://ci.example)",
|
||||||
|
"",
|
||||||
|
"```sh",
|
||||||
|
"pnpm install",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"A **candy** shop game for [kids](https://example.com),",
|
||||||
|
"built with React.",
|
||||||
|
"",
|
||||||
|
"## Setup",
|
||||||
|
].join("\n");
|
||||||
|
expect(summarizeProjectFiles({ readme })).toBe("A candy shop game for kids, built with React.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a manifest description", () => {
|
||||||
|
expect(
|
||||||
|
summarizeProjectFiles({ readme: "# Title only", packageJson: '{"description":"A CLI"}' }),
|
||||||
|
).toBe("A CLI");
|
||||||
|
expect(summarizeProjectFiles({ pyproject: '[project]\ndescription = "Data tools"\n' })).toBe(
|
||||||
|
"Data tools",
|
||||||
|
);
|
||||||
|
expect(summarizeProjectFiles({ cargoToml: '[package]\ndescription = "Fast parser"' })).toBe(
|
||||||
|
"Fast parser",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing describes the project", () => {
|
||||||
|
expect(summarizeProjectFiles({})).toBeNull();
|
||||||
|
expect(summarizeProjectFiles({ readme: "# Title", packageJson: "not json" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps long descriptions", () => {
|
||||||
|
const summary = summarizeProjectFiles({ readme: "word ".repeat(400) });
|
||||||
|
expect(summary?.length).toBe(PROJECT_SUMMARY_MAX_LENGTH);
|
||||||
|
expect(summary?.endsWith("…")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readProjectSummary", () => {
|
||||||
|
let directory: string | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (directory) await rm(directory, { recursive: true, force: true });
|
||||||
|
directory = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the README of a folder", async () => {
|
||||||
|
directory = await mkdtemp(path.join(tmpdir(), "project-summary-"));
|
||||||
|
await writeFile(path.join(directory, "README.md"), "# App\n\nTracks habits.\n");
|
||||||
|
expect(await readProjectSummary(directory)).toBe("Tracks habits.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an empty folder", async () => {
|
||||||
|
directory = await mkdtemp(path.join(tmpdir(), "project-summary-"));
|
||||||
|
expect(await readProjectSummary(directory)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
packages/core/src/project-summary.ts
Normal file
105
packages/core/src/project-summary.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
// Server-only. Kept out of the package index so browser bundles never load `node:fs`.
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/** Upper bound on a project summary, so it stays a short brief for the project's main agent. */
|
||||||
|
export const PROJECT_SUMMARY_MAX_LENGTH = 600;
|
||||||
|
const FILE_READ_LIMIT = 64 * 1024;
|
||||||
|
const README_NAMES = ["README.md", "README.markdown", "README.rst", "README.txt", "README"];
|
||||||
|
|
||||||
|
export interface ProjectFiles {
|
||||||
|
readme?: string | null;
|
||||||
|
packageJson?: string | null;
|
||||||
|
pyproject?: string | null;
|
||||||
|
cargoToml?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(text: string): string {
|
||||||
|
const flat = text.replace(/\s+/g, " ").trim();
|
||||||
|
if (flat.length <= PROJECT_SUMMARY_MAX_LENGTH) return flat;
|
||||||
|
return `${flat.slice(0, PROJECT_SUMMARY_MAX_LENGTH - 1).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first prose paragraph of a README: no headings, badges, images, HTML, or code. */
|
||||||
|
function readmeParagraph(readme: string): string | null {
|
||||||
|
let inFence = false;
|
||||||
|
const paragraph: string[] = [];
|
||||||
|
for (const raw of readme.split(/\r?\n/)) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line.startsWith("```") || line.startsWith("~~~")) {
|
||||||
|
inFence = !inFence;
|
||||||
|
if (paragraph.length) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence) continue;
|
||||||
|
const prose =
|
||||||
|
line &&
|
||||||
|
!/^(#|=+$|-+$|<|!\[|\[!\[|\||>)/.test(line) &&
|
||||||
|
// A line of only links is navigation, not a description.
|
||||||
|
line.replace(/\[[^\]]*\]\([^)]*\)/g, "").replace(/[\s|·•-]/g, "") !== "";
|
||||||
|
if (prose) {
|
||||||
|
paragraph.push(line);
|
||||||
|
} else if (paragraph.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!paragraph.length) return null;
|
||||||
|
const text = paragraph
|
||||||
|
.join(" ")
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
||||||
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||||
|
.replace(/[*_`]/g, "");
|
||||||
|
return text.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestDescription(files: ProjectFiles): string | null {
|
||||||
|
if (files.packageJson) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(files.packageJson) as { description?: unknown };
|
||||||
|
if (typeof parsed.description === "string" && parsed.description.trim()) {
|
||||||
|
return parsed.description.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// An unreadable manifest has no description.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const toml of [files.pyproject, files.cargoToml]) {
|
||||||
|
const match = toml?.match(/^\s*description\s*=\s*"([^"\n]+)"/m);
|
||||||
|
if (match?.[1]?.trim()) return match[1].trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a project is about, from the files a folder already has: the README's first paragraph,
|
||||||
|
* else a manifest description. Returns null when neither says anything.
|
||||||
|
*/
|
||||||
|
export function summarizeProjectFiles(files: ProjectFiles): string | null {
|
||||||
|
const summary =
|
||||||
|
(files.readme ? readmeParagraph(files.readme) : null) ?? manifestDescription(files);
|
||||||
|
return summary ? clamp(summary) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSmallFile(file: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(file, "utf8");
|
||||||
|
return content.slice(0, FILE_READ_LIMIT);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a linked project folder's README and manifests; nothing else in it is opened. */
|
||||||
|
export async function readProjectSummary(directory: string): Promise<string | null> {
|
||||||
|
let readme: string | null = null;
|
||||||
|
for (const name of README_NAMES) {
|
||||||
|
readme = await readSmallFile(path.join(directory, name));
|
||||||
|
if (readme) break;
|
||||||
|
}
|
||||||
|
const [packageJson, pyproject, cargoToml] = await Promise.all([
|
||||||
|
readSmallFile(path.join(directory, "package.json")),
|
||||||
|
readSmallFile(path.join(directory, "pyproject.toml")),
|
||||||
|
readSmallFile(path.join(directory, "Cargo.toml")),
|
||||||
|
]);
|
||||||
|
return summarizeProjectFiles({ readme, packageJson, pyproject, cargoToml });
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ export * from "./model-credentials.js";
|
|||||||
export * from "./model-tiers.js";
|
export * from "./model-tiers.js";
|
||||||
export * from "./notification-settings.js";
|
export * from "./notification-settings.js";
|
||||||
export * from "./project-goals.js";
|
export * from "./project-goals.js";
|
||||||
|
export * from "./project-recruits.js";
|
||||||
export * from "./project-workspace.js";
|
export * from "./project-workspace.js";
|
||||||
export * from "./push-subscriptions.js";
|
export * from "./push-subscriptions.js";
|
||||||
export * from "./repos.js";
|
export * from "./repos.js";
|
||||||
|
|||||||
148
packages/db/src/project-recruits.postgres.test.ts
Normal file
148
packages/db/src/project-recruits.postgres.test.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { bootstrapUserSpace } from "./bootstrap-user.js";
|
||||||
|
import { createDb, type PrismaClient } from "./client.js";
|
||||||
|
import {
|
||||||
|
listDelegationCandidates,
|
||||||
|
recruitIntoProject,
|
||||||
|
recruitSpawnKey,
|
||||||
|
syncProjectMembers,
|
||||||
|
} from "./project-recruits.js";
|
||||||
|
import { createRepos } from "./repos.js";
|
||||||
|
import { requireMembership } from "./scope.js";
|
||||||
|
import { createSpaceForMember } from "./spaces.js";
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
const describePostgres =
|
||||||
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||||
|
|
||||||
|
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
|
||||||
|
|
||||||
|
describePostgres("project recruits (PostgreSQL)", () => {
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let close: () => Promise<void>;
|
||||||
|
const userIds: string[] = [];
|
||||||
|
|
||||||
|
async function newUserWithProject() {
|
||||||
|
const id = `pr${randomUUID().replaceAll("-", "")}`;
|
||||||
|
userIds.push(id);
|
||||||
|
await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
|
||||||
|
const { spaceId: homeSpaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, {
|
||||||
|
claimDeploymentOwner: false,
|
||||||
|
});
|
||||||
|
const project = await createSpaceForMember(prisma, {
|
||||||
|
currentSpaceId: homeSpaceId,
|
||||||
|
userId: id,
|
||||||
|
name: "Sugar Rush",
|
||||||
|
});
|
||||||
|
const library = await createRepos(prisma).createBot(
|
||||||
|
await requireMembership(prisma, id, homeSpaceId),
|
||||||
|
{
|
||||||
|
name: "Pixel",
|
||||||
|
title: "Designer",
|
||||||
|
description: "Draws things",
|
||||||
|
instructions: "Be bold.",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { userId: id, homeSpaceId, projectSpaceId: project.id, library };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const db = createDb(databaseUrl!);
|
||||||
|
prisma = db.prisma;
|
||||||
|
close = async () => {
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await db.pool.end();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!prisma) return;
|
||||||
|
const memberships = await prisma.member.findMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
await prisma.organization.deleteMany({
|
||||||
|
where: { id: { in: memberships.map((member) => member.organizationId) } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
await close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a project one member per library agent, carrying its persona", async () => {
|
||||||
|
const { userId, projectSpaceId, library } = await newUserWithProject();
|
||||||
|
const [first, again] = await Promise.all([
|
||||||
|
recruitIntoProject(prisma, { userId, spaceId: projectSpaceId, libraryBotId: library.id }),
|
||||||
|
recruitIntoProject(prisma, { userId, spaceId: projectSpaceId, libraryBotId: library.id }),
|
||||||
|
]);
|
||||||
|
expect(first?.id).toBe(again?.id);
|
||||||
|
const member = await prisma.bot.findUniqueOrThrow({ where: { id: first!.id } });
|
||||||
|
expect(member).toMatchObject({
|
||||||
|
spaceId: projectSpaceId,
|
||||||
|
name: "Pixel",
|
||||||
|
title: "Designer",
|
||||||
|
instructions: "Be bold.",
|
||||||
|
kind: "specialist",
|
||||||
|
spawnKey: recruitSpawnKey(library.id),
|
||||||
|
});
|
||||||
|
// The agent's home memory stays home; the member starts with its own.
|
||||||
|
const memory = await prisma.memoryDocument.findMany({ where: { botId: member.id } });
|
||||||
|
expect(memory.map((doc) => doc.spaceId)).toEqual([projectSpaceId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to recruit into the home space or from a project", async () => {
|
||||||
|
const { userId, homeSpaceId, projectSpaceId, library } = await newUserWithProject();
|
||||||
|
expect(
|
||||||
|
await recruitIntoProject(prisma, { userId, spaceId: homeSpaceId, libraryBotId: library.id }),
|
||||||
|
).toBeNull();
|
||||||
|
const member = await recruitIntoProject(prisma, {
|
||||||
|
userId,
|
||||||
|
spaceId: projectSpaceId,
|
||||||
|
libraryBotId: library.id,
|
||||||
|
});
|
||||||
|
const other = await createSpaceForMember(prisma, {
|
||||||
|
currentSpaceId: homeSpaceId,
|
||||||
|
userId,
|
||||||
|
name: "Garden",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await recruitIntoProject(prisma, { userId, spaceId: other.id, libraryBotId: member!.id }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mirrors later edits of the library agent onto its members", async () => {
|
||||||
|
const { userId, projectSpaceId, library } = await newUserWithProject();
|
||||||
|
const member = await recruitIntoProject(prisma, {
|
||||||
|
userId,
|
||||||
|
spaceId: projectSpaceId,
|
||||||
|
libraryBotId: library.id,
|
||||||
|
});
|
||||||
|
await prisma.bot.update({ where: { id: library.id }, data: { instructions: "Be calm." } });
|
||||||
|
await syncProjectMembers(prisma, { userId, libraryBotId: library.id });
|
||||||
|
const synced = await prisma.bot.findUniqueOrThrow({ where: { id: member!.id } });
|
||||||
|
expect(synced.instructions).toBe("Be calm.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a recruited agent once per project and never another project's member", async () => {
|
||||||
|
const { userId, homeSpaceId, projectSpaceId, library } = await newUserWithProject();
|
||||||
|
const before = await listDelegationCandidates(prisma, { userId, spaceId: projectSpaceId });
|
||||||
|
expect(before.map((bot) => [bot.id, bot.joinsHere])).toEqual([[library.id, true]]);
|
||||||
|
|
||||||
|
const member = await recruitIntoProject(prisma, {
|
||||||
|
userId,
|
||||||
|
spaceId: projectSpaceId,
|
||||||
|
libraryBotId: library.id,
|
||||||
|
});
|
||||||
|
const inProject = await listDelegationCandidates(prisma, { userId, spaceId: projectSpaceId });
|
||||||
|
expect(inProject.map((bot) => bot.id)).toEqual([member!.id]);
|
||||||
|
|
||||||
|
const other = await createSpaceForMember(prisma, {
|
||||||
|
currentSpaceId: homeSpaceId,
|
||||||
|
userId,
|
||||||
|
name: "Garden",
|
||||||
|
});
|
||||||
|
const elsewhere = await listDelegationCandidates(prisma, { userId, spaceId: other.id });
|
||||||
|
expect(elsewhere.map((bot) => bot.id)).toEqual([library.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
137
packages/db/src/project-recruits.ts
Normal file
137
packages/db/src/project-recruits.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import type { Prisma, PrismaClient } from "./client.js";
|
||||||
|
import { createRepos } from "./repos.js";
|
||||||
|
import { requireMembership } from "./scope.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agents are created once, in the user's home space: that is their library. A project's main
|
||||||
|
* agent recruits one by giving the project its own member, linked to the library agent through a
|
||||||
|
* spawn key. The member carries the agent's persona and works with the project's files, memory,
|
||||||
|
* computer and connections; the agent's home memory never crosses into the project.
|
||||||
|
*/
|
||||||
|
const RECRUIT_SPAWN_KEY_PREFIX = "recruit:";
|
||||||
|
|
||||||
|
export function recruitSpawnKey(libraryBotId: string): string {
|
||||||
|
return `${RECRUIT_SPAWN_KEY_PREFIX}${libraryBotId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The persona a project member mirrors from its library agent. */
|
||||||
|
const personaSelect = {
|
||||||
|
name: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
instructions: true,
|
||||||
|
color: true,
|
||||||
|
notifyOnFinish: true,
|
||||||
|
tags: true,
|
||||||
|
restrictBuiltinTools: true,
|
||||||
|
allowedBuiltinTools: true,
|
||||||
|
modelProvider: true,
|
||||||
|
modelId: true,
|
||||||
|
thinkingLevel: true,
|
||||||
|
modelTier: true,
|
||||||
|
} as const satisfies Prisma.BotSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specialists the main agent of `spaceId` can hand work to, those of that space first: its own
|
||||||
|
* specialists, library agents, and specialists of other projects. A project member of a library
|
||||||
|
* agent stands in for that agent in its project and is never offered anywhere else.
|
||||||
|
* `joinsHere` marks those that work in this space's conversation when delegated to.
|
||||||
|
*/
|
||||||
|
export async function listDelegationCandidates(
|
||||||
|
prisma: Pick<PrismaClient, "bot">,
|
||||||
|
input: { userId: string; spaceId: string; take?: number },
|
||||||
|
) {
|
||||||
|
const bots = await prisma.bot.findMany({
|
||||||
|
where: {
|
||||||
|
userId: input.userId,
|
||||||
|
// Main bots lead their own space and are never delegated to.
|
||||||
|
kind: "specialist",
|
||||||
|
archivedAt: null,
|
||||||
|
thread: { isNot: null },
|
||||||
|
space: { memberships: { some: { userId: input.userId } } },
|
||||||
|
OR: [
|
||||||
|
{ spaceId: input.spaceId },
|
||||||
|
{ spawnKey: null },
|
||||||
|
{ NOT: { spawnKey: { startsWith: RECRUIT_SPAWN_KEY_PREFIX } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
spaceId: true,
|
||||||
|
spawnKey: true,
|
||||||
|
space: { select: { name: true, isDefault: true } },
|
||||||
|
thread: { select: { id: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
...(input.take ? { take: input.take } : {}),
|
||||||
|
});
|
||||||
|
const here = bots.filter((bot) => bot.spaceId === input.spaceId);
|
||||||
|
const recruited = new Set(here.map((bot) => bot.spawnKey));
|
||||||
|
const elsewhere = bots.filter(
|
||||||
|
(bot) => bot.spaceId !== input.spaceId && !recruited.has(recruitSpawnKey(bot.id)),
|
||||||
|
);
|
||||||
|
return [...here, ...elsewhere].map((bot) => ({
|
||||||
|
...bot,
|
||||||
|
joinsHere: bot.spaceId === input.spaceId || bot.space.isDefault,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project member of library agent `libraryBotId` in project `spaceId`, created or restored
|
||||||
|
* with the agent's current persona. Returns null when the agent is not an active library
|
||||||
|
* specialist of the user or `spaceId` is the home space itself.
|
||||||
|
*/
|
||||||
|
export async function recruitIntoProject(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
input: { userId: string; spaceId: string; libraryBotId: string },
|
||||||
|
): Promise<{ id: string; name: string; spaceId: string; threadId: string } | null> {
|
||||||
|
const project = await requireMembership(prisma, input.userId, input.spaceId);
|
||||||
|
const library = await prisma.bot.findFirst({
|
||||||
|
where: {
|
||||||
|
id: input.libraryBotId,
|
||||||
|
userId: input.userId,
|
||||||
|
kind: "specialist",
|
||||||
|
archivedAt: null,
|
||||||
|
space: { isDefault: true },
|
||||||
|
spaceId: { not: project.spaceId },
|
||||||
|
},
|
||||||
|
select: personaSelect,
|
||||||
|
});
|
||||||
|
if (!library) return null;
|
||||||
|
const spawnKey = recruitSpawnKey(input.libraryBotId);
|
||||||
|
const where = { spaceId_spawnKey: { spaceId: project.spaceId, spawnKey } };
|
||||||
|
const existing = await prisma.bot.findUnique({ where, select: { id: true, userId: true } });
|
||||||
|
if (existing && existing.userId !== input.userId) return null;
|
||||||
|
if (existing) {
|
||||||
|
// A member the user archived earlier comes back with the agent's current persona.
|
||||||
|
await prisma.bot.update({ where: { id: existing.id }, data: { ...library, archivedAt: null } });
|
||||||
|
} else {
|
||||||
|
// createBot is idempotent per spawn key, so concurrent recruits share one member.
|
||||||
|
await createRepos(prisma).createBot(project, { ...library, spawnKey });
|
||||||
|
}
|
||||||
|
const member = await prisma.bot.findUnique({
|
||||||
|
where,
|
||||||
|
select: { id: true, name: true, spaceId: true, thread: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
if (!member?.thread) return null;
|
||||||
|
return { id: member.id, name: member.name, spaceId: member.spaceId, threadId: member.thread.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies a library agent's persona onto its project members after the agent was edited. */
|
||||||
|
export async function syncProjectMembers(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
input: { userId: string; libraryBotId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const library = await prisma.bot.findFirst({
|
||||||
|
where: { id: input.libraryBotId, userId: input.userId, space: { isDefault: true } },
|
||||||
|
select: personaSelect,
|
||||||
|
});
|
||||||
|
if (!library) return;
|
||||||
|
await prisma.bot.updateMany({
|
||||||
|
where: { userId: input.userId, spawnKey: recruitSpawnKey(input.libraryBotId) },
|
||||||
|
data: library,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user