Rename the persistent agent integration to Sybil Agent

Use SYBIL_AGENT_BASE_URL and SYBIL_AGENT_API_KEY with the renamed sybil-agent
service. Update errors, API documentation, and test fixtures while retaining the
existing room protocol and browser storage keys for compatibility.

Validation: 44 server tests, 51 web tests, and both production builds.
This commit is contained in:
2026-10-14 12:37:03 -07:00
parent ef3d79c428
commit e83281c400
9 changed files with 52 additions and 50 deletions
+9 -8
View File
@@ -107,23 +107,24 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
- Postgres support + migrations for prod
- Attachments + embeddings + semantic search
## Persistent Pi Agent
## Sybil Agent
Configure the backend with:
```dotenv
PI_AGENT_BASE_URL=http://pi-durable:8788
PI_AGENT_API_KEY=<the Pi service's PI_API_KEY>
SYBIL_AGENT_BASE_URL=http://sybil-agent:8788
SYBIL_AGENT_API_KEY=<the Sybil Agent service API key>
```
The base URL defaults to `http://pi-durable:8788` and has no `/v1` suffix. In Docker,
attach Sybil's **server** container and Pi to the shared `agents` network so that
`pi-durable` resolves privately. Supply the key through a private server environment
Use the same `SYBIL_AGENT_API_KEY` value configured on the agent service.
The base URL defaults to `http://sybil-agent:8788` and has no `/v1` suffix. In Docker,
attach Sybil's **server** container and Sybil Agent to the shared `agents` network so that
`sybil-agent` resolves privately. Supply the key through a private server environment
file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil
`ADMIN_TOKEN` authentication for clients.
The Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the
same persistent Pi root conversation across devices. Pi remains the source of
The Sybil Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the
same persistent root conversation across devices. Sybil Agent remains the source of
truth for messages and background replies. Sybil stores only the shared,
monotonically increasing read cursor in SQLite through `/v1/agent/read-state`;
its migration is applied automatically at startup. The bundled nginx configuration
+2 -2
View File
@@ -216,8 +216,8 @@ model ProjectItem {
@@index([searchId])
}
// Shared personal read position for the external persistent Pi room.
// Messages remain in Pi's durable transcript, not duplicated in Sybil Chat.
// Shared personal read position for the external persistent Sybil Agent room.
// Messages remain in the agent's durable transcript, not duplicated in Sybil Chat.
model AgentReadState {
id String @id
cursor String @default("0")
+12 -12
View File
@@ -30,22 +30,22 @@ async function readState() {
return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null };
}
/** Pi owns the conversation; Sybil stores only its shared personal read cursor. */
/** Sybil Agent owns the conversation; the chat server stores only its shared read cursor. */
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
const baseUrl = options.baseUrl || env.PI_AGENT_BASE_URL;
const apiKey = options.apiKey ?? env.PI_AGENT_API_KEY;
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
const proxyJson = async (path: string, method = 'GET', body?: unknown) => {
if (!apiKey) throw app.httpErrors.serviceUnavailable('Pi agent is not configured');
if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
let response: Response;
try {
response = await fetch(new URL(path, baseUrl), { method,
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000),
});
} catch { throw app.httpErrors.badGateway('Pi agent is unavailable'); }
} catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
let data: unknown;
try { data = await response.json(); }
catch { throw app.httpErrors.badGateway('Pi agent returned invalid JSON'); }
catch { throw app.httpErrors.badGateway('Sybil Agent returned invalid JSON'); }
return { status: response.status, data };
};
@@ -75,7 +75,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
if (result.status >= 200 && result.status < 300) {
const receipt = SubmissionReceipt.safeParse(result.data);
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) {
throw app.httpErrors.badGateway('Pi agent returned an invalid message receipt');
throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt');
}
}
reply.code(result.status);
@@ -137,22 +137,22 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: strin
if (!authenticated) {
const auth = parse(AuthFrame, frame);
if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; }
if (!apiKey) { browser.close(4503, 'Pi agent is not configured'); return; }
if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; }
authenticated = true; clearTimeout(authTimeout);
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
upstream.on('message', (data, binary) => {
if (binary) { browser.close(1011, 'Invalid Pi frame'); return; }
if (binary) { browser.close(1011, 'Invalid Sybil Agent frame'); return; }
send(data.toString());
});
upstream.on('close', () => { if (!disposed) browser.close(1012, 'Pi agent disconnected; reconnect with cursor'); });
upstream.on('error', () => { send({ type: 'error', error: 'Pi agent is unavailable' }); browser.close(1011, 'Pi agent is unavailable'); });
upstream.on('close', () => { if (!disposed) browser.close(1012, 'Sybil Agent disconnected; reconnect with cursor'); });
upstream.on('error', () => { send({ type: 'error', error: 'Sybil Agent is unavailable' }); browser.close(1011, 'Sybil Agent is unavailable'); });
return;
}
if (frame.type === 'ping') { send({ type: 'pong' }); return; }
const subscribe = parse(SubscribeFrame, frame);
if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Pi agent is still connecting' }); return; }
if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Sybil Agent is still connecting' }); return; }
upstream.send(JSON.stringify(subscribe));
} catch (error) {
if (!authenticated) browser.close(4400, 'Invalid authentication frame');
+2 -2
View File
@@ -70,8 +70,8 @@ const EnvSchema = z.object({
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
PI_AGENT_BASE_URL: z.string().url().default("http://pi-durable:8788"),
PI_AGENT_API_KEY: OptionalTrimmedStringSchema,
SYBIL_AGENT_BASE_URL: z.string().url().default("http://sybil-agent:8788"),
SYBIL_AGENT_API_KEY: OptionalTrimmedStringSchema,
EXA_API_KEY: z.string().optional(),
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
+6 -6
View File
@@ -23,7 +23,7 @@ const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([
import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'),
]);
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` };
const privateKey = 'pi-private-key-for-test';
const privateKey = 'sybil-agent-private-key-for-test';
const requests: { method?: string; url?: string; body: any }[] = [];
const accepted = new Map<string, number>();
let upstreamReply: { status: number; body: string } | undefined;
@@ -42,7 +42,7 @@ const upstream = createServer(async (req, res) => {
res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` }));
} else if (req.url?.includes('/messages')) {
res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false }));
} else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Pi', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } }));
} else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Sybil Agent', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } }));
});
const sourceSockets = new WebSocketServer({ noServer: true });
upstream.on('upgrade', (req, socket, head) => {
@@ -80,7 +80,7 @@ async function eventually(check: () => boolean) {
assert.fail('Expected condition was not reached');
}
test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Pi credentials', async () => {
test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Sybil Agent credentials', async () => {
assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401);
const descriptor = await app.inject({ url: '/v1/agent', headers: auth });
assert.equal(descriptor.statusCode, 200);
@@ -94,7 +94,7 @@ test('REST authenticates, proxies the shared room, and retains stable message ID
const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json());
assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
assert.equal((await prisma.chat.count()), 0, 'Pi messages are not copied into ordinary Sybil chats');
assert.equal((await prisma.chat.count()), 0, 'Sybil Agent messages are not copied into ordinary Sybil chats');
assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400);
assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400);
});
@@ -132,7 +132,7 @@ test('malformed upstream JSON and unconfirmed message receipts return 502, prese
assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`);
});
test('malformed and unmatched upgrade targets close without crashing or opening Pi connections', async () => {
test('malformed and unmatched upgrade targets close without crashing or opening Sybil Agent connections', async () => {
const before = connections;
const requestUpgrade = (target: string): Promise<string> => new Promise((resolve, reject) => {
let response = '';
@@ -172,7 +172,7 @@ test('WebSocket first-frame authentication gates the private connection and forw
await eventually(() => sourceSockets.clients.size === 0);
});
test('open-mode WebSocket authentication accepts null and still keeps the Pi token server-side', async () => {
test('open-mode WebSocket authentication accepts null and still keeps the Sybil Agent token server-side', async () => {
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
try {
const browser = new WebSocket(wsUrl); const frames: any[] = [];