Add inline persistent Agent mentions
TestFlight / Build and upload (push) Successful in 1m57s

This commit is contained in:
Sybil Agent
2026-10-29 18:28:52 -07:00
parent 35a6c537ad
commit 5d738f897b
23 changed files with 1772 additions and 44 deletions
+212 -16
View File
@@ -3,6 +3,9 @@ import { performance } from "node:perf_hooks";
import { z } from "zod";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { ActiveSseStream, type SseStreamEvent } from "./active-streams.js";
import { invokeAgentInline, type AgentInlineInvoker } from "./agent-inline.js";
import { buildInlineAgentThreadContext, decodeChatHistoryCursor, decodeChatListCursor, encodeChatListCursor, loadCompleteChatHistory, readChatHistoryPage } from "./chat-history.js";
import { containsSybilMention, filterAgentAsideMessages } from "./agent-mention.js";
import { prisma } from "./db.js";
import { requireAdmin } from "./auth.js";
import { env } from "./env.js";
@@ -25,6 +28,7 @@ const MAX_FORK_MESSAGE_SNIPPET_CHARS = 48;
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
type IncomingChatMessage = {
id?: string;
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
@@ -92,14 +96,14 @@ function withRequestUserLocation<T extends { userLocation?: string }>(body: T, r
return body.userLocation ? body : { ...body, userLocation: inferRequestUserLocation(req) };
}
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string) {
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[], clientRequestId?: string, agentAside = false) {
const incoming = messages.filter((m) => m.role !== "assistant");
if (!incoming.length) return;
const existing = await prisma.message.findMany({
where: { chatId },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { role: true, content: true, name: true, metadata: true },
select: { id: true, role: true, content: true, name: true, metadata: true },
});
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
@@ -109,7 +113,20 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
sharedPrefix += 1;
}
if (sharedPrefix === incoming.length) return;
if (sharedPrefix === incoming.length) {
const lastUserIndex = incoming.map((message) => message.role).lastIndexOf("user");
const storedUser = lastUserIndex >= 0 ? existingNonAssistant[lastUserIndex] : undefined;
if (agentAside && storedUser && sameMessage(storedUser, incoming[lastUserIndex]!)) {
const metadata = storedUser.metadata && typeof storedUser.metadata === "object" && !Array.isArray(storedUser.metadata)
? storedUser.metadata as Record<string, unknown>
: {};
await prisma.message.update({
where: { id: storedUser.id },
data: { metadata: { ...metadata, ...(clientRequestId ? { clientRequestId } : {}), agentAside: true } as any },
});
}
return;
}
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
if (!toInsert.length) return;
@@ -119,6 +136,7 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
const metadata = {
...(m.attachments?.length ? { attachments: m.attachments } : {}),
...(clientRequestId && index === finalUserMessageIndex ? { clientRequestId } : {}),
...(agentAside && index === finalUserMessageIndex ? { agentAside: true } : {}),
};
return {
chatId,
@@ -161,6 +179,7 @@ const ChatAttachmentSchema = z.discriminatedUnion("kind", [
const CompletionMessageSchema = z
.object({
id: z.string().trim().min(1).max(128).optional(),
role: z.enum(["system", "user", "assistant", "tool"]),
content: z.string(),
name: z.string().optional(),
@@ -228,6 +247,12 @@ function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttac
};
}
function removeReservedAgentAsideMetadata(metadata: unknown) {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return metadata;
const { agentAside: _agentAside, ...remaining } = metadata as Record<string, unknown>;
return Object.keys(remaining).length ? remaining : undefined;
}
function normalizeAdditionalSystemPrompt(value: string | null | undefined) {
const trimmed = value?.trim();
return trimmed || null;
@@ -263,6 +288,71 @@ async function applyStoredChatSettings<T extends { chatId?: string; messages: In
});
}
function isInlineAgentRequest(messages: IncomingChatMessage[]) {
const latestUserMessage = [...messages].reverse().find((message) => message.role === "user");
return latestUserMessage ? containsSybilMention(latestUserMessage.content) : false;
}
async function prepareInlineAgentRequest(chatId: string, requestId: string, messages: IncomingChatMessage[]) {
const currentRequest = [...messages].reverse().find((message) => message.role === "user")?.content;
if (currentRequest === undefined) throw new Error("The current direct user request is unavailable; Sybil did not answer from partial history.");
let history: Awaited<ReturnType<typeof loadCompleteChatHistory>>;
try {
history = await loadCompleteChatHistory(prisma, chatId);
} catch {
throw new Error("The complete visible thread could not be loaded; Sybil did not answer from partial history.");
}
const context = buildInlineAgentThreadContext(chatId, currentRequest, history);
return { chatId, requestId, context };
}
async function withoutAgentAsideMessages<T extends { chatId?: string; messages: IncomingChatMessage[] }>(body: T): Promise<T> {
if (!body.chatId) return body;
const storedMessages = await prisma.message.findMany({
where: { chatId: body.chatId },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { id: true, role: true, content: true, name: true, metadata: true },
});
const messages = filterAgentAsideMessages(body.messages, storedMessages, (incoming, stored) => sameMessage(stored, incoming));
return { ...body, messages };
}
async function storeAgentAsideReply(chatId: string, content: string, clientRequestId?: string) {
return prisma.message.create({
data: {
chatId,
role: "assistant" as any,
content,
name: "Sybil Agent",
metadata: { agentAside: true, ...(clientRequestId ? { clientRequestId } : {}) } as any,
},
});
}
function executeActiveAgentAsideStream(chatId: string, body: z.infer<typeof CompletionStreamBody>, stream: ActiveSseStream, invoke: AgentInlineInvoker) {
stream.emit("meta", {
type: "meta",
chatId,
callId: null,
provider: "hermes-agent",
model: "sybil-agent",
agentAside: true,
});
void (async () => {
try {
const request = await prepareInlineAgentRequest(chatId, body.clientRequestId ?? randomUUID(), body.messages);
const text = await invoke(request);
await storeAgentAsideReply(chatId, text, body.clientRequestId);
stream.emit("delta", { type: "delta", text });
stream.complete({ event: "done", data: { type: "done", text } });
} catch (error) {
stream.complete({ event: "error", data: { message: getErrorMessage(error) } });
} finally {
clearActiveChatStream(chatId, stream);
}
})();
}
const SearchRunBody = z.object({
query: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).optional(),
@@ -717,14 +807,16 @@ function completeChatSubmissionStream(
stream: ActiveSseStream,
chatId: string,
body: z.infer<typeof CompletionStreamBody>,
assistantText: string
assistantText: string,
agentAside = false,
) {
stream.emit("meta", {
type: "meta",
chatId,
callId: null,
provider: body.provider,
model: body.model,
provider: agentAside ? "hermes-agent" : body.provider,
model: agentAside ? "sybil-agent" : body.model,
...(agentAside ? { agentAside: true } : {}),
});
stream.complete({
event: "done",
@@ -900,7 +992,8 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
}
}
export async function registerRoutes(app: FastifyInstance) {
export async function registerRoutes(app: FastifyInstance, options: { agentInlineInvoker?: AgentInlineInvoker } = {}) {
const agentInlineInvoker = options.agentInlineInvoker ?? invokeAgentInline;
app.get("/health", { logLevel: "silent" }, async () => ({ ok: true }));
app.get("/v1/auth/session", async (req) => {
@@ -962,8 +1055,42 @@ export async function registerRoutes(app: FastifyInstance) {
return { items: await listWorkspaceItems() };
});
app.get("/v1/chats", async (req) => {
app.get("/v1/chats", async (req, reply) => {
requireAdmin(req);
const Query = z.object({ cursor: z.string().max(512).optional(), limit: z.coerce.number().int().min(1).max(100).optional() });
const rawQuery = req.query as Record<string, unknown>;
if (Object.keys(rawQuery).length) {
reply.header("Cache-Control", "no-store");
const parsedQuery = Query.safeParse(req.query);
if (!parsedQuery.success) return app.httpErrors.badRequest(parsedQuery.error.message);
let cursor: ReturnType<typeof decodeChatListCursor> | undefined;
if (parsedQuery.data.cursor) {
try {
cursor = decodeChatListCursor(parsedQuery.data.cursor);
} catch {
return app.httpErrors.badRequest("Invalid chat-list cursor");
}
}
const limit = parsedQuery.data.limit ?? 50;
const chats = await prisma.chat.findMany({
where: cursor ? {
OR: [
{ updatedAt: { lt: cursor.updatedAt } },
{ updatedAt: cursor.updatedAt, id: { gt: cursor.id } },
],
} : undefined,
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
take: limit + 1,
select: { id: true, title: true, parentChatId: true, createdAt: true, updatedAt: true, initiatedProvider: true, initiatedModel: true, lastUsedProvider: true, lastUsedModel: true },
});
const hasMore = chats.length > limit;
const page = hasMore ? chats.slice(0, limit) : chats;
const last = page.at(-1);
return {
chats: page.map((chat) => serializeProviderFields(chat)),
nextCursor: hasMore && last ? encodeChatListCursor({ updatedAt: last.updatedAt, id: last.id }) : null,
};
}
const chats = await listRecentChatsWithRoots();
return { chats: chats.map((chat) => serializeChatLike(chat)) };
});
@@ -1513,6 +1640,32 @@ export async function registerRoutes(app: FastifyInstance) {
return streamActiveRun(req, reply, stream);
});
app.get("/v1/chats/:chatId/messages", async (req, reply) => {
reply.header("Cache-Control", "no-store");
requireAdmin(req);
const Params = z.object({ chatId: z.string() });
const Query = z.object({
cursor: z.string().max(512).optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
});
const { chatId } = Params.parse(req.params);
const parsedQuery = Query.safeParse(req.query);
if (!parsedQuery.success) return app.httpErrors.badRequest(parsedQuery.error.message);
const { limit } = parsedQuery.data;
let cursor: ReturnType<typeof decodeChatHistoryCursor> | undefined;
if (parsedQuery.data.cursor) {
try {
cursor = decodeChatHistoryCursor(parsedQuery.data.cursor);
} catch {
return app.httpErrors.badRequest("Invalid history cursor");
}
}
const page = await readChatHistoryPage(prisma, chatId, limit, cursor);
if (!page) return app.httpErrors.notFound("chat not found");
return page;
});
app.get("/v1/chats/:chatId", async (req) => {
requireAdmin(req);
const Params = z.object({ chatId: z.string() });
@@ -1552,7 +1705,7 @@ export async function registerRoutes(app: FastifyInstance) {
role: body.role as any,
content: body.content,
name: body.name,
metadata: mergeAttachmentsIntoMetadata(body.metadata, body.attachments) as any,
metadata: mergeAttachmentsIntoMetadata(removeReservedAgentAsideMetadata(body.metadata), body.attachments) as any,
},
});
@@ -1574,6 +1727,7 @@ export async function registerRoutes(app: FastifyInstance) {
const Body = z.object({
chatId: z.string().optional(),
clientRequestId: z.string().trim().min(1).max(128).optional(),
provider: ProviderSchema,
model: z.string().min(1),
messages: z.array(CompletionMessageSchema),
@@ -1596,10 +1750,44 @@ export async function registerRoutes(app: FastifyInstance) {
// Store only new non-assistant messages to avoid duplicate history entries.
if (body.chatId) {
await storeNonAssistantMessages(body.chatId, body.messages);
const agentAside = isInlineAgentRequest(body.messages);
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId, agentAside);
if (agentAside) {
if (body.clientRequestId) {
const completed = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
if (completed) {
const completedAgentAside = Boolean((completed.metadata as Record<string, unknown> | null)?.agentAside === true);
return {
chatId: body.chatId,
provider: completedAgentAside ? "hermes-agent" : body.provider,
model: completedAgentAside ? "sybil-agent" : body.model,
message: {
role: "assistant",
content: completed.content,
...(completedAgentAside ? { name: "Sybil Agent", agentAside: true } : {}),
},
};
}
}
const requestId = body.clientRequestId ?? randomUUID();
try {
const request = await prepareInlineAgentRequest(body.chatId, requestId, body.messages);
const content = await agentInlineInvoker(request);
await storeAgentAsideReply(body.chatId, content, body.clientRequestId);
return {
chatId: body.chatId,
provider: "hermes-agent",
model: "sybil-agent",
message: { role: "assistant", content, name: "Sybil Agent", agentAside: true },
};
} catch (error) {
return app.httpErrors.unprocessableEntity(getErrorMessage(error));
}
}
}
const result = await runMultiplex(await applyStoredChatSettings(body));
const contextBody = await withoutAgentAsideMessages(body);
const result = await runMultiplex(await applyStoredChatSettings(contextBody));
return {
chatId: body.chatId ?? null,
@@ -1635,6 +1823,7 @@ export async function registerRoutes(app: FastifyInstance) {
const parsed = CompletionStreamBody.safeParse(req.body);
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
const body = withRequestUserLocation(parsed.data, req);
const agentAside = body.chatId ? isInlineAgentRequest(body.messages) : false;
// Ensure the chat exists and identify its family before reserving a stream.
let chatFamilyRootId: string | null = null;
@@ -1665,16 +1854,22 @@ export async function registerRoutes(app: FastifyInstance) {
if (body.clientRequestId) {
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
if (completedSubmission) {
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
const completedAgentAside = Boolean((completedSubmission.metadata as Record<string, unknown> | null)?.agentAside === true);
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content, completedAgentAside);
clearActiveChatStream(body.chatId, reservedStream);
return streamActiveRun(req, reply, reservedStream);
}
}
// Reserve the stream before persistence so deletion cannot interleave with setup.
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
const configuredBody = await applyStoredChatSettings(body);
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId, agentAside);
if (agentAside) {
executeActiveAgentAsideStream(body.chatId, body, reservedStream, agentInlineInvoker);
} else {
const contextBody = await withoutAgentAsideMessages(body);
const configuredBody = await applyStoredChatSettings(contextBody);
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
}
return streamActiveRun(req, reply, reservedStream);
} catch (err) {
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
@@ -1686,7 +1881,8 @@ export async function registerRoutes(app: FastifyInstance) {
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
reply.raw.flushHeaders();
for await (const ev of runMultiplexStream(await applyStoredChatSettings(body))) {
const contextBody = await withoutAgentAsideMessages(body);
for await (const ev of runMultiplexStream(await applyStoredChatSettings(contextBody))) {
writeSseEvent(reply, mapChatStreamEvent(ev));
}