+
+
+
+
+
+
diff --git a/design/head-lab/looks/2026-10-27-blue.json b/design/head-lab/looks/2026-10-27-blue.json
new file mode 100644
index 00000000..45ebe1a8
--- /dev/null
+++ b/design/head-lab/looks/2026-10-27-blue.json
@@ -0,0 +1,17 @@
+{
+ "color": "#1d84f5",
+ "noseDark": 0.22,
+ "eyeColor": "white",
+ "eyes": 2,
+ "nose": 0.48,
+ "noseOut": 0.35,
+ "eye": 27,
+ "eyeTheta": 55,
+ "eyeLat": 8,
+ "rotX": -3,
+ "rotY": -65,
+ "rotZ": -12,
+ "flip": false,
+ "theme": "dark",
+ "cam": "persp"
+}
diff --git a/packages/api/src/activity/route.test.ts b/packages/api/src/activity/route.test.ts
index b6de0136..236f1d54 100644
--- a/packages/api/src/activity/route.test.ts
+++ b/packages/api/src/activity/route.test.ts
@@ -21,6 +21,7 @@ const listAgentTasksBetween = mock();
const listSubTasks = mock();
mock.module('@proxy/db/task', () => ({listAgentTasks, listAgentTasksBetween, listSubTasks}));
+mock.module('@proxy/db/harness', () => ({listPersonasById: mock(async () => Ok([]))}));
function makeRequest(query: Record) {
return {user: {userId: 'user-1', email: 'test@example.com', name: 'Test User'}, query};
@@ -45,6 +46,7 @@ describe('handleActivityTimelineRoute', () => {
const task = {
id: 'task-1',
agentId: 'agent-1',
+ personaId: null,
parentId: null,
title: 'Triage inbox',
goal: 'Archive the newsletters',
@@ -90,6 +92,7 @@ describe('handleActivityTimelineRoute', () => {
{
id: 'task-1',
agentId: 'agent-1',
+ persona: null,
parentId: null,
title: 'Triage inbox',
goal: 'Archive the newsletters',
@@ -118,6 +121,7 @@ describe('handleActivityTimelineRoute', () => {
const task = (id: string, parentId: string | null, createdAt: string) => ({
id,
agentId: 'agent-1',
+ personaId: null,
parentId,
title: 'Draft the pricing notes',
goal: 'Write up the pricing review',
@@ -205,6 +209,7 @@ describe('handleListActivityRoute', () => {
{
id: 'task-1',
agentId: 'agent-1',
+ persona: null,
parentId: 'task-0',
title: 'Triage inbox',
goal: 'Archive the newsletters',
@@ -262,6 +267,7 @@ describe('handleListActivityRoute', () => {
{
id: 'task-1',
agentId: 'agent-1',
+ persona: null,
parentId: 'task-0',
title: 'Triage inbox',
goal: 'Archive the newsletters',
@@ -280,6 +286,7 @@ describe('handleTaskRunRoute', () => {
const task = (id: string, start: string, finish: string | null, parentId: string | null = null) => ({
id,
agentId: 'agent-1',
+ personaId: null,
parentId,
title: `Task ${id}`,
goal: 'List the week’s events and what to do first',
diff --git a/packages/api/src/activity/route.ts b/packages/api/src/activity/route.ts
index 67a79445..f926c0eb 100644
--- a/packages/api/src/activity/route.ts
+++ b/packages/api/src/activity/route.ts
@@ -4,6 +4,8 @@ import {z} from 'zod';
import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils';
import {type AuditEntryRow, listAuditEntries, listLastTaskEntries, listTaskEntries} from '@proxy/db/audit';
import {listMemberNames} from '@proxy/db/organization';
+import type {Look} from '@proxy/utils/harness';
+import {listPersonasById} from '@proxy/db/harness';
import {type AgentTaskRow, listAgentTasks, listAgentTasksBetween, listSubTasks} from '@proxy/db/task';
import type {Viewer} from '@proxy/db/viewer';
@@ -27,8 +29,8 @@ export type AuditEntryResponse = {
// What went wrong, for a failed request: the api's kind of error, with the provider's HTTP status
// where it answered with one, such as `provider_unreachable 429`; none for every other outcome.
error: string | null;
- // The web app's agent pages, or the MCP server.
- via: 'web' | 'mcp';
+ // The web app's agent pages, the MCP server, or the built-in agent's own loop.
+ via: 'web' | 'mcp' | 'harness';
// The agent's task the request was made for, among the answer's `tasks`; none for entries from
// before tasks, and for what Personal Agent Proxy did itself.
taskId: string | null;
@@ -46,12 +48,15 @@ export type AuditEntryResponse = {
export type ActivityTaskResponse = {
id: string;
agentId: string;
+ // The persona of the Chat page a sub-agent ran it as, with its head; null for every other task,
+ // and for one whose persona is gone.
+ persona: {id: string; name: string; look: Look; color: string} | null;
// The agent's task it is a sub-task of, where the agent handed part of that task to a sub-agent;
// null for a task started on its own. The parent may be missing from the answer.
parentId: string | null;
title: string;
goal: string;
- via: 'web' | 'mcp';
+ via: 'web' | 'mcp' | 'harness';
startedAt: string;
// Null while the agent hasn't called it finished, abandoned (@proxy/utils/tasks) or not.
finishedAt: string | null;
@@ -62,8 +67,22 @@ export type ActivityTaskResponse = {
lastStartedAt: string;
};
-function toActivityTask({createdAt, finishedAt, lastStartedAt, ...task}: AgentTaskRow): ActivityTaskResponse {
- return {...task, startedAt: createdAt.toISOString(), finishedAt: finishedAt?.toISOString() ?? null, lastStartedAt: lastStartedAt.toISOString()};
+// The tasks as the web app reads them, each with the persona it was run as, where it was.
+function toActivityTasks(viewer: Viewer, rows: AgentTaskRow[]): Promise> {
+ return Do(async ($) => {
+ const personaIds = new Set(rows.flatMap((row) => (row.personaId === null ? [] : [row.personaId])));
+ const personas = $(await listPersonasById(viewer.orgId, [...personaIds]));
+ return rows.map(({createdAt, finishedAt, lastStartedAt, personaId, ...task}) => {
+ const persona = personas.find((candidate) => candidate.id === personaId);
+ return {
+ ...task,
+ persona: persona ? {id: persona.id, name: persona.name, look: persona.look, color: persona.color} : null,
+ startedAt: createdAt.toISOString(),
+ finishedAt: finishedAt?.toISOString() ?? null,
+ lastStartedAt: lastStartedAt.toISOString(),
+ };
+ });
+ });
}
// `?agentId=proxy` is what Personal Agent Proxy did itself.
@@ -94,7 +113,7 @@ export function handleListActivityRoute(request: AuthenticatedRequest): Promise<
const rows = $(await listAuditEntries(viewer, filter, MAX_ENTRIES));
const taskIds = new Set([...rows.flatMap((row) => (row.taskId === null ? [] : [row.taskId])), ...(taskId === undefined ? [] : [taskId])]);
const tasks = $(await listAgentTasks(viewer, [...taskIds]));
- return {entries: $(await toEntries(viewer, rows)), tasks: tasks.map(toActivityTask)};
+ return {entries: $(await toEntries(viewer, rows)), tasks: $(await toActivityTasks(viewer, tasks))};
});
}
@@ -151,8 +170,8 @@ export function handleTaskRunRoute(request: AuthenticatedRequest): Promise> {
return Do(async ($) => {
const product = $(requirePresent(findAgentProvider(providerId), ApiErr.parseError(`Unknown agent ${providerId}`)));
+ // The built-in agents are made on the Chat page (harness/built-in-agent.ts), never handed out.
+ if (product.connects === 'built-in') {
+ return $(Err(ApiErr.parseError(`${product.name} is not an agent a login is made for`)));
+ }
const provider = $(requirePresent(findAgentProvider(loginProviderIdFor(product)), ApiErr.parseError(`Unknown login provider for ${providerId}`)));
const name = $(nameFor(provider, options.name));
const password = generatePassword();
diff --git a/packages/api/src/agents/route.test.ts b/packages/api/src/agents/route.test.ts
index 60ccf77f..2d7e1a7d 100644
--- a/packages/api/src/agents/route.test.ts
+++ b/packages/api/src/agents/route.test.ts
@@ -215,6 +215,17 @@ describe('handleSetAgentRevokedRoute', () => {
expect(updateAgent.mock.calls[0]?.[2].revokedAt).toBeInstanceOf(Date);
expect(updateAgent.mock.calls[1]?.[2]).toEqual({revokedAt: null});
});
+
+ test('the organization’s built-in login, which every persona acts through, is neither revoked nor deleted, nor given a password to sign in with', async () => {
+ const {handleDeleteAgentRoute, handleResetAgentPasswordRoute, handleSetAgentRevokedRoute} = await import('./route');
+ getAgent.mockResolvedValue(Ok({...agentRow, providerId: 'proxy', ownerUserId: null, createdById: null}));
+
+ expect((await handleSetAgentRevokedRoute(makeRequest({agentId: 'agent-1'}, {revoked: true}))).unwrapErr().kind).toBe('forbidden');
+ expect((await handleDeleteAgentRoute(makeRequest({agentId: 'agent-1'}))).unwrapErr().kind).toBe('forbidden');
+ expect((await handleResetAgentPasswordRoute(makeRequest({agentId: 'agent-1'}))).unwrapErr().kind).toBe('forbidden');
+ expect(updateAgent).not.toHaveBeenCalled();
+ expect(deleteAgent).not.toHaveBeenCalled();
+ });
});
describe('handleSetAgentGrantsRoute', () => {
diff --git a/packages/api/src/agents/route.ts b/packages/api/src/agents/route.ts
index 6a83c4b2..809c4c2d 100644
--- a/packages/api/src/agents/route.ts
+++ b/packages/api/src/agents/route.ts
@@ -1,4 +1,4 @@
-import {Err, type Result} from 'ts-results-es';
+import {Err, Ok, type Result} from 'ts-results-es';
import {z} from 'zod';
import {ApiErr, type ApiError, Do, parseSchema, requirePresent} from '@proxy/utils';
@@ -7,6 +7,7 @@ import {getConnectionWithCredential} from '@proxy/db/connection';
import {deleteOAuthGrant, listAgentOAuthGrants} from '@proxy/db/oauth';
import {listMemberNames} from '@proxy/db/organization';
import type {MemberViewer} from '@proxy/db/viewer';
+import {findAgentProvider} from '@proxy/integrations';
import {refuseUnavailable, unavailableActions} from '../connections/unavailable';
import type {AuthenticatedRequest} from '../server/middleware/require-auth';
@@ -83,6 +84,7 @@ export function handleResetAgentPasswordRoute(request: AuthenticatedRequest): Pr
return Do(async ($) => {
const viewer = $(await requireViewer(request));
const agent = $(await requireManagedAgent(viewer, agentIdOf(request)));
+ $(requireNotBuiltIn(agent));
const password = generatePassword();
const updated = $(await updateAgent(viewer.orgId, agent.id, {passwordHash: await Bun.password.hash(password)}));
if (!updated) {
@@ -111,12 +113,23 @@ export function handleRenameAgentRoute(request: AuthenticatedRequest): Promise {
+ if (findAgentProvider(agent.providerId)?.connects === 'built-in') {
+ return Err(ApiErr.forbidden());
+ }
+ return Ok(undefined);
+}
+
/** A revoked agent can't sign in, and is shown nothing it could reach before. */
export function handleSetAgentRevokedRoute(request: AuthenticatedRequest): Promise> {
return Do(async ($) => {
const {revoked} = $(parseSchema(setRevokedBodySchema, request.body));
const viewer = $(await requireViewer(request));
const agent = $(await requireManagedAgent(viewer, agentIdOf(request)));
+ $(requireNotBuiltIn(agent));
const updated = $(await updateAgent(viewer.orgId, agent.id, {revokedAt: revoked ? new Date() : null}));
if (!updated) {
return $(Err(ApiErr.notFound('agent', agent.id)));
@@ -129,6 +142,7 @@ export function handleDeleteAgentRoute(request: AuthenticatedRequest): Promise {
const viewer = $(await requireViewer(request));
const agent = $(await requireManagedAgent(viewer, agentIdOf(request)));
+ $(requireNotBuiltIn(agent));
const deleted = $(await deleteAgent(viewer.orgId, agent.id));
if (!deleted) {
return $(Err(ApiErr.notFound('agent', agent.id)));
diff --git a/packages/api/src/harness/built-in-agent.ts b/packages/api/src/harness/built-in-agent.ts
new file mode 100644
index 00000000..6744477a
--- /dev/null
+++ b/packages/api/src/harness/built-in-agent.ts
@@ -0,0 +1,24 @@
+import {randomBytes} from 'node:crypto';
+
+import type {Result} from 'ts-results-es';
+
+import type {ApiError} from '@proxy/utils';
+import {type AgentRow, ensureSharedAgent} from '@proxy/db/agent';
+import {BUILT_IN_AGENT_PROVIDER} from '@proxy/integrations';
+
+/**
+ * The organization's one built-in agent login, which every persona of the Chat page acts through,
+ * named Chat for that: an Agent like any other, shared with every member, with grants and an
+ * activity log of its own, so what the personas may do on the shared connections is set where it
+ * is set for Claude. Made the first time Chat is used, once however many open it at once, by
+ * nobody in particular, so only the organization's owner changes its grants. It has a password
+ * nobody knows, since it never signs in: its loop runs in the api, and in each member's
+ * conversation it acts as them.
+ */
+export function ensureBuiltInAgent(orgId: string): Promise> {
+ return ensureSharedAgent(orgId, BUILT_IN_AGENT_PROVIDER.id, async () => ({
+ name: 'Chat',
+ username: `proxy-${randomBytes(6).toString('hex')}`,
+ passwordHash: await Bun.password.hash(randomBytes(32).toString('base64url')),
+ }));
+}
diff --git a/packages/api/src/harness/context.test.ts b/packages/api/src/harness/context.test.ts
new file mode 100644
index 00000000..1890696b
--- /dev/null
+++ b/packages/api/src/harness/context.test.ts
@@ -0,0 +1,208 @@
+import {describe, expect, test} from 'bun:test';
+
+import type {EntryRow} from '@proxy/db/harness';
+
+import {COMPACT_AT_TOKENS, compactionCut, toMessages, toTranscript} from './context';
+
+const now = new Date('2026-10-21T10:00:00Z');
+
+type Kind = EntryRow['kind'];
+
+function entry(seq: number, kind: Kind, text: string, extra: Partial> = {}): EntryRow {
+ return {
+ id: `e${seq}`,
+ seq,
+ threadId: 'thread-1',
+ kind,
+ pending: false,
+ text,
+ toolCalls: extra.toolCalls ?? null,
+ callId: extra.callId ?? null,
+ isError: false,
+ inputTokens: extra.inputTokens ?? null,
+ outputTokens: null,
+ coversSeq: extra.coversSeq ?? null,
+ system: null,
+ jobId: null,
+ createdAt: now,
+ };
+}
+
+const read = (seq: number, id: string, inputTokens = 10) => entry(seq, 'assistant', '', {toolCalls: [{id, name: 'read', arguments: '{}'}], inputTokens});
+const result = (seq: number, id: string, text = 'rows') => entry(seq, 'tool_result', text, {callId: id});
+
+// Three turns of the person, each answered after a read.
+const long = [
+ entry(1, 'person', 'One'),
+ read(2, 'c1'),
+ result(3, 'c1', 'rows one'),
+ entry(4, 'assistant', 'Done one', {inputTokens: 10}),
+ entry(5, 'person', 'Two'),
+ read(6, 'c2'),
+ result(7, 'c2', 'rows two'),
+ entry(8, 'assistant', 'Done two', {inputTokens: 10}),
+ entry(9, 'person', 'Three'),
+ read(10, 'c3', COMPACT_AT_TOKENS),
+ result(11, 'c3', 'rows three'),
+];
+
+describe('toMessages', () => {
+ test('gives the log in the chat form, as it is, where it has no summary', () => {
+ expect(toMessages(long.slice(0, 4), {clearOld: true})).toEqual([
+ {role: 'user', content: 'One'},
+ {role: 'assistant', content: null, tool_calls: [{id: 'c1', type: 'function', function: {name: 'read', arguments: '{}'}}]},
+ {role: 'tool', tool_call_id: 'c1', content: 'rows one'},
+ {role: 'assistant', content: 'Done one'},
+ ]);
+ });
+
+ test('reads the last summary in place of what it covers, and clears the results of turns before the current one', () => {
+ const log = [...long, entry(12, 'summary', 'The person asked for one.', {coversSeq: 4})];
+
+ const messages = toMessages(log, {clearOld: true});
+
+ expect(messages[0]).toEqual({role: 'user', content: expect.stringContaining('The person asked for one.')});
+ expect(String(messages[0]?.content)).toContain('The person did not write this');
+ expect(messages.slice(1)).toEqual([
+ {role: 'user', content: 'Two'},
+ {role: 'assistant', content: null, tool_calls: [{id: 'c2', type: 'function', function: {name: 'read', arguments: '{}'}}]},
+ {role: 'tool', tool_call_id: 'c2', content: expect.stringContaining('cleared')},
+ {role: 'assistant', content: 'Done two'},
+ {role: 'user', content: 'Three'},
+ {role: 'assistant', content: null, tool_calls: [{id: 'c3', type: 'function', function: {name: 'read', arguments: '{}'}}]},
+ {role: 'tool', tool_call_id: 'c3', content: 'rows three'},
+ ]);
+ expect(toMessages(log, {clearOld: false})[3]).toEqual({role: 'tool', tool_call_id: 'c2', content: 'rows two'});
+ });
+
+ test('reads a sub-agent’s report as a message, led in with whose it is, and clears old ones too', () => {
+ const delegated = entry(1, 'person', 'Find my invoices');
+ const call = entry(2, 'assistant', 'Looking.', {toolCalls: [{id: 'd1', name: 'delegate', arguments: '{"title":"Find the invoices","brief":"Search the mailbox."}'}], inputTokens: 10});
+ const started = entry(3, 'tool_result', 'Started.', {callId: 'd1'});
+ const report = entry(4, 'event', 'Three invoices from Dana.', {callId: 'd1'});
+ const failed = {...entry(5, 'event', 'The sub-agent failed: boom', {callId: 'd1'}), isError: true};
+
+ const messages = toMessages([delegated, call, started, report, failed], {clearOld: true});
+
+ expect(messages[3]).toEqual({role: 'user', content: expect.stringContaining('The report of the sub-agent “Find the invoices”')});
+ expect(String(messages[3]?.content)).toContain('Three invoices from Dana.');
+ expect(messages[4]).toEqual({role: 'user', content: expect.stringContaining('What the sub-agent “Find the invoices” failed with')});
+
+ const later = [
+ delegated,
+ call,
+ started,
+ report,
+ entry(6, 'assistant', 'Three invoices.', {inputTokens: 10}),
+ entry(7, 'person', 'Thanks'),
+ entry(8, 'summary', 'Invoices were found.', {coversSeq: 1}),
+ ];
+ const cleared = toMessages(later, {clearOld: true});
+ expect(String(cleared[3]?.content)).toContain('cleared');
+ expect(String(cleared[3]?.content)).not.toContain('Dana');
+ });
+
+ test('leads a report in by the call where the delegation’s arguments were not JSON', () => {
+ const call = entry(2, 'assistant', '', {toolCalls: [{id: 'd1', name: 'delegate', arguments: '{"title":'}], inputTokens: 10});
+ const messages = toMessages([entry(1, 'person', 'Go'), call, entry(3, 'tool_result', 'Started.', {callId: 'd1'}), entry(4, 'event', 'Done.', {callId: 'd1'})], {clearOld: true});
+ expect(messages[3]).toEqual({role: 'user', content: expect.stringContaining('The report of your call d1')});
+ });
+
+ test('with summaries on summaries, only the last counts', () => {
+ const log = [...long, entry(12, 'summary', 'First.', {coversSeq: 4}), entry(13, 'summary', 'Second.', {coversSeq: 8})];
+
+ const messages = toMessages(log, {clearOld: true});
+
+ expect(String(messages[0]?.content)).toContain('Second.');
+ expect(messages).toHaveLength(4);
+ expect(messages[1]).toEqual({role: 'user', content: 'Three'});
+ });
+});
+
+describe('compactionCut', () => {
+ test('is nothing while the last request was under the limit', () => {
+ expect(compactionCut(long.slice(0, 8))).toBeNull();
+ });
+
+ test('covers the log before the person’s last two turns once a request was over the limit', () => {
+ expect(compactionCut(long)).toBe(4);
+ });
+
+ test('is nothing where the last turns are the whole log', () => {
+ expect(compactionCut([entry(1, 'person', 'One'), read(2, 'c1', COMPACT_AT_TOKENS), result(3, 'c1')])).toBeNull();
+ });
+
+ test('moves back so that no call is left without its result', () => {
+ // The person spoke while a read was still running: its result is after their message.
+ const log = [
+ entry(1, 'person', 'One'),
+ entry(2, 'assistant', 'Ok', {inputTokens: 10}),
+ entry(3, 'person', 'Two'),
+ read(4, 'c1'),
+ entry(5, 'person', 'Three'),
+ result(6, 'c1'),
+ entry(7, 'assistant', 'Done', {inputTokens: 10}),
+ entry(8, 'person', 'Four'),
+ read(9, 'c2', COMPACT_AT_TOKENS),
+ result(10, 'c2'),
+ ];
+ expect(compactionCut(log)).toBe(3);
+ });
+
+ test('is nothing again right after a summary, until a request since was over the limit', () => {
+ const summarized = [...long, entry(12, 'summary', 'So far.', {coversSeq: 4})];
+ expect(compactionCut(summarized)).toBeNull();
+
+ const grown = [
+ ...summarized,
+ entry(13, 'assistant', 'Done three', {inputTokens: 10}),
+ entry(14, 'person', 'Four'),
+ entry(15, 'assistant', 'Ok', {inputTokens: COMPACT_AT_TOKENS}),
+ entry(16, 'person', 'Five'),
+ ];
+ expect(compactionCut(grown)).toBe(13);
+ });
+
+ test('covers a log with one turn of the person, a sub-agent’s brief, before the model’s last three steps', () => {
+ const log = [
+ entry(1, 'person', 'Search the mailbox.'),
+ read(2, 'c1'),
+ result(3, 'c1'),
+ read(4, 'c2'),
+ result(5, 'c2'),
+ read(6, 'c3'),
+ result(7, 'c3'),
+ read(8, 'c4', COMPACT_AT_TOKENS),
+ result(9, 'c4'),
+ ];
+ expect(compactionCut(log)).toBe(3);
+
+ const summarized = [...log, entry(10, 'summary', 'Two reads so far.', {coversSeq: 3}), read(11, 'c5', COMPACT_AT_TOKENS), result(12, 'c5')];
+ expect(compactionCut(summarized)).toBe(5);
+ });
+});
+
+describe('toTranscript', () => {
+ test('writes the log out as text from its last summary on, the calls, their answers and the reports with whose they are', () => {
+ const delegated = entry(1, 'person', 'Find my invoices');
+ const call = entry(2, 'assistant', 'Looking.', {toolCalls: [{id: 'd1', name: 'delegate', arguments: '{"title":"Find the invoices","brief":"Search the mailbox."}'}], inputTokens: 10});
+ const started = entry(3, 'tool_result', 'Started.', {callId: 'd1'});
+ const report = entry(4, 'event', 'Three invoices from Dana.', {callId: 'd1'});
+ const failed = {...entry(5, 'tool_result', 'No such tool.', {callId: 'x9'}), isError: true};
+
+ expect(toTranscript([delegated, call, started, report, failed])).toBe(
+ [
+ 'Person:\nFind my invoices',
+ 'Agent:\nLooking.\nAgent called delegate with {"title":"Find the invoices","brief":"Search the mailbox."}',
+ 'delegate answered:\nStarted.',
+ 'The report of the sub-agent “Find the invoices”. The person did not write this; it is for you to act on or tell them of.\nThree invoices from Dana.',
+ 'The call answered with an error:\nNo such tool.',
+ ].join('\n\n'),
+ );
+
+ const transcript = toTranscript([...long, entry(12, 'summary', 'The person asked for one.', {coversSeq: 4})]);
+ expect(transcript.startsWith('What came before in this conversation')).toBe(true);
+ expect(transcript).toContain('The person asked for one.\n\nPerson:\nTwo');
+ expect(transcript).not.toContain('rows one');
+ });
+});
diff --git a/packages/api/src/harness/context.ts b/packages/api/src/harness/context.ts
new file mode 100644
index 00000000..cc732a69
--- /dev/null
+++ b/packages/api/src/harness/context.ts
@@ -0,0 +1,142 @@
+import {callArguments, DELEGATE_TOOL, delegateArgs, pendingToolCalls, type ToolCall} from '@proxy/utils/harness';
+import type {EntryRow} from '@proxy/db/harness';
+
+import type {ModelMessage} from './model';
+
+/**
+ * The log as the model reads it. An event, a sub-agent's report, is read as a message, since it
+ * came on its own, after the call it answers was answered as started. A log that has grown long
+ * is summarized: a summary entry stands for every entry up to the one it covers, and the model
+ * reads the summary, then what came since. Of that, the person's last turns are kept as said,
+ * except what tools answered and what events brought in the turns before the current one, which
+ * is cleared: it is the bulk, and the model can ask again. A log with one turn of the person, a
+ * sub-agent's brief and the work on it, keeps its last steps instead. The log to be summarized
+ * is given to the model as plain text, the calls and their answers written out.
+ */
+
+/** Once a request to the model was this many tokens, the log is summarized before the next. */
+export const COMPACT_AT_TOKENS = 80_000;
+
+// How many of the person's turns are kept behind a summary: the current one and the one before.
+const KEEP_TURNS = 2;
+
+// How many of the model's steps are kept behind a summary where the log has fewer turns of the
+// person than that: a sub-agent's, one brief and the calls made on it.
+const KEEP_STEPS = 3;
+
+const SUMMARY_LEAD = 'What came before in this conversation, summarized so that it fits. The person did not write this; take it as what happened.';
+
+const CLEARED = '(This was cleared from the conversation to save room. Ask again if you need it.)';
+
+// The call an entry answers, by the id the model gave it, found in the log.
+function callOf(entries: EntryRow[], callId: string | null): ToolCall | undefined {
+ return entries.flatMap((entry) => entry.toolCalls ?? []).find((candidate) => candidate.id === callId);
+}
+
+// How an event is led in: whose report it is, by the call it answers, found in the log.
+function eventLead(entries: EntryRow[], event: EntryRow): string {
+ const call = callOf(entries, event.callId);
+ const parsed = call?.name === DELEGATE_TOOL ? delegateArgs(callArguments(call)) : null;
+ const who = parsed ? `the sub-agent “${parsed.title}”` : `your call ${event.callId ?? ''}`;
+ if (event.isError) {
+ return `What ${who} failed with. The person did not write this.`;
+ }
+ return `The report of ${who}. The person did not write this; it is for you to act on or tell them of.`;
+}
+
+function toMessage(entries: EntryRow[], entry: EntryRow): ModelMessage {
+ if (entry.kind === 'person') {
+ return {role: 'user', content: entry.text};
+ }
+ if (entry.kind === 'tool_result') {
+ return {role: 'tool', tool_call_id: entry.callId ?? '', content: entry.text};
+ }
+ if (entry.kind === 'event') {
+ return {role: 'user', content: `${eventLead(entries, entry)}\n\n${entry.text}`};
+ }
+ if (entry.kind === 'summary') {
+ return {role: 'user', content: `${SUMMARY_LEAD}\n\n${entry.text}`};
+ }
+ const calls = entry.toolCalls ?? [];
+ return {
+ role: 'assistant',
+ content: entry.text === '' && calls.length > 0 ? null : entry.text,
+ ...(calls.length > 0 ? {tool_calls: calls.map((call) => ({id: call.id, type: 'function' as const, function: {name: call.name, arguments: call.arguments}}))} : {}),
+ };
+}
+
+// The log from its last summary on: the summary, then the entries it doesn't cover, no other
+// summary among them.
+function sinceSummary(entries: EntryRow[]): {summary: EntryRow | null; tail: EntryRow[]} {
+ const summary = entries.findLast((entry) => entry.kind === 'summary') ?? null;
+ const covered = summary?.coversSeq ?? -1;
+ return {summary, tail: entries.filter((entry) => entry.kind !== 'summary' && entry.seq > covered)};
+}
+
+/**
+ * The log in the chat form, from its last summary on. With `clearOld`, what tools answered and
+ * what events brought before the person's current turn is cleared, once there is a summary: that
+ * is how the model reads a log, where a summary is made from the log as it is.
+ */
+export function toMessages(entries: EntryRow[], {clearOld}: {clearOld: boolean}): ModelMessage[] {
+ const {summary, tail} = sinceSummary(entries);
+ if (!summary) {
+ return tail.map((entry) => toMessage(entries, entry));
+ }
+ const current = clearOld ? tail.findLastIndex((entry) => entry.kind === 'person') : -1;
+ const bulky = (entry: EntryRow) => entry.kind === 'tool_result' || entry.kind === 'event';
+ return [toMessage(entries, summary), ...tail.map((entry, index) => (bulky(entry) && index < current ? toMessage(entries, {...entry, text: CLEARED}) : toMessage(entries, entry)))];
+}
+
+// An entry as the transcript has it.
+function toLine(entries: EntryRow[], entry: EntryRow): string {
+ if (entry.kind === 'person') {
+ return `Person:\n${entry.text}`;
+ }
+ if (entry.kind === 'tool_result') {
+ return `${callOf(entries, entry.callId)?.name ?? 'The call'} answered${entry.isError ? ' with an error' : ''}:\n${entry.text}`;
+ }
+ if (entry.kind === 'event') {
+ return `${eventLead(entries, entry)}\n${entry.text}`;
+ }
+ if (entry.kind === 'summary') {
+ return `${SUMMARY_LEAD}\n${entry.text}`;
+ }
+ const calls = (entry.toolCalls ?? []).map((call) => `Agent called ${call.name} with ${call.arguments}`);
+ return [...(entry.text === '' ? [] : [`Agent:\n${entry.text}`]), ...calls].join('\n');
+}
+
+/**
+ * The log from its last summary on, as plain text, for the model to summarize: the calls and
+ * what they answered written out, since a request that carries them as such has to carry the
+ * tools too, which a summary needs none of.
+ */
+export function toTranscript(entries: EntryRow[]): string {
+ const {summary, tail} = sinceSummary(entries);
+ return [...(summary ? [summary] : []), ...tail].map((entry) => toLine(entries, entry)).join('\n\n');
+}
+
+/**
+ * How far a summary would reach, as the count of entries from the start it would cover: the log
+ * before the person's last turns, or, where the person spoke but once, before the model's last
+ * steps, cut so that no call is left without its result. Null when the log needs none: the last
+ * request to the model was under the limit, or nothing since the last summary would be covered.
+ */
+export function compactionCut(entries: EntryRow[]): number | null {
+ const {summary, tail} = sinceSummary(entries);
+ const lastAnswer = tail.findLast((entry) => entry.kind === 'assistant');
+ if ((lastAnswer?.inputTokens ?? 0) < COMPACT_AT_TOKENS) {
+ return null;
+ }
+ const turns = entries.flatMap((entry, index) => (entry.kind === 'person' ? [index] : []));
+ const steps = entries.flatMap((entry, index) => (entry.kind === 'assistant' ? [index] : []));
+ let cut = turns.at(-KEEP_TURNS) ?? steps.at(-KEEP_STEPS) ?? 0;
+ while (cut > 0 && pendingToolCalls(entries.slice(0, cut)).length > 0) {
+ cut -= 1;
+ }
+ const covered = summary?.coversSeq ?? -1;
+ if (!entries.slice(0, cut).some((entry) => entry.kind !== 'summary' && entry.seq > covered)) {
+ return null;
+ }
+ return cut;
+}
diff --git a/packages/api/src/harness/inference.test.ts b/packages/api/src/harness/inference.test.ts
new file mode 100644
index 00000000..2bf3495c
--- /dev/null
+++ b/packages/api/src/harness/inference.test.ts
@@ -0,0 +1,302 @@
+import {beforeEach, describe, expect, mock, test} from 'bun:test';
+import {Err, Ok} from 'ts-results-es';
+
+import {ApiErr} from '@proxy/utils';
+
+const mockEnv: {OPENROUTER_KEY?: string} = {};
+mock.module('../utils/env', () => ({env: mockEnv}));
+
+const getJobThread = mock();
+const listEntries = mock();
+const finishJob = mock(async () => Ok(true));
+const completeInference = mock(async () => Ok(true));
+const requeueJob = mock(async () => Ok(true));
+const appendSummary = mock(async () => Ok(true));
+const getThreadAgent = mock(async () => Ok({agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'proxy', name: 'Finance', orgSlug: 'acme', experiments: []}));
+mock.module('@proxy/db/harness', () => ({getJobThread, listEntries, finishJob, completeInference, requeueJob, appendSummary, getThreadAgent}));
+
+const agentInstructions = mock(async (_agent: unknown, flow: string) => Ok(`You are signed in.\n\n${flow}`));
+mock.module('../mcp/tools', () => ({agentInstructions, MCP_TOOLS: []}));
+
+const askModel = mock();
+mock.module('./model', () => ({askModel}));
+
+const delegate = {name: 'delegate', description: 'Delegates', parameters: {}};
+const read = {name: 'read', description: 'Reads', parameters: {}};
+mock.module('./tool-surface', () => ({toolsFor: (candidate: {parentThreadId: string | null}) => (candidate.parentThreadId === null ? [delegate] : [read])}));
+
+const now = new Date('2026-10-21T10:00:00Z');
+// The member's conversation, and a sub-agent started from it.
+const thread = {id: 'thread-1', orgId: 'org-1', agentId: 'agent-1', userId: 'user-1', parentThreadId: null, parentCallId: null, title: null, taskId: null, finishedAt: null, createdAt: now};
+const sub = {...thread, id: 'thread-2', parentThreadId: 'thread-1', parentCallId: 'c0', title: 'Find the invoices', taskId: 'task-2'};
+const job = {id: 'job-1', threadId: 'thread-1', kind: 'inference' as const, status: 'running' as const, entryId: null, callId: null, runAt: now, claimedBy: 'w1', attempts: 1};
+
+let seq = 0;
+
+function entry(
+ kind: 'person' | 'assistant' | 'tool_result' | 'event' | 'summary',
+ text: string,
+ extra: Partial<{toolCalls: Array<{id: string; name: string; arguments: string}>; callId: string; inputTokens: number; coversSeq: number}> = {},
+) {
+ seq += 1;
+ return {
+ id: `e-${text}`,
+ seq,
+ threadId: 'thread-1',
+ kind,
+ pending: false,
+ text,
+ toolCalls: extra.toolCalls ?? null,
+ callId: extra.callId ?? null,
+ isError: false,
+ inputTokens: extra.inputTokens ?? null,
+ outputTokens: null,
+ coversSeq: extra.coversSeq ?? null,
+ system: null,
+ jobId: null,
+ createdAt: now,
+ };
+}
+
+beforeEach(() => {
+ mock.clearAllMocks();
+ mockEnv.OPENROUTER_KEY = 'sk-test';
+ getJobThread.mockResolvedValue(Ok(thread));
+ listEntries.mockResolvedValue(Ok([entry('person', 'Hi')]));
+ askModel.mockResolvedValue(Ok({text: 'Hello', toolCalls: [], inputTokens: 10, outputTokens: 2}));
+});
+
+describe('runInference', () => {
+ test('runs the model on the conversation, as the chat form, with the one tool to delegate, and answers a delegation as started', async () => {
+ const {runInference} = await import('./inference');
+ const log = [
+ entry('person', 'Find my invoices'),
+ entry('assistant', 'Looking.', {toolCalls: [{id: 'c1', name: 'delegate', arguments: '{"title":"Find the invoices","brief":"Search the mailbox."}'}]}),
+ entry('tool_result', 'Started the sub-agent “Find the invoices”.', {callId: 'c1'}),
+ entry('event', 'Three invoices from Dana.', {callId: 'c1'}),
+ ];
+ listEntries.mockResolvedValue(Ok(log));
+ askModel.mockResolvedValue(
+ Ok({
+ text: 'Three invoices; I’ll have them filed.',
+ toolCalls: [{id: 'c2', name: 'delegate', arguments: '{"title":"File the invoices","brief":"Save the three invoices from Dana to the vault."}'}],
+ inputTokens: 10,
+ outputTokens: 2,
+ }),
+ );
+
+ const result = await runInference(job, 'w1', now);
+
+ expect(result.isOk()).toBe(true);
+ // The agent acts as the member whose conversation it is.
+ expect(getThreadAgent).toHaveBeenCalledWith(thread);
+ expect(agentInstructions).toHaveBeenCalledWith(expect.objectContaining({agentId: 'agent-1', ownerUserId: 'user-1'}), expect.stringContaining('You are talking with the person directly'));
+ const request = askModel.mock.calls[0]?.[0] as {messages: Array>; tools: unknown[]};
+ expect(request.messages[0]).toMatchObject({role: 'system'});
+ expect(String(request.messages[0]?.content)).toContain('The time now is 2026-10-21T10:00:00.000Z');
+ expect(request.messages.slice(1)).toEqual([
+ {role: 'user', content: 'Find my invoices'},
+ {role: 'assistant', content: 'Looking.', tool_calls: [{id: 'c1', type: 'function', function: {name: 'delegate', arguments: '{"title":"Find the invoices","brief":"Search the mailbox."}'}}]},
+ {role: 'tool', tool_call_id: 'c1', content: 'Started the sub-agent “Find the invoices”.'},
+ {role: 'user', content: expect.stringContaining('Three invoices from Dana.')},
+ ]);
+ expect(request.tools).toEqual([delegate]);
+ expect(completeInference).toHaveBeenCalledWith(
+ 'job-1',
+ 'w1',
+ 'thread-1',
+ {
+ text: 'Three invoices; I’ll have them filed.',
+ toolCalls: [{id: 'c2', name: 'delegate', arguments: '{"title":"File the invoices","brief":"Save the three invoices from Dana to the vault."}'}],
+ inputTokens: 10,
+ outputTokens: 2,
+ system: expect.stringContaining('You are signed in.'),
+ },
+ {c2: {runAt: now, placeholder: expect.stringContaining('Started the sub-agent “File the invoices”')}},
+ );
+ });
+
+ test('runs a sub-agent on its log with its own tools, a sleep’s job due when it ends and a read’s now', async () => {
+ const {runInference} = await import('./inference');
+ getJobThread.mockResolvedValue(Ok(sub));
+ listEntries.mockResolvedValue(
+ Ok([entry('person', 'Search the mailbox.'), entry('assistant', '', {toolCalls: [{id: 'c1', name: 'read', arguments: '{}'}]}), entry('tool_result', '{"records":[]}', {callId: 'c1'})]),
+ );
+ askModel.mockResolvedValue(
+ Ok({
+ text: '',
+ toolCalls: [
+ {id: 'c2', name: 'sleep_until', arguments: '{"seconds":60}'},
+ {id: 'c3', name: 'read', arguments: '{}'},
+ ],
+ inputTokens: 10,
+ outputTokens: 2,
+ }),
+ );
+
+ const result = await runInference({...job, threadId: 'thread-2'}, 'w1', now);
+
+ expect(result.isOk()).toBe(true);
+ expect(agentInstructions).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('You are a sub-agent'));
+ const request = askModel.mock.calls[0]?.[0] as {messages: Array>; tools: unknown[]};
+ expect(request.messages.slice(1)).toEqual([
+ {role: 'user', content: 'Search the mailbox.'},
+ {role: 'assistant', content: null, tool_calls: [{id: 'c1', type: 'function', function: {name: 'read', arguments: '{}'}}]},
+ {role: 'tool', tool_call_id: 'c1', content: '{"records":[]}'},
+ ]);
+ expect(request.tools).toEqual([read]);
+ expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-2', expect.objectContaining({toolCalls: [expect.objectContaining({id: 'c2'}), expect.objectContaining({id: 'c3'})]}), {
+ c2: {runAt: new Date('2026-10-21T10:01:00Z'), placeholder: null},
+ c3: {runAt: now, placeholder: null},
+ });
+ });
+
+ test('a delegation without a title and a brief is not answered as started, so its job answers with the error', async () => {
+ const {runInference} = await import('./inference');
+ askModel.mockResolvedValue(Ok({text: '', toolCalls: [{id: 'c2', name: 'delegate', arguments: '{"title":"Find"}'}], inputTokens: 10, outputTokens: 2}));
+
+ await runInference(job, 'w1', now);
+
+ expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.anything(), {c2: {runAt: now, placeholder: null}});
+ });
+
+ test('an answer of nothing, no words and no calls, is written as that said, so the person sees something and the log keeps no empty turn', async () => {
+ const {runInference} = await import('./inference');
+ askModel.mockResolvedValue(Ok({text: ' ', toolCalls: [], inputTokens: 10, outputTokens: 0}));
+
+ await runInference(job, 'w1', now);
+ expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({text: expect.stringContaining('nothing to say'), toolCalls: [], inputTokens: 10}), {});
+
+ getJobThread.mockResolvedValue(Ok(sub));
+ await runInference({...job, threadId: 'thread-2'}, 'w1', now);
+ expect(completeInference).toHaveBeenLastCalledWith('job-1', 'w1', 'thread-2', expect.objectContaining({text: expect.stringContaining('without a report')}), {});
+ });
+
+ test('a job that was no longer the worker’s by the time the answer came is a conflict', async () => {
+ const {runInference} = await import('./inference');
+ completeInference.mockResolvedValueOnce(Ok(false));
+ expect((await runInference(job, 'w1', now)).unwrapErr().kind).toBe('conflict');
+ });
+
+ test('a model that refused the request, or turned the key down, fails the job with why, and is not tried again', async () => {
+ const {runInference} = await import('./inference');
+ askModel.mockResolvedValue(Err(ApiErr.unavailable('OpenRouter turned the server’s key down (401): bad key')));
+
+ const result = await runInference(job, 'w1', now);
+
+ expect(result.unwrapErr()).toMatchObject({kind: 'unavailable', message: expect.stringContaining('401')});
+ expect(requeueJob).not.toHaveBeenCalled();
+ expect(completeInference).not.toHaveBeenCalled();
+ });
+
+ test('a sub-agent that is done, or a thread that is gone, ends the job', async () => {
+ const {runInference} = await import('./inference');
+ getJobThread.mockResolvedValue(Ok({...sub, finishedAt: now}));
+ await runInference({...job, threadId: 'thread-2'}, 'w1', now);
+ expect(finishJob).toHaveBeenCalledWith('job-1', 'w1');
+ expect(askModel).not.toHaveBeenCalled();
+ });
+
+ test('a thread that is gone ends the job', async () => {
+ const {runInference} = await import('./inference');
+ getJobThread.mockResolvedValue(Ok(null));
+ await runInference(job, 'w1', now);
+ expect(finishJob).toHaveBeenCalledWith('job-1', 'w1');
+ expect(askModel).not.toHaveBeenCalled();
+ });
+
+ test('without a key, the person is told the server is not set up', async () => {
+ const {runInference} = await import('./inference');
+ delete mockEnv.OPENROUTER_KEY;
+
+ await runInference(job, 'w1', now);
+
+ expect(askModel).not.toHaveBeenCalled();
+ expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({text: expect.stringContaining('OPENROUTER_KEY'), toolCalls: []}), {});
+ });
+
+ test('a model that can’t be reached is tried again later, and the person told after the last try', async () => {
+ const {runInference} = await import('./inference');
+ askModel.mockResolvedValue(Err(ApiErr.providerUnreachable(new Error('down'))));
+
+ await runInference(job, 'w1', now);
+ expect(requeueJob).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', new Date('2026-10-21T10:00:05Z'), expect.stringContaining('The model could not be reached'));
+ expect(completeInference).not.toHaveBeenCalled();
+
+ await runInference({...job, attempts: 3}, 'w1', now);
+ expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({text: expect.stringContaining('could not be reached')}), {});
+ });
+
+ test('a log grown long is summarized first, and the model reads the summary in place of what it covers', async () => {
+ const {runInference} = await import('./inference');
+ const {COMPACT_AT_TOKENS} = await import('./context');
+ const long = [
+ entry('person', 'One'),
+ entry('assistant', '', {toolCalls: [{id: 'c1', name: 'read', arguments: '{}'}], inputTokens: 10}),
+ entry('tool_result', 'rows one', {callId: 'c1'}),
+ entry('assistant', 'Done one', {inputTokens: 10}),
+ entry('person', 'Two'),
+ entry('assistant', 'Done two', {inputTokens: 10}),
+ entry('person', 'Three'),
+ entry('assistant', '', {toolCalls: [{id: 'c3', name: 'read', arguments: '{}'}], inputTokens: COMPACT_AT_TOKENS}),
+ entry('tool_result', 'rows three', {callId: 'c3'}),
+ ];
+ const summary = entry('summary', 'The person asked for one; it was done.', {coversSeq: long[3]?.seq});
+ listEntries.mockResolvedValueOnce(Ok(long)).mockResolvedValueOnce(Ok([...long, summary]));
+ askModel
+ .mockResolvedValueOnce(Ok({text: 'The person asked for one; it was done.', toolCalls: [], inputTokens: 90_000, outputTokens: 300}))
+ .mockResolvedValueOnce(Ok({text: 'Three is done too.', toolCalls: [], inputTokens: 2000, outputTokens: 20}));
+
+ const result = await runInference(job, 'w1', now);
+
+ expect(result.isOk()).toBe(true);
+ const asked = askModel.mock.calls.map((call) => call[0] as {messages: Array<{role: string; content: string | null}>; tools: unknown[]});
+ // The log to summarize goes as text, calls written out, since turns with calls need the tools sent along.
+ expect(asked[0]?.tools).toEqual([]);
+ expect(asked[0]?.messages[0]).toMatchObject({role: 'system', content: expect.stringContaining('summarizing a conversation')});
+ expect(asked[0]?.messages.slice(1)).toEqual([{role: 'user', content: 'Person:\nOne\n\nAgent called read with {}\n\nread answered:\nrows one\n\nAgent:\nDone one\n\nWrite the summary now.'}]);
+ expect(appendSummary).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', {text: 'The person asked for one; it was done.', toolCalls: [], inputTokens: 90_000, outputTokens: 300}, long[3]?.seq);
+ expect(asked[1]?.messages.slice(1).map((message) => message.content)).toEqual([expect.stringContaining('The person asked for one; it was done.'), 'Two', 'Done two', 'Three', null, 'rows three']);
+ expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({text: 'Three is done too.'}), {});
+ });
+
+ test('after thirty answers without the person, it stops and says so; a sub-agent reports that it did', async () => {
+ const {runInference} = await import('./inference');
+ const alone = [entry('person', 'Go'), ...Array.from({length: 30}, (_, i) => entry('assistant', `step ${i}`))];
+ listEntries.mockResolvedValue(Ok([...alone, entry('tool_result', 'x', {callId: 'none'})]));
+
+ await runInference(job, 'w1', now);
+ expect(askModel).not.toHaveBeenCalled();
+ expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({text: expect.stringContaining('30 steps without hearing from you')}), {});
+
+ getJobThread.mockResolvedValue(Ok(sub));
+ await runInference({...job, threadId: 'thread-2'}, 'w1', now);
+ expect(completeInference).toHaveBeenLastCalledWith('job-1', 'w1', 'thread-2', expect.objectContaining({text: expect.stringContaining('not a complete report')}), {});
+ });
+
+ test('the answer is written with the system prompt the model was sent; an answer given without the model, with none', async () => {
+ const {runInference} = await import('./inference');
+ listEntries.mockResolvedValue(Ok([entry('person', 'Hi')]));
+
+ await runInference(job, 'w1', now);
+ expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({text: 'Hello', system: expect.stringContaining('You are signed in.')}), {});
+
+ completeInference.mockClear();
+ listEntries.mockResolvedValue(Ok([entry('person', 'Go'), ...Array.from({length: 30}, (_, i) => entry('assistant', `step ${i}`)), entry('event', 'done', {callId: 'c1'})]));
+ await runInference(job, 'w1', now);
+ expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({system: null}), {});
+ });
+
+ test('a report that comes after it stopped is acted on, not answered with the stop again', async () => {
+ const {runInference} = await import('./inference');
+ const alone = [entry('person', 'Go'), ...Array.from({length: 30}, (_, i) => entry('assistant', `step ${i}`))];
+ const stopped = entry('assistant', 'I have taken 30 steps without hearing from you, so I have stopped. Tell me to go on if you want me to.');
+ listEntries.mockResolvedValue(Ok([...alone, stopped, entry('event', 'Found three rows.', {callId: 'c1'})]));
+ askModel.mockResolvedValue(Ok({text: 'There are three rows.', toolCalls: [], inputTokens: 10, outputTokens: 2}));
+
+ await runInference(job, 'w1', now);
+
+ expect(askModel).toHaveBeenCalledTimes(1);
+ expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({text: 'There are three rows.'}), {});
+ });
+});
diff --git a/packages/api/src/harness/inference.ts b/packages/api/src/harness/inference.ts
new file mode 100644
index 00000000..45b463f9
--- /dev/null
+++ b/packages/api/src/harness/inference.ts
@@ -0,0 +1,226 @@
+import {Err, type Result} from 'ts-results-es';
+
+import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils';
+import {callArguments, DELEGATE_TOOL, delegateArgs, SLEEP_TOOL, sleepUntil, type ToolCall} from '@proxy/utils/harness';
+import {
+ appendSummary,
+ type AssistantTurn,
+ type CallPlan,
+ completeInference,
+ type EntryRow,
+ finishJob,
+ getJobThread,
+ getThreadAgent,
+ type JobRow,
+ listEntries,
+ requeueJob,
+ type ThreadRow,
+} from '@proxy/db/harness';
+
+import {agentInstructions} from '../mcp/tools';
+import {log, serializeError} from '../observability/log';
+import {env} from '../utils/env';
+import {compactionCut, toMessages, toTranscript} from './context';
+import {askModel, type ModelTurn} from './model';
+import {toolsFor} from './tool-surface';
+
+/**
+ * An inference job: the model, run once on a thread's log, which is ready for it, every call
+ * answered, and changes under it only by what the job itself writes (db/harness). Its answer goes
+ * in the log as an assistant entry, with a tool job for each call it made; a sleep's job is due
+ * when the sleep ends, and a delegation is answered at once as started, its report to come as an
+ * event. A log that has grown long is first summarized, in a summary entry the model reads in
+ * place of what it covers. A model that can't be reached is tried again a few times, then the
+ * person is told; any other failure fails the job, which tells them too (db/harness failJob). A
+ * sub-agent's answer with no calls is its report, and ends its work. An answer of nothing, no
+ * words and no calls, is written as that said: the person would otherwise see nothing, and the
+ * model refuses a log with an empty turn in it. The answer is written with the system prompt the
+ * model was sent, which is built from the login's grants as they are then, so that what the model
+ * read can be told later; the rest of the request is rebuilt from the log.
+ */
+
+// How the agent talking with the person is to go about it: it does nothing itself, and hands
+// every piece of work to a sub-agent.
+const MAIN_FLOW =
+ 'You are talking with the person directly, on the Chat page of Personal Agent Proxy; what you write is shown to them as a chat message, rendered as Markdown, so write short, and answer what they asked: plain sentences for most things, a list or a table where it lays out better, no headings. You make no calls to their accounts yourself: you have one tool, delegate, which starts a sub-agent on a piece of work with the same access as you. Where these instructions name a tool such as read, run, entries_list or entries_create, that is what a sub-agent does on your behalf: put it in its brief. The sub-agent knows nothing but its brief and can’t ask anything, so put in it everything it needs, including what to report back. delegate answers at once that the sub-agent has started; its report comes to you as a message when it is done. Until then, go on, or, when there is nothing to do but wait, tell the person briefly what is under way and stop; say nothing of waiting on a sub-agent as a thing of its own, the person sees it. Start several at once when the pieces are independent. When a report comes, tell the person what matters of it, or start the next piece. A long conversation is summarized for you as it goes, and older reports are cleared from it: take a summary as what happened, and don’t mention that the conversation was summarized.';
+
+// How a sub-agent is to go about it: one brief, its tools, and a report at the end.
+const SUB_FLOW =
+ 'You are a sub-agent of the agent talking with the person on the Chat page of Personal Agent Proxy. The first message is your brief from it; nothing you write is read until you are done, and nobody answers questions, so ask none: do what the brief says with what you have, and where you can’t, say so in your report. list_connections lists the accounts you can use and what you may do with each. list_tools with a connection gives that connection’s tools and the params each takes. Don’t guess a tool’s params: where these instructions don’t give them, call list_tools for the connection before you first read or run one of its tools. read runs the tools that only read; run runs the ones that change something or reach other people. Every call is made for your task, filled in for you: there is nothing to start or finish. When you have called sleep_until, you are woken at that time with nothing but this log, so say in your message what you will do then. Your first message without a tool call is your report, handed back as it is, and ends your work: say what you did and found, with the names, ids, dates and numbers that matter, and what you could not do; nothing else. A long log is summarized for you as it goes: take a summary as what happened.';
+
+// How a long log is summarized, by the same model, for itself: a conversation's, or a
+// sub-agent's, whose one message from the person is its brief.
+const SUMMARIZE =
+ 'You are summarizing a conversation between a person and their agent, written out below with the calls the agent made and what they answered, so that the agent can go on from the summary in place of the conversation so far. Write what the agent needs to go on as if it remembered: what the person asked for and what they said they want in general; what was done and found, with the names, ids, dates and numbers that matter; what the person was told; what is still open, promised or planned, such as what to do after a sleep or when a sub-agent reports. Leave out what no longer matters. Plain prose, from the agent’s side, no title.';
+const SUMMARIZE_NOW = 'Write the summary now.';
+
+// How many answers the model may give in a row without the person saying anything, calls,
+// reports and wake-ups included, before it stops and waits for them; a report that comes after
+// the stop is acted on, and the count starts over.
+const MAX_TURNS_ALONE = 30;
+
+// How often a model that can't be reached is tried, and how long to wait between tries.
+const MAX_MODEL_ATTEMPTS = 3;
+const RETRY_MS = 5_000;
+
+const MAX_TOKENS = 4000;
+const TIMEOUT_MS = 120_000;
+
+const NOT_SET_UP = 'Chat is not set up on this server: it needs an OpenRouter key (OPENROUTER_KEY). Everything else works as before.';
+const UNREACHABLE = 'The model could not be reached. Say something to try again.';
+const TOO_LONG = `I have taken ${MAX_TURNS_ALONE} steps without hearing from you, so I have stopped. Tell me to go on if you want me to.`;
+const TOO_LONG_SUB = `I took ${MAX_TURNS_ALONE} steps without finishing, so I stopped. This is not a complete report.`;
+const NOTHING = 'I had nothing to say to that. Say something to go on.';
+const NOTHING_SUB = 'I finished without a report.';
+
+// Whether the answer is the model stopping for the person, at the cap: the count starts over
+// from it, so a report that comes after the stop is acted on rather than answered with the stop
+// again, once per report.
+function isStop(entry: EntryRow): boolean {
+ return entry.kind === 'assistant' && (entry.text === TOO_LONG || entry.text === TOO_LONG_SUB);
+}
+
+// How many answers the model has given since the person last spoke, or since it last stopped for
+// them.
+function turnsAlone(entries: EntryRow[]): number {
+ const since = entries.findLastIndex((entry) => entry.kind === 'person' || isStop(entry));
+ return entries.slice(since + 1).filter((entry) => entry.kind === 'assistant').length;
+}
+
+// What a delegation is answered with at once, the report to come as an event.
+function started(title: string): string {
+ return `Started the sub-agent “${title}”. Its report will come to you as a message when it is done.`;
+}
+
+// What is to happen with each call: a sleep's job is due when it ends, a delegation is answered
+// as started, every other call's job is due now.
+function plans(thread: ThreadRow, calls: ToolCall[], now: Date): Record {
+ return Object.fromEntries(
+ calls.map((call) => {
+ const args = callArguments(call);
+ if (call.name === SLEEP_TOOL && thread.parentThreadId !== null) {
+ return [call.id, {runAt: sleepUntil(args, now) ?? now, placeholder: null}];
+ }
+ const delegated = call.name === DELEGATE_TOOL && thread.parentThreadId === null ? delegateArgs(args) : null;
+ return [call.id, {runAt: now, placeholder: delegated ? started(delegated.title) : null}];
+ }),
+ );
+}
+
+// The answer as it goes in the log: an answer of nothing says so.
+function said(thread: ThreadRow, turn: ModelTurn): ModelTurn {
+ if (turn.text.trim() !== '' || turn.toolCalls.length > 0) {
+ return turn;
+ }
+ return {...turn, text: thread.parentThreadId === null ? NOTHING : NOTHING_SUB};
+}
+
+// Writes the answer back; a job no longer the worker's is a conflict.
+function writeBack(job: JobRow, workerId: string, turn: AssistantTurn, callPlans: Record): Promise> {
+ return Do(async ($) => {
+ if (!$(await completeInference(job.id, workerId, job.threadId, turn, callPlans))) {
+ return $(Err(ApiErr.conflict('The job was no longer this worker’s')));
+ }
+ });
+}
+
+// Answers the person with `text` alone, as the model would, when the model can't be run: no
+// prompt was built.
+function answerWith(job: JobRow, workerId: string, text: string): Promise> {
+ return writeBack(job, workerId, {text, toolCalls: [], inputTokens: 0, outputTokens: 0, system: null}, {});
+}
+
+// Why the model couldn't be reached, for the job's record.
+function unreachable(failure: ApiError): string {
+ const cause = 'cause' in failure ? failure.cause : null;
+ return `The model could not be reached${cause instanceof Error ? `: ${cause.message}` : ''}`;
+}
+
+// The system prompt: the instructions any agent with the login gets, for the agent as it acts in
+// the thread, as the member whose conversation it is, and how to go about it on this thread.
+function systemPrompt(thread: ThreadRow, now: Date): Promise> {
+ return Do(async ($) => {
+ const signedIn = $(requirePresent($(await getThreadAgent(thread)), ApiErr.notFound('agent', thread.agentId)));
+ const instructions = $(await agentInstructions(signedIn, thread.parentThreadId === null ? MAIN_FLOW : SUB_FLOW));
+ return `${instructions}\n\nThe time now is ${now.toISOString()}.`;
+ });
+}
+
+// Summarizes the log where it has grown long, into a summary entry the model reads in place of
+// what it covers; the log then, as it is. The model gets the log as text, not as the turns it
+// made, which would need the tools sent along: a request with calls in it and no tools is refused.
+function compact(job: JobRow, workerId: string, entries: EntryRow[], key: string): Promise> {
+ return Do(async ($) => {
+ const cut = compactionCut(entries);
+ const last = cut === null ? undefined : entries[cut - 1];
+ if (cut === null || !last) {
+ return entries;
+ }
+ const summary = $(
+ await askModel({
+ key,
+ messages: [
+ {role: 'system', content: SUMMARIZE},
+ {role: 'user', content: `${toTranscript(entries.slice(0, cut))}\n\n${SUMMARIZE_NOW}`},
+ ],
+ tools: [],
+ maxTokens: MAX_TOKENS,
+ timeoutMs: TIMEOUT_MS,
+ }),
+ );
+ const written = $(await appendSummary(job.id, workerId, job.threadId, summary, last.seq));
+ if (!written) {
+ return $(Err(ApiErr.conflict('The job was no longer this worker’s')));
+ }
+ return $(await listEntries(job.threadId));
+ });
+}
+
+// The model's turn on the log, summarized first where it has grown long, with the system prompt
+// it was sent.
+function askOnLog(job: JobRow, workerId: string, thread: ThreadRow, entries: EntryRow[], key: string, now: Date): Promise> {
+ return Do(async ($) => {
+ const current = $(await compact(job, workerId, entries, key));
+ const system = $(await systemPrompt(thread, now));
+ const turn = $(
+ await askModel({key, messages: [{role: 'system', content: system}, ...toMessages(current, {clearOld: true})], tools: toolsFor(thread), maxTokens: MAX_TOKENS, timeoutMs: TIMEOUT_MS}),
+ );
+ return {turn, system};
+ });
+}
+
+/** Runs the job: one turn of the model on its thread's log, written back; nothing when the thread is done, or gone. */
+export function runInference(job: JobRow, workerId: string, now = new Date()): Promise> {
+ return Do(async ($) => {
+ const thread = $(await getJobThread(job.threadId));
+ if (!thread || thread.finishedAt !== null) {
+ $(await finishJob(job.id, workerId));
+ return;
+ }
+ const entries = $(await listEntries(thread.id));
+ if (turnsAlone(entries) >= MAX_TURNS_ALONE) {
+ $(await answerWith(job, workerId, thread.parentThreadId === null ? TOO_LONG : TOO_LONG_SUB));
+ return;
+ }
+ const key = env.OPENROUTER_KEY;
+ if (!key) {
+ $(await answerWith(job, workerId, NOT_SET_UP));
+ return;
+ }
+ const asked = await askOnLog(job, workerId, thread, entries, key, now);
+ if (asked.isErr() && asked.error.kind !== 'provider_unreachable') {
+ return $(Err(asked.error));
+ }
+ if (asked.isErr()) {
+ log.warn('The built-in agent’s model could not be reached', {attempt: job.attempts, ...serializeError('cause' in asked.error ? asked.error.cause : asked.error)});
+ if (job.attempts < MAX_MODEL_ATTEMPTS) {
+ $(await requeueJob(job.id, workerId, thread.id, new Date(now.getTime() + RETRY_MS * job.attempts), unreachable(asked.error)));
+ return;
+ }
+ $(await answerWith(job, workerId, UNREACHABLE));
+ return;
+ }
+ const {turn, system} = asked.value;
+ $(await writeBack(job, workerId, {...said(thread, turn), system}, plans(thread, turn.toolCalls, now)));
+ });
+}
diff --git a/packages/api/src/harness/model.test.ts b/packages/api/src/harness/model.test.ts
new file mode 100644
index 00000000..8d81d511
--- /dev/null
+++ b/packages/api/src/harness/model.test.ts
@@ -0,0 +1,82 @@
+import {afterAll, beforeEach, describe, expect, spyOn, test} from 'bun:test';
+
+import {askModel, MODEL} from './model';
+
+// What OpenRouter answers, and with what status.
+let status = 200;
+let message: Record = {content: 'Hello'};
+const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((async () =>
+ status === 200 ? Response.json({choices: [{message}], usage: {prompt_tokens: 120, completion_tokens: 8}}) : Response.json({error: {message: 'down'}}, {status})) as unknown as typeof fetch);
+
+afterAll(() => {
+ fetchSpy.mockRestore();
+});
+
+beforeEach(() => {
+ fetchSpy.mockClear();
+ status = 200;
+ message = {content: 'Hello'};
+});
+
+const request = {
+ key: 'sk-test',
+ messages: [
+ {role: 'system' as const, content: 'Be brief.'},
+ {role: 'user' as const, content: 'Hi'},
+ ],
+ tools: [{name: 'read', description: 'Reads', parameters: {type: 'object', properties: {}}}],
+ maxTokens: 100,
+ timeoutMs: 1000,
+};
+
+// The body of the request made to OpenRouter.
+function sent(): {model: string; messages: unknown[]; tools: unknown[]; max_tokens: number; provider: {data_collection: string}} {
+ return JSON.parse(String(fetchSpy.mock.calls[0]?.[1]?.body));
+}
+
+describe('askModel', () => {
+ test('asks Claude Sonnet 5.5 over OpenRouter with the tools, on a host that keeps nothing', async () => {
+ const turn = await askModel(request);
+
+ expect(turn.unwrap()).toEqual({text: 'Hello', toolCalls: [], inputTokens: 120, outputTokens: 8});
+ expect(String(fetchSpy.mock.calls[0]?.[0])).toBe('https://openrouter.ai/api/v1/chat/completions');
+ expect((fetchSpy.mock.calls[0]?.[1]?.headers as Record).authorization).toBe('Bearer sk-test');
+ const body = sent();
+ expect(body.model).toBe(MODEL);
+ expect(body.messages).toEqual(request.messages);
+ expect(body.tools).toEqual([{type: 'function', function: {name: 'read', description: 'Reads', parameters: {type: 'object', properties: {}}}}]);
+ expect(body.max_tokens).toBe(100);
+ expect(body.provider.data_collection).toBe('deny');
+ });
+
+ test('reads the calls the model made, with their arguments as it wrote them', async () => {
+ message = {content: null, tool_calls: [{id: 'call-1', type: 'function', function: {name: 'read', arguments: '{"connection":"c1"}'}}]};
+ const turn = await askModel(request);
+
+ expect(turn.unwrap()).toEqual({text: '', toolCalls: [{id: 'call-1', name: 'read', arguments: '{"connection":"c1"}'}], inputTokens: 120, outputTokens: 8});
+ });
+
+ test('a provider that is down, or too busy, is unreachable, which is worth trying again', async () => {
+ status = 502;
+ expect((await askModel(request)).unwrapErr().kind).toBe('provider_unreachable');
+
+ status = 429;
+ expect((await askModel(request)).unwrapErr().kind).toBe('provider_unreachable');
+ });
+
+ test('a key turned down, or out of credit, means the server is not set up right, and says what OpenRouter said', async () => {
+ status = 401;
+ expect((await askModel(request)).unwrapErr()).toEqual({kind: 'unavailable', statusCode: 503, message: 'OpenRouter turned the server’s key down (401): down'});
+
+ status = 402;
+ expect((await askModel(request)).unwrapErr()).toMatchObject({kind: 'unavailable', message: expect.stringContaining('(402)')});
+ });
+
+ test('a request refused is an error of ours, which no retry mends', async () => {
+ status = 400;
+ const error = (await askModel(request)).unwrapErr();
+
+ expect(error.kind).toBe('internal_error');
+ expect(error.kind === 'internal_error' && error.cause instanceof Error ? error.cause.message : '').toBe('OpenRouter refused the request (400): down');
+ });
+});
diff --git a/packages/api/src/harness/model.ts b/packages/api/src/harness/model.ts
new file mode 100644
index 00000000..9c97ffdc
--- /dev/null
+++ b/packages/api/src/harness/model.ts
@@ -0,0 +1,112 @@
+import {Err, type Result} from 'ts-results-es';
+import {z} from 'zod';
+
+import {ApiErr, type ApiError, Do, type FetchError, httpRequest} from '@proxy/utils';
+import type {ToolCall} from '@proxy/utils/harness';
+
+/**
+ * The built-in agent's model: Claude Sonnet 5.5 over OpenRouter, answering a conversation with
+ * text, tool calls, or both, in the chat completions form. One call per turn; the loop
+ * (inference.ts) runs the calls and asks again. What goes wrong is told apart: a model that
+ * can't be reached, or answered with something that isn't an answer, is worth trying again; one
+ * that turned the server's key down, or has no credit left, is not set up right; and one that
+ * refused the request was sent something wrong, which no retry mends.
+ */
+
+// How much of what OpenRouter said went wrong is kept.
+const MAX_SAID = 500;
+
+const errorBodySchema = z.object({error: z.object({message: z.string()})});
+
+// What OpenRouter said went wrong, from the body of its answer.
+function said(body: unknown): string {
+ const parsed = errorBodySchema.safeParse(body);
+ const message = parsed.success ? parsed.data.error.message : typeof body === 'string' ? body : JSON.stringify(body ?? '');
+ return message.slice(0, MAX_SAID);
+}
+
+// The failure as this server's error, by what it means for the next try.
+function toApiError(error: FetchError): ApiError {
+ if (error.kind !== 'http') {
+ return ApiErr.providerUnreachable(error);
+ }
+ if (error.status === 401 || error.status === 402 || error.status === 403) {
+ return ApiErr.unavailable(`OpenRouter turned the server’s key down (${error.status}): ${said(error.body)}`);
+ }
+ if (error.status === 408 || error.status === 429 || error.status >= 500) {
+ return ApiErr.providerUnreachable(error);
+ }
+ return ApiErr.internalError(new Error(`OpenRouter refused the request (${error.status}): ${said(error.body)}`));
+}
+
+const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
+
+export const MODEL = 'anthropic/claude-sonnet-5.5';
+
+/** A tool as the model is given it: its name, what it does, and the JSON Schema of its arguments. */
+export type ModelTool = {name: string; description: string; parameters: object};
+
+/** The conversation as the model reads it, in the chat completions form. */
+export type ModelMessage =
+ | {role: 'system'; content: string}
+ | {role: 'user'; content: string}
+ | {role: 'assistant'; content: string | null; tool_calls?: Array<{id: string; type: 'function'; function: {name: string; arguments: string}}>}
+ | {role: 'tool'; tool_call_id: string; content: string};
+
+export type ModelRequest = {
+ key: string;
+ messages: ModelMessage[];
+ tools: ModelTool[];
+ maxTokens: number;
+ timeoutMs: number;
+};
+
+/** What the model answered: its text, the calls it made, and what the turn cost. */
+export type ModelTurn = {text: string; toolCalls: ToolCall[]; inputTokens: number; outputTokens: number};
+
+const completionSchema = z.object({
+ choices: z
+ .array(
+ z.object({
+ message: z.object({
+ content: z.string().nullable().optional(),
+ tool_calls: z.array(z.object({id: z.string(), function: z.object({name: z.string(), arguments: z.string()})})).optional(),
+ }),
+ }),
+ )
+ .min(1),
+ usage: z.object({prompt_tokens: z.number(), completion_tokens: z.number()}).optional(),
+});
+
+/** One turn of the model on the conversation. */
+export function askModel(request: ModelRequest): Promise> {
+ return Do(async ($) => {
+ const answered = await httpRequest(
+ OPENROUTER_URL,
+ {
+ method: 'POST',
+ headers: {'content-type': 'application/json', authorization: `Bearer ${request.key}`},
+ body: JSON.stringify({
+ model: MODEL,
+ messages: request.messages,
+ tools: request.tools.map((tool) => ({type: 'function', function: {name: tool.name, description: tool.description, parameters: tool.parameters}})),
+ max_tokens: request.maxTokens,
+ // Only a host that neither keeps prompts nor trains on them: the conversation is
+ // the person's, and what their email and notes say is in it.
+ provider: {data_collection: 'deny'},
+ }),
+ },
+ {schema: completionSchema, timeoutMs: request.timeoutMs},
+ );
+ if (answered.isErr()) {
+ return $(Err(toApiError(answered.error)));
+ }
+ const {message} = answered.value.choices[0] ?? {};
+ return {
+ text: message?.content ?? '',
+ toolCalls: (message?.tool_calls ?? []).map((call) => ({id: call.id, name: call.function.name, arguments: call.function.arguments})),
+ inputTokens: answered.value.usage?.prompt_tokens ?? 0,
+ outputTokens: answered.value.usage?.completion_tokens ?? 0,
+ };
+ });
+}
diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts
new file mode 100644
index 00000000..ced0b279
--- /dev/null
+++ b/packages/api/src/harness/route.test.ts
@@ -0,0 +1,248 @@
+import {beforeEach, describe, expect, mock, test} from 'bun:test';
+import {Ok} from 'ts-results-es';
+
+const listPersonas = mock();
+const ensureFirstPersona = mock();
+const getPersona = mock();
+const createPersona = mock();
+const updatePersona = mock();
+const deletePersona = mock(async () => Ok(true));
+const getMemberThread = mock();
+const ensureMemberThread = mock();
+const listShownEntries = mock();
+const listOpenJobs = mock();
+const appendPersonMessage = mock();
+mock.module('@proxy/db/harness', () => ({
+ listPersonas,
+ ensureFirstPersona,
+ getPersona,
+ createPersona,
+ updatePersona,
+ deletePersona,
+ getMemberThread,
+ ensureMemberThread,
+ listShownEntries,
+ listOpenJobs,
+ appendPersonMessage,
+}));
+
+const viewer = {orgId: 'org-1', userId: 'user-1', slug: 'acme', name: 'Acme', role: 'member' as 'member' | 'owner', onboarded: true, experiments: ['harness']};
+const requireViewer = mock(async () => Ok(viewer));
+mock.module('../utils/user-org', () => ({requireViewer}));
+
+const ensureBuiltInAgent = mock(async () => Ok({id: 'login-1'}));
+mock.module('./built-in-agent', () => ({ensureBuiltInAgent}));
+
+const now = new Date('2026-10-21T10:00:00Z');
+const look = {nose: 0.48, noseOut: 0.35, eye: 27, eyeTheta: 55, eyeLat: 8, eyes: 2 as const};
+const persona = {id: 'persona-1', name: 'Finance', look, color: 'blue', createdById: 'user-1'};
+const personaResponse = {id: 'persona-1', name: 'Finance', look, color: 'blue', mayChange: true, lastMessage: null};
+const thread = {
+ id: 'thread-1',
+ orgId: 'org-1',
+ agentId: 'login-1',
+ personaId: 'persona-1',
+ userId: 'user-1',
+ parentThreadId: null,
+ parentCallId: null,
+ title: null,
+ taskId: null,
+ finishedAt: null,
+ createdAt: now,
+};
+
+function request(params: Record = {}, body: unknown = undefined) {
+ return {user: {userId: 'user-1', email: 'a@example.com'}, params, body} as never;
+}
+
+beforeEach(() => {
+ mock.clearAllMocks();
+ requireViewer.mockResolvedValue(Ok(viewer));
+ listPersonas.mockResolvedValue(Ok([{...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}}]));
+ getPersona.mockResolvedValue(Ok(persona));
+ createPersona.mockResolvedValue(Ok({...persona, id: 'persona-2'}));
+ updatePersona.mockResolvedValue(Ok(true));
+ getMemberThread.mockResolvedValue(Ok(thread));
+ ensureMemberThread.mockResolvedValue(Ok(thread));
+ listShownEntries.mockResolvedValue(
+ Ok([
+ {
+ id: 'e1',
+ seq: 1,
+ threadId: 'thread-1',
+ kind: 'person',
+ text: 'Hi',
+ toolCalls: null,
+ callId: null,
+ isError: false,
+ inputTokens: null,
+ outputTokens: null,
+ coversSeq: null,
+ system: null,
+ jobId: null,
+ createdAt: now,
+ },
+ {
+ id: 'e2',
+ seq: 2,
+ threadId: 'thread-1',
+ kind: 'summary',
+ text: 'The person said hi.',
+ toolCalls: null,
+ callId: null,
+ isError: false,
+ inputTokens: 90_000,
+ outputTokens: 10,
+ coversSeq: 1,
+ createdAt: now,
+ },
+ ]),
+ );
+ listOpenJobs.mockResolvedValue(Ok([]));
+ appendPersonMessage.mockResolvedValue(
+ Ok({
+ id: 'e3',
+ seq: 3,
+ threadId: 'thread-1',
+ kind: 'person',
+ text: 'Hello',
+ toolCalls: null,
+ callId: null,
+ isError: false,
+ inputTokens: null,
+ outputTokens: null,
+ coversSeq: null,
+ system: null,
+ jobId: null,
+ createdAt: now,
+ }),
+ );
+});
+
+describe('the chat routes', () => {
+ test('are refused without the experiment', async () => {
+ const {handleListPersonasRoute, handlePostMessageRoute} = await import('./route');
+ requireViewer.mockResolvedValue(Ok({...viewer, experiments: []}));
+
+ expect((await handleListPersonasRoute(request())).unwrapErr().kind).toBe('forbidden');
+ expect((await handlePostMessageRoute(request({personaId: 'persona-1'}, {text: 'Hi'}))).unwrapErr().kind).toBe('forbidden');
+ expect(appendPersonMessage).not.toHaveBeenCalled();
+ });
+
+ test('list the organization’s personas, with their heads, whether the viewer may change each, and the last thing said with each, on one line', async () => {
+ const {handleListPersonasRoute} = await import('./route');
+ listPersonas.mockResolvedValue(
+ Ok([
+ {...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}},
+ {...persona, id: 'persona-3', createdById: 'user-2', lastMessage: null},
+ {
+ ...persona,
+ id: 'persona-4',
+ lastMessage: {kind: 'assistant', text: '## Found\n\n- **Three** invoices, see [the list](https://x.test)\n\n| Who | Sum |\n| --- | --- |\n| Dana | 40 |\n\n```\ncode\n```', createdAt: now},
+ },
+ ]),
+ );
+
+ const listed = (await handleListPersonasRoute(request())).unwrap();
+
+ expect(listPersonas).toHaveBeenCalledWith('org-1', 'user-1');
+ expect(listed).toEqual({
+ personas: [
+ {...personaResponse, lastMessage: {kind: 'assistant', text: 'Your vault has one entry.', at: '2026-10-21T10:00:00.000Z'}},
+ {...personaResponse, id: 'persona-3', mayChange: false},
+ {...personaResponse, id: 'persona-4', lastMessage: {kind: 'assistant', text: 'Found Three invoices, see the list Who Sum Dana 40', at: '2026-10-21T10:00:00.000Z'}},
+ ],
+ });
+ });
+
+ test('an organization without a persona gets a General one, nobody’s, on its first listing', async () => {
+ const {handleListPersonasRoute} = await import('./route');
+ listPersonas.mockResolvedValue(Ok([]));
+ ensureFirstPersona.mockResolvedValue(Ok({...persona, id: 'persona-9', name: 'General', createdById: null}));
+
+ const listed = (await handleListPersonasRoute(request())).unwrap();
+
+ expect(ensureFirstPersona).toHaveBeenCalledWith('org-1');
+ expect(listed).toEqual({personas: [{...personaResponse, id: 'persona-9', name: 'General', mayChange: false}]});
+ });
+
+ test('make a persona as given, for everyone in the organization, with the organization’s built-in login where there is none', async () => {
+ const {handleCreatePersonaRoute} = await import('./route');
+
+ const made = await handleCreatePersonaRoute(request({}, {name: ' Finance ', look, color: 'blue'}));
+
+ expect(ensureBuiltInAgent).toHaveBeenCalledWith('org-1');
+ expect(createPersona).toHaveBeenCalledWith('org-1', 'user-1', {name: 'Finance', look, color: 'blue'});
+ expect(made.unwrap().persona).toEqual({...personaResponse, id: 'persona-2'});
+ });
+
+ test('refuse a persona without a name, with a look outside the lab’s range, or a color outside the catalog', async () => {
+ const {handleCreatePersonaRoute} = await import('./route');
+
+ expect((await handleCreatePersonaRoute(request({}, {name: ' ', look, color: 'blue'}))).unwrapErr().kind).toBe('parse_error');
+ expect((await handleCreatePersonaRoute(request({}, {name: 'Finance', look: {...look, nose: 2}, color: 'blue'}))).unwrapErr().kind).toBe('parse_error');
+ expect((await handleCreatePersonaRoute(request({}, {name: 'Finance', look, color: 'brown'}))).unwrapErr().kind).toBe('parse_error');
+ expect(createPersona).not.toHaveBeenCalled();
+ });
+
+ test('rename a persona or change its head, and delete one with every conversation, for whoever made it or owns the organization', async () => {
+ const {handleDeletePersonaRoute, handleUpdatePersonaRoute} = await import('./route');
+ const changed = {name: 'Money', look: {...look, eyes: 1 as const}, color: 'green'};
+
+ const updated = await handleUpdatePersonaRoute(request({personaId: 'persona-1'}, changed));
+ expect(updatePersona).toHaveBeenCalledWith('org-1', 'persona-1', changed);
+ expect(updated.unwrap().persona).toEqual({...personaResponse, ...changed});
+
+ getPersona.mockResolvedValue(Ok({...persona, createdById: 'user-2'}));
+ expect((await handleUpdatePersonaRoute(request({personaId: 'persona-1'}, changed))).unwrapErr().kind).toBe('forbidden');
+ expect((await handleDeletePersonaRoute(request({personaId: 'persona-1'}))).unwrapErr().kind).toBe('forbidden');
+ expect(deletePersona).not.toHaveBeenCalled();
+
+ requireViewer.mockResolvedValue(Ok({...viewer, role: 'owner'}));
+ expect((await handleDeletePersonaRoute(request({personaId: 'persona-1'}))).isOk()).toBe(true);
+ expect(deletePersona).toHaveBeenCalledWith('org-1', 'persona-1');
+
+ getPersona.mockResolvedValue(Ok(null));
+ expect((await handleDeletePersonaRoute(request({personaId: 'nope'}))).unwrapErr()).toMatchObject({kind: 'not_found'});
+ });
+
+ test('give the viewer’s conversation with the persona, begun through the built-in login where there was none, as turns alone, with whether the persona is busy or asleep', async () => {
+ const {handleGetThreadRoute} = await import('./route');
+ const soon = new Date(Date.now() + 60_000);
+ const base = {id: 'j1', threadId: 'thread-1', kind: 'tool' as const, status: 'queued' as const, entryId: 'e1', callId: 'c1', claimedBy: null, attempts: 0};
+
+ const idle = (await handleGetThreadRoute(request({personaId: 'persona-1'}))).unwrap();
+ expect(idle.persona).toEqual(personaResponse);
+ expect(idle.entries).toEqual([{id: 'e1', kind: 'person', text: 'Hi', isError: false, at: '2026-10-21T10:00:00.000Z'}]);
+ expect(idle).toMatchObject({busy: false, sleepingUntil: null});
+ expect(ensureMemberThread).not.toHaveBeenCalled();
+ expect(ensureBuiltInAgent).not.toHaveBeenCalled();
+
+ getMemberThread.mockResolvedValue(Ok(null));
+ listOpenJobs.mockResolvedValue(Ok([{...base, runAt: new Date(Date.now() - 1000)}]));
+ expect((await handleGetThreadRoute(request({personaId: 'persona-1'}))).unwrap()).toMatchObject({busy: true, sleepingUntil: null});
+ expect(ensureBuiltInAgent).toHaveBeenCalledWith('org-1');
+ expect(ensureMemberThread).toHaveBeenCalledWith({orgId: 'org-1', agentId: 'login-1', personaId: 'persona-1', userId: 'user-1'});
+
+ listOpenJobs.mockResolvedValue(Ok([{...base, runAt: soon}]));
+ expect((await handleGetThreadRoute(request({personaId: 'persona-1'}))).unwrap()).toMatchObject({busy: false, sleepingUntil: soon.toISOString()});
+
+ // A turn of the model to be tried again in a moment is work under way, not sleep.
+ listOpenJobs.mockResolvedValue(Ok([{...base, kind: 'inference' as const, entryId: null, callId: null, runAt: soon}]));
+ expect((await handleGetThreadRoute(request({personaId: 'persona-1'}))).unwrap()).toMatchObject({busy: true, sleepingUntil: null});
+
+ getPersona.mockResolvedValue(Ok(null));
+ expect((await handleGetThreadRoute(request({personaId: 'nope'}))).unwrapErr()).toMatchObject({kind: 'not_found', resource: 'persona'});
+ });
+
+ test('append what the person says, trimmed, and refuse nothing or too much', async () => {
+ const {handlePostMessageRoute} = await import('./route');
+
+ const posted = await handlePostMessageRoute(request({personaId: 'persona-1'}, {text: ' Hello \n'}));
+ expect(appendPersonMessage).toHaveBeenCalledWith('thread-1', 'Hello');
+ expect(posted.unwrap().entry).toMatchObject({id: 'e3', kind: 'person', text: 'Hello'});
+
+ expect((await handlePostMessageRoute(request({personaId: 'persona-1'}, {text: ' '}))).unwrapErr().kind).toBe('parse_error');
+ expect((await handlePostMessageRoute(request({personaId: 'persona-1'}, {text: 'x'.repeat(20_001)}))).unwrapErr().kind).toBe('parse_error');
+ });
+});
diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts
new file mode 100644
index 00000000..665ed795
--- /dev/null
+++ b/packages/api/src/harness/route.ts
@@ -0,0 +1,207 @@
+import {Err, type Result} from 'ts-results-es';
+import {z} from 'zod';
+
+import {ApiErr, type ApiError, Do, hasExperiment, parseSchema, requirePresent} from '@proxy/utils';
+import {AVATAR_COLORS, type Look, lookSchema, preview} from '@proxy/utils/harness';
+import {
+ appendPersonMessage,
+ createPersona,
+ deletePersona,
+ ensureFirstPersona,
+ ensureMemberThread,
+ type EntryRow,
+ getMemberThread,
+ getPersona,
+ type LastMessage,
+ listOpenJobs,
+ listPersonas,
+ listShownEntries,
+ type PersonaRow,
+ type ThreadRow,
+ updatePersona,
+} from '@proxy/db/harness';
+import {AGENT_NAME_MAX} from '@proxy/integrations';
+
+import type {AuthenticatedRequest} from '../server/middleware/require-auth';
+import {manages} from '../utils/manages';
+import {type OrgViewer, requireViewer} from '../utils/user-org';
+import {ensureBuiltInAgent} from './built-in-agent';
+
+/**
+ * The Chat page's routes, under the organization: its personas, which every member sees, each
+ * named and given a head, all acting through the organization's one built-in agent login, and the
+ * member's own conversation with each, its log, the sub-agents started from it, and what they say
+ * in it. Behind the `harness` experiment. Nothing here runs the model: a message queues a job, and
+ * the worker (worker.ts) does the rest; the page reads the log again as it goes.
+ */
+
+const MAX_MESSAGE = 20_000;
+
+/** A persona as the page lists it, whether the viewer may change it, and the last thing said in their conversation with it, cut to a line; null before anything was said. */
+export type PersonaResponse = {id: string; name: string; look: Look; color: string; mayChange: boolean; lastMessage: {kind: 'person' | 'assistant'; text: string; at: string} | null};
+
+/** A turn of the conversation as the page shows it: what the person said, or what the persona answered, which is what went wrong where its turn failed. What the persona did in between is in the activity log, not here. */
+export type EntryResponse = {id: string; kind: 'person' | 'assistant'; text: string; isError: boolean; at: string};
+
+/** The member's conversation with a persona: the persona, its turns, and what the persona is up to: busy on it, asleep until a time, or waiting for the person. */
+export type ThreadLogResponse = {persona: PersonaResponse; entries: EntryResponse[]; busy: boolean; sleepingUntil: string | null};
+
+// Whether the viewer may rename, reface or delete the persona: whoever made it, or the organization's owner (utils/manages.ts).
+function managesPersona(viewer: OrgViewer, persona: PersonaRow): boolean {
+ return manages(viewer, {ownerUserId: null, byId: persona.createdById});
+}
+
+function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null}): PersonaResponse {
+ const last = persona.lastMessage ?? null;
+ return {
+ id: persona.id,
+ name: persona.name,
+ look: persona.look,
+ color: persona.color,
+ mayChange: managesPersona(viewer, persona),
+ lastMessage: last && {kind: last.kind, text: preview(last.text), at: last.createdAt.toISOString()},
+ };
+}
+
+// A persona as the person makes or changes it: a name, and a head, a look in one of the colors.
+const personaSchema = z.object({
+ name: z.string().trim().pipe(z.string().min(1).max(AGENT_NAME_MAX)),
+ look: lookSchema,
+ color: z.enum(AVATAR_COLORS),
+});
+
+// The entries the person sees (db/harness listShownEntries), as their kinds, for the response.
+function isShown(entry: EntryRow): entry is EntryRow & {kind: EntryResponse['kind']} {
+ return entry.kind === 'person' || (entry.kind === 'assistant' && entry.text !== '');
+}
+
+function toEntry(entry: EntryRow & {kind: EntryResponse['kind']}): EntryResponse {
+ return {id: entry.id, kind: entry.kind, text: entry.text, isError: entry.isError, at: entry.createdAt.toISOString()};
+}
+
+function requireChat(request: AuthenticatedRequest): Promise> {
+ return Do(async ($) => {
+ const viewer = $(await requireViewer(request));
+ if (!hasExperiment(viewer, 'harness')) {
+ return $(Err(ApiErr.forbidden()));
+ }
+ return viewer;
+ });
+}
+
+// The persona in the address, one of the organization's.
+function requirePersona(viewer: OrgViewer, request: AuthenticatedRequest): Promise> {
+ return Do(async ($) => {
+ const personaId = request.params.personaId ?? '';
+ return $(requirePresent($(await getPersona(viewer.orgId, personaId)), ApiErr.notFound('persona', personaId)));
+ });
+}
+
+// The persona in the address, which the viewer may change; forbidden for one they only talk to.
+function requireManagedPersona(viewer: OrgViewer, request: AuthenticatedRequest): Promise> {
+ return Do(async ($) => {
+ const persona = $(await requirePersona(viewer, request));
+ if (!managesPersona(viewer, persona)) {
+ return $(Err(ApiErr.forbidden()));
+ }
+ return persona;
+ });
+}
+
+// The viewer's conversation with the persona, begun the first time, through the organization's
+// built-in login, made with it where there is none yet.
+function requireThread(viewer: OrgViewer, persona: PersonaRow): Promise> {
+ return Do(async ($) => {
+ const existing = $(await getMemberThread(persona.id, viewer.userId));
+ if (existing) {
+ return existing;
+ }
+ const login = $(await ensureBuiltInAgent(viewer.orgId));
+ return $(await ensureMemberThread({orgId: viewer.orgId, agentId: login.id, personaId: persona.id, userId: viewer.userId}));
+ });
+}
+
+/** `GET /api/orgs/:org/chat/personas`: the organization's personas, in the order they were made, with the viewer's conversations; a General one is made where it has none. */
+export function handleListPersonasRoute(request: AuthenticatedRequest): Promise> {
+ return Do(async ($) => {
+ const viewer = $(await requireChat(request));
+ const personas = $(await listPersonas(viewer.orgId, viewer.userId));
+ if (personas.length > 0) {
+ return {personas: personas.map((persona) => toPersona(viewer, persona))};
+ }
+ const first = $(await ensureFirstPersona(viewer.orgId));
+ return {personas: [toPersona(viewer, first)]};
+ });
+}
+
+/** `POST /api/orgs/:org/chat/personas` with `{name, look, color}`: a new persona, every member's, and the organization's built-in login with it where there is none yet. */
+export function handleCreatePersonaRoute(request: AuthenticatedRequest): Promise> {
+ return Do(async ($) => {
+ const viewer = $(await requireChat(request));
+ const persona = $(parseSchema(personaSchema, request.body));
+ $(await ensureBuiltInAgent(viewer.orgId));
+ const made = $(await createPersona(viewer.orgId, viewer.userId, persona));
+ return {persona: toPersona(viewer, made)};
+ });
+}
+
+/** `PUT /api/orgs/:org/chat/personas/:personaId` with `{name, look, color}`: the persona renamed, or with another head. */
+export function handleUpdatePersonaRoute(request: AuthenticatedRequest): Promise> {
+ return Do(async ($) => {
+ const viewer = $(await requireChat(request));
+ const persona = $(parseSchema(personaSchema, request.body));
+ const existing = $(await requireManagedPersona(viewer, request));
+ if (!$(await updatePersona(viewer.orgId, existing.id, persona))) {
+ return $(Err(ApiErr.notFound('persona', existing.id)));
+ }
+ return {persona: toPersona(viewer, {...existing, ...persona})};
+ });
+}
+
+/** `DELETE /api/orgs/:org/chat/personas/:personaId`: the persona, with every member's conversation with it. */
+export function handleDeletePersonaRoute(request: AuthenticatedRequest): Promise> {
+ return Do(async ($) => {
+ const viewer = $(await requireChat(request));
+ const persona = $(await requireManagedPersona(viewer, request));
+ if (!$(await deletePersona(viewer.orgId, persona.id))) {
+ return $(Err(ApiErr.notFound('persona', persona.id)));
+ }
+ });
+}
+
+/** `GET /api/orgs/:org/chat/personas/:personaId/thread`: the viewer's conversation with the persona, begun where there was none, what they said while it was answering at the end, and whether the persona is busy on it, its sub-agents included. */
+export function handleGetThreadRoute(request: AuthenticatedRequest): Promise> {
+ return Do(async ($) => {
+ const viewer = $(await requireChat(request));
+ const persona = $(await requirePersona(viewer, request));
+ const thread = $(await requireThread(viewer, persona));
+ const [entries, jobs] = await Promise.all([listShownEntries(thread.id), listOpenJobs(thread.id)]);
+ const open = $(jobs);
+ const now = Date.now();
+ // Asleep when every open job is a sleep, a tool job due later; the first to be due is when
+ // it wakes. A turn of the model to be tried again later is work under way, not sleep.
+ const sleeping = open.filter((job) => job.kind === 'tool' && job.runAt.getTime() > now);
+ const asleep = open.length > 0 && sleeping.length === open.length;
+ const first = sleeping.map((job) => job.runAt.getTime()).sort((a, b) => a - b)[0];
+ return {
+ persona: toPersona(viewer, persona),
+ entries: $(entries).filter(isShown).map(toEntry),
+ busy: open.length > 0 && !asleep,
+ sleepingUntil: asleep && first !== undefined ? new Date(first).toISOString() : null,
+ };
+ });
+}
+
+const messageBodySchema = z.object({text: z.string().trim().min(1).max(MAX_MESSAGE)});
+
+/** `POST /api/orgs/:org/chat/personas/:personaId/messages` with `{text}`: what the viewer says to the persona; it is woken. */
+export function handlePostMessageRoute(request: AuthenticatedRequest): Promise> {
+ return Do(async ($) => {
+ const viewer = $(await requireChat(request));
+ const {text} = $(parseSchema(messageBodySchema, request.body));
+ const persona = $(await requirePersona(viewer, request));
+ const thread = $(await requireThread(viewer, persona));
+ const entry = $(requirePresent($(await appendPersonMessage(thread.id, text)), ApiErr.notFound('thread', thread.id)));
+ return {entry: toEntry({...entry, kind: 'person'})};
+ });
+}
diff --git a/packages/api/src/harness/thread-task.ts b/packages/api/src/harness/thread-task.ts
new file mode 100644
index 00000000..0bb768aa
--- /dev/null
+++ b/packages/api/src/harness/thread-task.ts
@@ -0,0 +1,44 @@
+import {Err, type Result} from 'ts-results-es';
+
+import {type ApiError, ApiErr, Do, requirePresent} from '@proxy/utils';
+import {taskEndedAt} from '@proxy/utils/tasks';
+import {createAgentTask, getAgentTask} from '@proxy/db/task';
+import {setThreadTask, type ThreadRow} from '@proxy/db/harness';
+
+/**
+ * The task a sub-agent's calls are made for, as every agent's calls are, so the activity log says
+ * which piece of work each request was for. It is the member's, as the conversation it was
+ * delegated from is, so what the sub-agent did is theirs alone to see. A sub-agent starts with
+ * one, made as it is (db/harness startSubAgent), and gets a new one when it was abandoned, a day
+ * after it started, as when it slept long, since the gate refuses calls for one that was, for the
+ * same piece of work, the title and goal the one before had. The agent talking with the person has
+ * none: it makes no calls but to delegate.
+ */
+
+/** The sub-agent's task, still under way: the one it has, or a new one in its place. */
+export function ensureThreadTask(thread: ThreadRow, now: Date): Promise> {
+ return Do(async ($) => {
+ if (thread.taskId === null) {
+ return $(Err(ApiErr.conflict('The agent talking with the person makes no calls of its own')));
+ }
+ const task = $(requirePresent($(await getAgentTask(thread.orgId, thread.agentId, thread.taskId)), ApiErr.notFound('task', thread.taskId)));
+ if (taskEndedAt({lastStartedAt: task.lastStartedAt, finishedAt: task.finishedAt}, now) === null) {
+ return task.id;
+ }
+ const made = $(
+ await createAgentTask({
+ orgId: thread.orgId,
+ agentId: thread.agentId,
+ ownerUserId: thread.userId,
+ personaId: thread.personaId,
+ parentId: null,
+ title: task.title,
+ goal: task.goal,
+ via: 'harness',
+ }),
+ );
+ const id = $(requirePresent(made, ApiErr.internalError(new Error('A task with no parent was not made')))).id;
+ $(await setThreadTask(thread.id, id));
+ return id;
+ });
+}
diff --git a/packages/api/src/harness/tool-job.test.ts b/packages/api/src/harness/tool-job.test.ts
new file mode 100644
index 00000000..458335af
--- /dev/null
+++ b/packages/api/src/harness/tool-job.test.ts
@@ -0,0 +1,179 @@
+import {beforeEach, describe, expect, mock, test} from 'bun:test';
+import {Ok} from 'ts-results-es';
+
+const getJobThread = mock();
+const getEntry = mock();
+const completeToolJob = mock(async () => Ok(true));
+const failJob = mock(async () => Ok(true));
+const finishJob = mock(async () => Ok(true));
+// The agent as it acts in the thread: signed in as the member whose conversation it is.
+const signedIn = {agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'proxy', name: 'Finance', orgSlug: 'acme', experiments: []};
+const getThreadAgent = mock(async () => Ok(signedIn));
+const startSubAgent = mock(async () => Ok(true));
+mock.module('@proxy/db/harness', () => ({getJobThread, getEntry, completeToolJob, failJob, finishJob, getThreadAgent, startSubAgent}));
+
+const ensureThreadTask = mock(async () => Ok('task-2'));
+mock.module('./thread-task', () => ({ensureThreadTask}));
+
+const callMcpTool = mock();
+mock.module('../mcp/tools', () => ({callMcpTool}));
+
+mock.module('./tool-surface', () => ({isGateTool: (name: string) => ['read', 'run', 'list_connections', 'list_tools', 'send_feedback'].includes(name)}));
+
+const now = new Date('2026-10-21T10:00:00Z');
+// A sub-agent's thread, and the member's conversation it was started from.
+const thread = {
+ id: 'thread-1',
+ orgId: 'org-1',
+ agentId: 'agent-1',
+ userId: 'user-1',
+ parentThreadId: 'thread-0',
+ parentCallId: 'c0',
+ title: 'Find the invoices',
+ taskId: 'task-1',
+ finishedAt: null,
+ createdAt: now,
+};
+const conversation = {...thread, id: 'thread-0', parentThreadId: null, parentCallId: null, title: null, taskId: null};
+
+function job(callId: string, attempts = 1) {
+ return {id: 'job-1', threadId: 'thread-1', kind: 'tool' as const, status: 'running' as const, entryId: 'entry-1', callId, runAt: now, claimedBy: 'w1', attempts};
+}
+
+function entryWith(calls: Array<{id: string; name: string; arguments: string}>) {
+ return {
+ id: 'entry-1',
+ seq: 2,
+ threadId: 'thread-1',
+ kind: 'assistant' as const,
+ text: '',
+ toolCalls: calls,
+ callId: null,
+ isError: false,
+ inputTokens: 1,
+ outputTokens: 1,
+ coversSeq: null,
+ system: null,
+ jobId: null,
+ createdAt: now,
+ };
+}
+
+beforeEach(() => {
+ mock.clearAllMocks();
+ getJobThread.mockResolvedValue(Ok(thread));
+ getEntry.mockResolvedValue(Ok(entryWith([{id: 'c1', name: 'read', arguments: '{"connection":"mail-1","tool":"emails_list"}'}])));
+ callMcpTool.mockResolvedValue({content: [{type: 'text', text: '{"records":[]}'}], structuredContent: {records: []}});
+});
+
+describe('runToolJob', () => {
+ test('runs a gate tool through the MCP dispatch as the built-in agent, for the sub-agent’s task', async () => {
+ const {runToolJob} = await import('./tool-job');
+
+ const result = await runToolJob(job('c1'), 'w1', now);
+
+ expect(result.isOk()).toBe(true);
+ expect(callMcpTool).toHaveBeenCalledWith(signedIn, 'read', {connection: 'mail-1', tool: 'emails_list', task: 'task-2'}, false, 'harness');
+ expect(completeToolJob).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', {callId: 'c1', text: '{"records":[]}', isError: false});
+ });
+
+ test('a delegation from the conversation starts the sub-agent on its title and brief, and answers nothing: the report will', async () => {
+ const {runToolJob} = await import('./tool-job');
+ getJobThread.mockResolvedValue(Ok(conversation));
+ getEntry.mockResolvedValue(Ok(entryWith([{id: 'c1', name: 'delegate', arguments: '{"title":" Find the invoices ","brief":"Search the mailbox for invoices from Dana."}'}])));
+
+ const result = await runToolJob(job('c1'), 'w1', now);
+
+ expect(result.isOk()).toBe(true);
+ expect(startSubAgent).toHaveBeenCalledWith('job-1', 'w1', conversation, {id: 'c1', title: 'Find the invoices', brief: 'Search the mailbox for invoices from Dana.'});
+ expect(completeToolJob).not.toHaveBeenCalled();
+ expect(callMcpTool).not.toHaveBeenCalled();
+
+ startSubAgent.mockResolvedValueOnce(Ok(false));
+ expect((await runToolJob(job('c1'), 'w1', now)).unwrapErr().kind).toBe('conflict');
+ });
+
+ test('the conversation’s agent has no other tool, and a sub-agent can’t delegate', async () => {
+ const {runToolJob} = await import('./tool-job');
+ getJobThread.mockResolvedValue(Ok(conversation));
+ getEntry.mockResolvedValue(
+ Ok(
+ entryWith([
+ {id: 'c1', name: 'delegate', arguments: '{"title":"Find"}'},
+ {id: 'c2', name: 'read', arguments: '{}'},
+ ]),
+ ),
+ );
+
+ await runToolJob(job('c1'), 'w1', now);
+ expect(completeToolJob).toHaveBeenLastCalledWith('job-1', 'w1', 'thread-0', {callId: 'c1', text: expect.stringContaining('Give title'), isError: true});
+ await runToolJob(job('c2'), 'w1', now);
+ expect(completeToolJob).toHaveBeenLastCalledWith('job-1', 'w1', 'thread-0', {callId: 'c2', text: 'There is no tool read.', isError: true});
+ expect(callMcpTool).not.toHaveBeenCalled();
+
+ getJobThread.mockResolvedValue(Ok(thread));
+ getEntry.mockResolvedValue(Ok(entryWith([{id: 'c1', name: 'delegate', arguments: '{"title":"Find","brief":"Search."}'}])));
+ await runToolJob(job('c1'), 'w1', now);
+ expect(completeToolJob).toHaveBeenLastCalledWith('job-1', 'w1', 'thread-1', {callId: 'c1', text: 'There is no tool delegate.', isError: true});
+ expect(startSubAgent).not.toHaveBeenCalled();
+ });
+
+ test('a refused call is answered as an error, in the gate’s words', async () => {
+ const {runToolJob} = await import('./tool-job');
+ callMcpTool.mockResolvedValue({content: [{type: 'text', text: 'This login may not run emails_list with this connection.'}], isError: true});
+
+ await runToolJob(job('c1'), 'w1', now);
+
+ expect(completeToolJob).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', {callId: 'c1', text: 'This login may not run emails_list with this connection.', isError: true});
+ });
+
+ test('a sleep answers with the time, since its job was due when the sleep ended', async () => {
+ const {runToolJob} = await import('./tool-job');
+ getEntry.mockResolvedValue(Ok(entryWith([{id: 'c1', name: 'sleep_until', arguments: '{"seconds":60}'}])));
+
+ await runToolJob(job('c1'), 'w1', now);
+
+ expect(callMcpTool).not.toHaveBeenCalled();
+ expect(completeToolJob).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', {callId: 'c1', text: 'You slept, and it is now 2026-10-21T10:00:00.000Z.', isError: false});
+ });
+
+ test('a write a worker was interrupted in is not made again: the model is told the outcome is unknown', async () => {
+ const {runToolJob} = await import('./tool-job');
+ getEntry.mockResolvedValue(Ok(entryWith([{id: 'c1', name: 'run', arguments: '{"connection":"mail-1","tool":"emails_send","params":{"id":"m1"}}'}])));
+
+ await runToolJob(job('c1', 2), 'w1', now);
+
+ expect(callMcpTool).not.toHaveBeenCalled();
+ expect(completeToolJob).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', {callId: 'c1', text: expect.stringContaining('outcome of this call is unknown'), isError: true});
+
+ // A read is simply made again.
+ getEntry.mockResolvedValue(Ok(entryWith([{id: 'c1', name: 'read', arguments: '{}'}])));
+ await runToolJob(job('c1', 2), 'w1', now);
+ expect(callMcpTool).toHaveBeenCalledTimes(1);
+ });
+
+ test('bad arguments, an unknown tool and a missing call are answered or failed, never thrown', async () => {
+ const {runToolJob} = await import('./tool-job');
+ getEntry.mockResolvedValue(
+ Ok(
+ entryWith([
+ {id: 'c1', name: 'read', arguments: 'not json'},
+ {id: 'c2', name: 'fly', arguments: '{}'},
+ ]),
+ ),
+ );
+
+ await runToolJob(job('c1'), 'w1', now);
+ expect(completeToolJob).toHaveBeenLastCalledWith('job-1', 'w1', 'thread-1', {callId: 'c1', text: 'The arguments were not a JSON object.', isError: true});
+
+ await runToolJob(job('c2'), 'w1', now);
+ expect(completeToolJob).toHaveBeenLastCalledWith('job-1', 'w1', 'thread-1', {callId: 'c2', text: 'There is no tool fly.', isError: true});
+
+ await runToolJob(job('c9'), 'w1', now);
+ expect(failJob).toHaveBeenCalledWith('job-1', 'w1', 'No call c9 in entry entry-1');
+
+ getJobThread.mockResolvedValue(Ok(null));
+ await runToolJob(job('c1'), 'w1', now);
+ expect(finishJob).toHaveBeenCalledWith('job-1', 'w1');
+ });
+});
diff --git a/packages/api/src/harness/tool-job.ts b/packages/api/src/harness/tool-job.ts
new file mode 100644
index 00000000..7292e925
--- /dev/null
+++ b/packages/api/src/harness/tool-job.ts
@@ -0,0 +1,115 @@
+import {Err, type Result} from 'ts-results-es';
+
+import {type ApiError, ApiErr, Do, requirePresent} from '@proxy/utils';
+import {callArguments, DELEGATE_TOOL, delegateArgs, SLEEP_TOOL, sleepUntil, type ToolCall} from '@proxy/utils/harness';
+import {completeToolJob, failJob, finishJob, getEntry, getJobThread, getThreadAgent, type JobRow, startSubAgent, type ThreadRow} from '@proxy/db/harness';
+
+import {callMcpTool} from '../mcp/tools';
+import {ensureThreadTask} from './thread-task';
+import {isGateTool} from './tool-surface';
+
+/**
+ * A tool job: one call the model made, run and answered in the log. A delegation starts the
+ * sub-agent, whose report answers the call later; the call was answered as started when it was
+ * made (inference.ts). The MCP server's tools go through the gate as the sub-agent acting as the
+ * member, for its task, logged like any agent's call. A sleep answers with the time, since its job
+ * was due when the sleep ended. A call that changes something is never run twice: a worker that
+ * was interrupted mid-call leaves the outcome unknown, and the model is told so rather than the
+ * call being made again.
+ */
+
+// How much of a result the log keeps, and the model reads.
+const MAX_RESULT_CHARS = 60_000;
+
+type ToolAnswer = {text: string; isError: boolean};
+
+function error(text: string): ToolAnswer {
+ return {text, isError: true};
+}
+
+// The call's arguments where they are a JSON object, as a tool takes them.
+function parseArgs(call: ToolCall): Record | null {
+ const args = callArguments(call);
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) {
+ return null;
+ }
+ return args as Record;
+}
+
+// A result cut to what the log keeps, saying so at its end, so the model knows there was more.
+function cutResult(text: string): string {
+ if (text.length <= MAX_RESULT_CHARS) {
+ return text;
+ }
+ return `${text.slice(0, MAX_RESULT_CHARS)}\n\n[Cut: the result was ${text.length} characters long.]`;
+}
+
+// Runs the call through the gate, as the sub-agent acts in the thread, for its task.
+function runGateTool(thread: ThreadRow, name: string, args: Record, now: Date): Promise> {
+ return Do(async ($) => {
+ const signedIn = $(requirePresent($(await getThreadAgent(thread)), ApiErr.notFound('agent', thread.agentId)));
+ const taskId = $(await ensureThreadTask(thread, now));
+ const result = await callMcpTool(signedIn, name, {...args, task: taskId}, false, 'harness');
+ return {text: cutResult(result.content.map((part) => part.text).join('\n')), isError: result.isError === true};
+ });
+}
+
+// Whether the call is a delegation the thread's agent may make, with the title and brief it gave.
+function delegation(thread: ThreadRow, call: ToolCall): {title: string; brief: string} | null {
+ if (call.name !== DELEGATE_TOOL || thread.parentThreadId !== null) {
+ return null;
+ }
+ return delegateArgs(parseArgs(call));
+}
+
+function answer(thread: ThreadRow, job: JobRow, call: ToolCall, now: Date): Promise> {
+ return Do(async ($) => {
+ const args = parseArgs(call);
+ if (args === null) {
+ return error('The arguments were not a JSON object.');
+ }
+ if (call.name === DELEGATE_TOOL && thread.parentThreadId === null) {
+ return error('Give title, a few words, and brief, what to do.');
+ }
+ if (call.name === SLEEP_TOOL && thread.parentThreadId !== null) {
+ if (sleepUntil(args, now) === null) {
+ return error('Give until, a time in ISO 8601, or seconds from now.');
+ }
+ return {text: `You slept, and it is now ${now.toISOString()}.`, isError: false};
+ }
+ if (!isGateTool(call.name) || thread.parentThreadId === null) {
+ return error(`There is no tool ${call.name}.`);
+ }
+ // A write run by a worker that lost the job mid-call may have gone through.
+ if (call.name === 'run' && job.attempts > 1) {
+ return error('The outcome of this call is unknown: the worker running it was interrupted before it could record the answer. Check what it changed before making it again.');
+ }
+ return $(await runGateTool(thread, call.name, args, now));
+ });
+}
+
+/** Runs the job: the call it is for, its answer written to the log, and the model woken once every call is answered; a delegation starts the sub-agent instead. */
+export function runToolJob(job: JobRow, workerId: string, now = new Date()): Promise> {
+ return Do(async ($) => {
+ const thread = $(await getJobThread(job.threadId));
+ if (!thread) {
+ $(await finishJob(job.id, workerId));
+ return;
+ }
+ const entry = $(await getEntry(job.entryId ?? ''));
+ const call = entry.toolCalls?.find((candidate) => candidate.id === job.callId);
+ if (!call) {
+ $(await failJob(job.id, workerId, `No call ${job.callId ?? ''} in entry ${entry.id}`));
+ return;
+ }
+ const delegated = delegation(thread, call);
+ if (delegated) {
+ if (!$(await startSubAgent(job.id, workerId, thread, {id: call.id, ...delegated}))) {
+ return $(Err(ApiErr.conflict('The job was no longer this worker’s')));
+ }
+ return;
+ }
+ const answered = $(await answer(thread, job, call, now));
+ $(await completeToolJob(job.id, workerId, thread.id, {callId: call.id, ...answered}));
+ });
+}
diff --git a/packages/api/src/harness/tool-surface.test.ts b/packages/api/src/harness/tool-surface.test.ts
new file mode 100644
index 00000000..48285283
--- /dev/null
+++ b/packages/api/src/harness/tool-surface.test.ts
@@ -0,0 +1,57 @@
+import {describe, expect, mock, test} from 'bun:test';
+
+// The catalog's modules read the api's env as they load, and ci has no .env: so the env is mocked,
+// and the modules imported after, since a static import would load before the mock.
+mock.module('../utils/env', () => ({env: {NODE_ENV: 'test', APP_URL: 'http://localhost:5173'}}));
+const {MCP_TOOLS} = await import('../mcp/tools');
+const {isGateTool, MAIN_AGENT_TOOLS, SUB_AGENT_TOOLS, toolsFor} = await import('./tool-surface');
+
+type Params = {properties: Record; required: string[]};
+
+function tool(name: string) {
+ const found = SUB_AGENT_TOOLS.find((candidate) => candidate.name === name);
+ if (!found) {
+ throw new Error(`No sub-agent tool ${name}`);
+ }
+ return {...found, parameters: found.parameters as Params};
+}
+
+describe('the main agent’s tools', () => {
+ test('are one: delegating, with a title and a brief', () => {
+ expect(MAIN_AGENT_TOOLS.map((candidate) => candidate.name)).toEqual(['delegate']);
+ expect((MAIN_AGENT_TOOLS[0]?.parameters as Params).required).toEqual(['title', 'brief']);
+ });
+
+ test('are the conversation’s, and a sub-agent gets its own', () => {
+ expect(toolsFor({parentThreadId: null})).toBe(MAIN_AGENT_TOOLS);
+ expect(toolsFor({parentThreadId: 'thread-1'})).toBe(SUB_AGENT_TOOLS);
+ });
+});
+
+describe('a sub-agent’s tools', () => {
+ test('are the MCP server’s without the two about tasks, and a sleep', () => {
+ expect(SUB_AGENT_TOOLS.map((candidate) => candidate.name)).toEqual(['list_connections', 'list_tools', 'read', 'run', 'send_feedback', 'sleep_until']);
+ });
+
+ test('take no task: the loop fills it in', () => {
+ for (const name of ['list_connections', 'list_tools', 'read', 'run']) {
+ expect(tool(name).parameters.properties).not.toHaveProperty('task');
+ expect(tool(name).parameters.required).not.toContain('task');
+ expect(tool(name).description).not.toContain('from start_task');
+ }
+ expect(tool('read').parameters.required).toEqual(['connection', 'tool']);
+ });
+
+ test('keep every other param the MCP server’s have', () => {
+ const run = MCP_TOOLS.find((candidate) => candidate.name === 'run');
+ expect(Object.keys(tool('run').parameters.properties)).toEqual(Object.keys(run?.inputSchema.properties ?? {}).filter((key) => key !== 'task'));
+ });
+
+ test('the gate’s tools are the MCP server’s but the task ones', () => {
+ expect(isGateTool('read')).toBe(true);
+ expect(isGateTool('send_feedback')).toBe(true);
+ expect(isGateTool('start_task')).toBe(false);
+ expect(isGateTool('sleep_until')).toBe(false);
+ expect(isGateTool('delegate')).toBe(false);
+ });
+});
diff --git a/packages/api/src/harness/tool-surface.ts b/packages/api/src/harness/tool-surface.ts
new file mode 100644
index 00000000..004c7b5d
--- /dev/null
+++ b/packages/api/src/harness/tool-surface.ts
@@ -0,0 +1,84 @@
+import {DELEGATE_TOOL, SLEEP_TOOL} from '@proxy/utils/harness';
+
+import {MCP_TOOLS, type McpToolDefinition} from '../mcp/tools';
+import type {ModelTool} from './model';
+
+/**
+ * The tools the built-in agent has. The agent talking with the person has one: to delegate a
+ * piece of work to a sub-agent, which reports back. A sub-agent has the MCP server's, as any agent
+ * gets them, less the two about tasks, since every call it makes is for its own task and the loop
+ * fills it in (tool-job.ts); and one of its own, to be woken later. Nothing else: everything done
+ * to the person's accounts goes through the same gate as Claude's calls.
+ */
+
+// The MCP tools a sub-agent doesn't need: its task is its own.
+const TASK_TOOLS = new Set(['start_task', 'finish_task']);
+
+const DELEGATE: ModelTool = {
+ name: DELEGATE_TOOL,
+ description:
+ 'Starts a sub-agent on a piece of work with the person’s accounts, with the same access as you. It answers at once that the sub-agent has started, and the sub-agent’s report comes to you as a message when it is done: go on meanwhile, or, when there is nothing to do until then, say so to the person and stop. Give `title`, what the work is in a few words, which the person sees, and `brief`, everything the sub-agent needs: it knows nothing of this conversation but the brief, and can’t ask you or the person anything. Several started in one turn run side by side.',
+ parameters: {
+ type: 'object',
+ properties: {
+ title: {type: 'string', description: 'What the work is, in a few words, such as "Find last week’s invoices"'},
+ brief: {
+ type: 'string',
+ description: 'What to do, with everything the sub-agent needs to know to do it: what the person asked for, what has been found so far, names, ids, dates, and what to report back.',
+ },
+ },
+ required: ['title', 'brief'],
+ additionalProperties: false,
+ },
+};
+
+const SLEEP: ModelTool = {
+ name: SLEEP_TOOL,
+ description:
+ 'Stops until a time, then wakes you with a note of the time. For checking back on something later, or doing what the brief asks for at a time it named. Give `until`, the time to wake at, in ISO 8601 with its offset, or `seconds` from now; at most a week. Say what you will do when you wake in your message, since it is all you will have then.',
+ parameters: {
+ type: 'object',
+ properties: {
+ until: {type: 'string', description: 'When to wake, in ISO 8601, such as 2026-10-22T07:30:00-07:00'},
+ seconds: {type: 'number', description: 'How many seconds from now to wake, instead of until'},
+ },
+ additionalProperties: false,
+ },
+};
+
+// What the tools that take a task are for, said without it; every other tool keeps the MCP
+// server's words.
+const DESCRIPTIONS: Record = {
+ list_connections:
+ 'Call this before reporting that a service isn’t connected: the person’s email, meeting notes, documents or issues may well be here. The accounts and information you can use, such as a mailbox or the person’s vault, each with its id and what you may do with it.',
+ list_tools:
+ 'The tools you have with one connection: each one’s name, what it does, whether it runs with read or run, its risk, and the JSON Schema of its params. Take `connection` from list_connections.',
+ read: 'Runs a tool that only reads, such as listing or opening emails: never changes anything. Take `connection` from list_connections and `tool` from list_tools.',
+ run: 'Runs a tool that changes something or reaches other people, such as archiving an email, saving a draft or sending one. Take `connection` from list_connections and `tool` from list_tools.',
+};
+
+// The MCP tool without its `task` param, which the loop fills in.
+function withoutTask(tool: McpToolDefinition): ModelTool {
+ const {task: _task, ...properties} = tool.inputSchema.properties;
+ return {
+ name: tool.name,
+ description: DESCRIPTIONS[tool.name] ?? tool.description,
+ parameters: {...tool.inputSchema, properties, required: tool.inputSchema.required.filter((name) => name !== 'task')},
+ };
+}
+
+/** The tools the agent talking with the person is given: delegating, and nothing else. */
+export const MAIN_AGENT_TOOLS: ModelTool[] = [DELEGATE];
+
+/** The tools a sub-agent is given: the MCP server's but the task ones, in the order it lists them, and a sleep. */
+export const SUB_AGENT_TOOLS: ModelTool[] = [...MCP_TOOLS.filter((tool) => !TASK_TOOLS.has(tool.name)).map(withoutTask), SLEEP];
+
+/** The tools the model is given on a thread: a sub-agent's, or the main agent's. */
+export function toolsFor(thread: {parentThreadId: string | null}): ModelTool[] {
+ return thread.parentThreadId === null ? MAIN_AGENT_TOOLS : SUB_AGENT_TOOLS;
+}
+
+/** Whether a tool call is for one of the MCP server's tools, run through the gate. */
+export function isGateTool(name: string): boolean {
+ return MCP_TOOLS.some((tool) => tool.name === name && !TASK_TOOLS.has(tool.name));
+}
diff --git a/packages/api/src/harness/worker.test.ts b/packages/api/src/harness/worker.test.ts
new file mode 100644
index 00000000..4d5a9c07
--- /dev/null
+++ b/packages/api/src/harness/worker.test.ts
@@ -0,0 +1,69 @@
+import {beforeEach, describe, expect, mock, test} from 'bun:test';
+import {Err, Ok, type Result} from 'ts-results-es';
+
+import {ApiErr, type ApiError} from '@proxy/utils';
+import type {JobRow} from '@proxy/db/harness';
+
+type Run = (job: JobRow, workerId: string) => Promise>;
+
+const claimJob = mock();
+const failJob = mock(async () => Ok(true));
+const renewLease = mock(async () => Ok(true));
+mock.module('@proxy/db/harness', () => ({claimJob, failJob, renewLease}));
+
+const runInference = mock(async () => Ok(undefined));
+mock.module('./inference', () => ({runInference}));
+
+const runToolJob = mock(async () => Ok(undefined));
+mock.module('./tool-job', () => ({runToolJob}));
+
+const now = new Date();
+const inference = {id: 'j1', threadId: 't1', kind: 'inference' as const, status: 'running' as const, entryId: null, callId: null, runAt: now, claimedBy: 'w', attempts: 1};
+const tool = {...inference, id: 'j2', kind: 'tool' as const, entryId: 'e1', callId: 'c1'};
+
+// Waits for the worker to get through what is queued.
+const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
+
+beforeEach(() => {
+ mock.clearAllMocks();
+});
+
+describe('the worker', () => {
+ test('claims jobs one after another and runs each by its kind, then stops claiming', async () => {
+ const {startHarnessWorker} = await import('./worker');
+ claimJob.mockResolvedValueOnce(Ok(inference)).mockResolvedValueOnce(Ok(tool)).mockResolvedValue(Ok(null));
+
+ const worker = startHarnessWorker();
+ await settle();
+ await worker.stop();
+ const claims = claimJob.mock.calls.length;
+ await settle();
+
+ expect(runInference).toHaveBeenCalledWith(inference, expect.any(String));
+ expect(runToolJob).toHaveBeenCalledWith(tool, expect.any(String));
+ expect(runInference.mock.calls[0]?.[1]).toBe(runToolJob.mock.calls[0]?.[1]);
+ expect(claimJob.mock.calls.length).toBe(claims);
+ });
+
+ test('a job that fails, or throws, is marked failed with why, and one claimed too often is given up', async () => {
+ const {startHarnessWorker} = await import('./worker');
+ runInference.mockResolvedValueOnce(Err(ApiErr.conflict('no longer mine'))).mockResolvedValueOnce(Err(ApiErr.internalError(new Error('OpenRouter refused the request (400): too long'))));
+ runToolJob.mockRejectedValueOnce(new Error('boom'));
+ claimJob
+ .mockResolvedValueOnce(Ok(inference))
+ .mockResolvedValueOnce(Ok(tool))
+ .mockResolvedValueOnce(Ok({...inference, id: 'j3', attempts: 5}))
+ .mockResolvedValueOnce(Ok({...inference, id: 'j4'}))
+ .mockResolvedValue(Ok(null));
+
+ const worker = startHarnessWorker();
+ await settle();
+ await worker.stop();
+
+ expect(failJob).toHaveBeenCalledWith('j1', expect.any(String), 'no longer mine');
+ expect(failJob).toHaveBeenCalledWith('j2', expect.any(String), 'boom');
+ expect(failJob).toHaveBeenCalledWith('j3', expect.any(String), 'Given up after 4 attempts');
+ expect(failJob).toHaveBeenCalledWith('j4', expect.any(String), 'OpenRouter refused the request (400): too long');
+ expect(runInference).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/api/src/harness/worker.ts b/packages/api/src/harness/worker.ts
new file mode 100644
index 00000000..a537f1a2
--- /dev/null
+++ b/packages/api/src/harness/worker.ts
@@ -0,0 +1,118 @@
+import {randomBytes} from 'node:crypto';
+import {hostname} from 'node:os';
+import {setTimeout as sleep} from 'node:timers/promises';
+
+import {Result} from 'ts-results-es';
+
+import type {ApiError} from '@proxy/utils';
+import {claimJob, failJob, type JobRow, renewLease} from '@proxy/db/harness';
+
+import {log, serializeError} from '../observability/log';
+import {runInference} from './inference';
+import {runToolJob} from './tool-job';
+
+/**
+ * The worker that runs the built-in agents' jobs, a few at a time, in each api instance: it
+ * claims the next job due while it has room, keeps each job's lease while it runs, and claims
+ * the next as soon as one is done, so a turn's calls, the sub-agents started side by side and the
+ * next turn follow each other without waiting; nothing due, it looks again in a moment. The queue
+ * is the database, so instances need nothing of each other: one that goes down mid-job loses its
+ * lease, and another picks the job up. Stopping, as for a deploy, the worker claims nothing more
+ * and finishes the jobs it is on, up to a point.
+ */
+
+const POLL_MS = 500;
+const LEASE_MS = 120_000;
+const RENEW_MS = 30_000;
+// How many jobs a worker runs at once.
+const CONCURRENCY = 4;
+// After this many claims, a job that keeps failing is given up.
+const MAX_ATTEMPTS = 4;
+// How long stopping waits for the jobs under way.
+const STOP_MS = 8_000;
+
+export type HarnessWorker = {stop: () => Promise};
+
+// One name per worker, so a lease says who holds it.
+function workerName(): string {
+ return `${hostname()}-${process.pid}-${randomBytes(3).toString('hex')}`;
+}
+
+function run(job: JobRow, workerId: string): Promise> {
+ if (job.kind === 'inference') {
+ return runInference(job, workerId);
+ }
+ return runToolJob(job, workerId);
+}
+
+// The failure in words, for the job's record and whoever is told of it.
+function describe(failure: ApiError): string {
+ if ('message' in failure) {
+ return failure.message;
+ }
+ if (failure.kind === 'not_found') {
+ return `There is no ${failure.resource}${failure.id ? ` ${failure.id}` : ''}`;
+ }
+ if ('cause' in failure) {
+ return failure.cause instanceof Error ? failure.cause.message : `${failure.kind}: ${JSON.stringify(failure.cause)}`;
+ }
+ return failure.kind;
+}
+
+// Runs the job with its lease kept, and marks it failed where it throws or fails.
+async function runClaimed(job: JobRow, workerId: string): Promise {
+ if (job.attempts > MAX_ATTEMPTS) {
+ await failJob(job.id, workerId, `Given up after ${job.attempts - 1} attempts`);
+ return;
+ }
+ const renewal = setInterval(() => {
+ void renewLease(job.id, workerId, LEASE_MS);
+ }, RENEW_MS);
+ const result = await Result.wrapAsync(() => run(job, workerId));
+ clearInterval(renewal);
+ const thrown = result.isErr() ? result.error : null;
+ const failed = result.isOk() && result.value.isErr() ? result.value.error : null;
+ if (thrown === null && failed === null) {
+ return;
+ }
+ log.error('Built-in agent job failed', {jobId: job.id, kind: job.kind, attempt: job.attempts, ...serializeError(failed ?? thrown)});
+ const message = failed ? describe(failed) : thrown instanceof Error ? thrown.message : String(thrown);
+ await failJob(job.id, workerId, message.slice(0, 1000));
+}
+
+/** Starts the worker; it runs until stopped. */
+export function startHarnessWorker(): HarnessWorker {
+ const workerId = workerName();
+ let stopping = false;
+ const running = new Set>();
+
+ const loop = async (): Promise => {
+ while (!stopping) {
+ if (running.size >= CONCURRENCY) {
+ await Promise.race(running);
+ continue;
+ }
+ const claimed = await claimJob(workerId, LEASE_MS);
+ if (claimed.isErr()) {
+ log.error('Built-in agent worker could not claim a job', serializeError(claimed.error));
+ await sleep(POLL_MS * 4);
+ continue;
+ }
+ if (claimed.value === null) {
+ await sleep(POLL_MS);
+ continue;
+ }
+ const job: Promise = runClaimed(claimed.value, workerId).finally(() => running.delete(job));
+ running.add(job);
+ }
+ };
+ void loop();
+ log.info('Built-in agent worker started', {workerId});
+
+ return {
+ stop: async () => {
+ stopping = true;
+ await Promise.race([Promise.all(running), sleep(STOP_MS)]);
+ },
+ };
+}
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 9793c2b5..c5139bd5 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -5,6 +5,7 @@ import {Result} from 'ts-results-es';
import {connectDb, disconnectDb} from '@proxy/db';
import {takeOverCustomConnections} from './connections/take-over-custom';
+import {startHarnessWorker, type HarnessWorker} from './harness/worker';
import {log, serializeError} from './observability/log';
import {convertOldVaultRecords} from './records/vault-entries';
import {createHttpApp} from './server/create-http-app';
@@ -25,6 +26,7 @@ async function startApiServer(): Promise {
process.exit(1);
}
startTelemetry();
+ let worker: HarnessWorker | null = null;
const shutdown = async (signal: string): Promise => {
if (isShuttingDown) {
@@ -42,6 +44,9 @@ async function startApiServer(): Promise {
forceExit.unref();
server.close();
+ // The built-in agent's worker claims nothing more and finishes the job it is on; a job it
+ // can't finish in time goes to another instance when its lease runs out.
+ await worker?.stop();
const disconnectResult = await Result.wrapAsync(() => disconnectDb());
if (disconnectResult.isErr()) {
@@ -63,5 +68,6 @@ async function startApiServer(): Promise {
// After listening, since signing in to a provider to name an account can take a while.
void takeOverCustomConnections();
void convertOldVaultRecords();
+ worker = startHarnessWorker();
});
}
diff --git a/packages/api/src/mcp/route.test.ts b/packages/api/src/mcp/route.test.ts
index 3dc3930a..eaf2ac1f 100644
--- a/packages/api/src/mcp/route.test.ts
+++ b/packages/api/src/mcp/route.test.ts
@@ -137,13 +137,13 @@ describe('handleMcpRoute', () => {
listAgentConnections.mockResolvedValue(Ok({agent: {id: 'agent-1', providerId: 'claude', name: 'Claude'}, connections: [vault, mailbox]}));
const reading = await instructions();
expect(reading).toContain('The vault, connection vault-1, is your memory of the person');
- expect(reading).toContain('search it with entries_list');
+ expect(reading).toContain('read entries_list with a few words as `search`');
expect(reading).toContain('suggest they add it to their vault');
expect(reading).not.toContain('entries_create');
listAgentConnections.mockResolvedValue(Ok({agent: {id: 'agent-1', providerId: 'claude', name: 'Claude'}, connections: [{...vault, actions: ['readEntries', 'writeEntries']}]}));
const saving = await instructions();
- expect(saving).toContain('save it there with entries_create');
+ expect(saving).toContain('save it there: run entries_create with `title`, a few words, `type`, one of Text, Address, Phone number, Email, Link or Date, and `content`.');
expect(saving).toContain('edit that entry rather than adding another');
});
@@ -157,7 +157,7 @@ describe('handleMcpRoute', () => {
expect(alone).toContain('- Vault (Acme), connection vault-2, shared with everyone in the organization: Read entries, Edit entries');
expect(alone).toContain('- Email (alex@example.com), connection mail-1, shared with everyone in the organization: Read, Archive');
expect(alone).toContain('The organization’s vault, connection vault-2, is the memory everyone in the organization and their agents share');
- expect(alone).toContain('save it in the organization’s vault with entries_create');
+ expect(alone).toContain('save it in the organization’s vault: run entries_create with `title`');
expect(alone).not.toContain('memory of the person');
expect(alone).not.toContain('belongs in their own vault');
diff --git a/packages/api/src/mcp/tools.ts b/packages/api/src/mcp/tools.ts
index 8d4db279..7f374e20 100644
--- a/packages/api/src/mcp/tools.ts
+++ b/packages/api/src/mcp/tools.ts
@@ -3,7 +3,7 @@ import {z} from 'zod';
import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils';
import type {SignedInAgent} from '@proxy/db/agent';
-import {findIntegration, integrationFor, INTEGRATIONS, isOffered, VAULT_INTEGRATION_ID, type Tool} from '@proxy/integrations';
+import {findIntegration, integrationFor, INTEGRATIONS, isOffered, VAULT_ENTRY_TYPES, VAULT_INTEGRATION_ID, type Tool} from '@proxy/integrations';
import {sendAgentFeedback} from '../agent-side/feedback';
import {finishAgentTask, startAgentTask} from '../agent-side/tasks';
@@ -174,6 +174,9 @@ function vaultParagraph(vault: AgentConnection, {reading, saving, readOnly}: Vau
return `${reading} ${saving}`;
}
+// What entries_create takes, so an agent told to save there needn't list the vault's tools first.
+const ENTRY_PARAMS = `\`title\`, a few words, \`type\`, one of ${VAULT_ENTRY_TYPES.slice(0, -1).join(', ')} or ${VAULT_ENTRY_TYPES.at(-1)}, and \`content\`.`;
+
// The vaults as the agent's memory, as far as it may use each: none without reading it. The
// person's own, for an agent of their own, and the organization's, shared with everyone in it,
// which a shared agent has alone; with both, what is whose goes where.
@@ -185,9 +188,8 @@ function vaultInstructions(connections: AgentConnection[]): string[] {
if (own) {
paragraphs.push(
vaultParagraph(own, {
- reading: `The vault, connection ${own.id}, is your memory of the person, shared with every agent they use: what they or their agents chose to keep, such as their address, their phone number or how they like things done. Before asking them something about themselves, search it with entries_list.`,
- saving:
- 'When you learn something about them that would help next time, save it there with entries_create: a short title, its type and the content. When something kept there has changed, edit that entry rather than adding another.',
+ reading: `The vault, connection ${own.id}, is your memory of the person, shared with every agent they use: what they or their agents chose to keep, such as their address, their phone number or how they like things done. Before asking them something about themselves, search it: read entries_list with a few words as \`search\`.`,
+ saving: `When you learn something about them that would help next time, save it there: run entries_create with ${ENTRY_PARAMS} When something kept there has changed, edit that entry rather than adding another.`,
readOnly: 'You may only read it: when they tell you something worth keeping for next time, suggest they add it to their vault.',
}),
);
@@ -196,9 +198,8 @@ function vaultInstructions(connections: AgentConnection[]): string[] {
const whose = own ? ' What is about the person alone belongs in their own vault, what everyone there should know in the organization’s.' : '';
paragraphs.push(
vaultParagraph(shared, {
- reading: `The organization’s vault, connection ${shared.id}, is the memory everyone in the organization and their agents share: what any of them chose to keep for all, such as the office address, how the team does things or who handles what. Before asking about the organization, search it with entries_list.${whose}`,
- saving:
- 'When you learn something that would help everyone there next time, save it in the organization’s vault with entries_create: a short title, its type and the content. When something kept there has changed, edit that entry rather than adding another.',
+ reading: `The organization’s vault, connection ${shared.id}, is the memory everyone in the organization and their agents share: what any of them chose to keep for all, such as the office address, how the team does things or who handles what. Before asking about the organization, search it: read entries_list with a few words as \`search\`.${whose}`,
+ saving: `When you learn something that would help everyone there next time, save it in the organization’s vault: run entries_create with ${ENTRY_PARAMS} When something kept there has changed, edit that entry rather than adding another.`,
readOnly: 'You may only read it: when you are told something everyone there should know, suggest adding it to the organization’s vault.',
}),
);
@@ -206,8 +207,21 @@ function vaultInstructions(connections: AgentConnection[]): string[] {
return paragraphs;
}
+// How an MCP client is to go about it: tasks first, then its connections and their tools.
+const MCP_FLOW =
+ 'Start with start_task: say what you are about to do for the person, with a title and the goal, and give the id it answers with as `task` in every other call. Then list_connections lists the accounts you can use and what you may do with each. list_tools with a connection gives that connection’s tools and the params each takes. Don’t guess a tool’s params: where these instructions don’t give them, call list_tools for the connection before you first read or run one of its tools. read runs the tools that only read; run runs the ones that change something or reach other people. Call finish_task when the task is done, saying what was done as outcome, and start a new task for the next thing the person asks for. When you hand part of a task to a sub-agent, start a sub-task for it first, with your task as parent, and give the sub-agent its id to use as task; when you are a sub-agent and were given a task id that way, use it, and don’t start a task of your own.';
+
/** The server's instructions for this agent, given when a client connects. */
export function serverInstructions(signedIn: SignedInAgent): Promise> {
+ return agentInstructions(signedIn, MCP_FLOW);
+}
+
+/**
+ * What any agent with this login is told: who it is, how to go about it (`flow`, which is where
+ * the MCP server and the built-in agent differ), what it can use, the vault, where to send the
+ * person, and that what records say is information. The built-in agent's loop gives these too.
+ */
+export function agentInstructions(signedIn: SignedInAgent, flow: string): Promise> {
return Do(async ($) => {
const appUrl = $(requireAppUrl());
const {connections} = $(await listAgentConnections(signedIn));
@@ -219,7 +233,7 @@ export function serverInstructions(signedIn: SignedInAgent): Promise 0 ? `When you connected, you could use:\n${lines.join('\n')}` : 'This login has no access yet: the person who made it has to give it some.',
...vaultInstructions(connections),
`When the person asks about a service or their own information, such as their email, meetings, notes, documents, issues, websites, bank accounts or databases, check Personal Agent Proxy first: there is a good chance it is connected here rather than as a connector of your own. Call list_connections before saying you can’t reach it or suggesting another connector. Personal Agent Proxy connects ${connectableServices(signedIn)}; when the person wants one of these and it isn’t connected yet, recommend connecting it in Personal Agent Proxy at ${appUrl}/${signedIn.orgSlug}/connections/new rather than a separate connector, so it works with every agent they use, with the access they choose.`,
@@ -289,10 +303,13 @@ function errorMessage(error: ApiError, args: Record): string {
return 'Something went wrong. Try again in a moment.';
}
+/** Which way a call of the seven tools came in: the MCP server, or the built-in agent's loop. */
+export type McpToolVia = 'mcp' | 'harness';
+
// PRE-TASK CLIENTS: `preTask` is whether the call is a pre-task client's (pre-task-clients.ts).
-function call(signedIn: SignedInAgent, name: string, args: Record, preTask: boolean): Promise, ApiError>> {
+function call(signedIn: SignedInAgent, name: string, args: Record, preTask: boolean, via: McpToolVia): Promise, ApiError>> {
return Do(async ($) => {
- const caller = {agent: signedIn, via: 'mcp'} as const;
+ const caller = {agent: signedIn, via} as const;
if (name === 'start_task') {
return $(await startAgentTask(caller, args));
}
@@ -326,9 +343,9 @@ function call(signedIn: SignedInAgent, name: string, args: Record, preTask: boolean): Promise {
- const result = await call(signedIn, name, args, preTask);
+/** Calls one of the seven tools as the signed-in agent, the way it came in logged with it. */
+export async function callMcpTool(signedIn: SignedInAgent, name: string, args: Record, preTask: boolean, via: McpToolVia = 'mcp'): Promise {
+ const result = await call(signedIn, name, args, preTask, via);
if (result.isErr()) {
return {content: [{type: 'text', text: errorMessage(result.error, args)}], isError: true};
}
diff --git a/packages/api/src/server/create-api-router.ts b/packages/api/src/server/create-api-router.ts
index 06621acf..9b345794 100644
--- a/packages/api/src/server/create-api-router.ts
+++ b/packages/api/src/server/create-api-router.ts
@@ -46,6 +46,7 @@ import {
handleShareConnectionRoute,
} from '../connections/route';
import {handleFaviconRoute} from '../favicons/route';
+import {handleCreatePersonaRoute, handleDeletePersonaRoute, handleGetThreadRoute, handleListPersonasRoute, handlePostMessageRoute, handleUpdatePersonaRoute} from '../harness/route';
import {handleFillIntentRoute, handlePickIntentRoute} from '../intent/route';
import {handleMcpMethodNotAllowedRoute, handleMcpRoute} from '../mcp/route';
import {handleDeleteMeRoute, handleMeRoute} from '../me/route';
@@ -209,6 +210,16 @@ export function createApiRouter(): express.Router {
org.get('/widgets/accounts', withAuthResult('Widget bank accounts route', handleBankAccountsRoute));
org.get('/widgets/transactions', withAuthResult('Widget bank transactions route', handleBankTransactionsRoute));
+ // The Chat page: the organization's built-in agents and the member's conversations with them.
+ // Nothing here runs the model: the worker in each api instance does (harness/worker.ts); a
+ // message queues its work.
+ org.get('/chat/personas', withAuthResult('List personas route', handleListPersonasRoute));
+ org.post('/chat/personas', withAuthResult('Create persona route', handleCreatePersonaRoute));
+ org.put('/chat/personas/:personaId', withAuthResult('Update persona route', handleUpdatePersonaRoute));
+ org.delete('/chat/personas/:personaId', withAuthResult('Delete persona route', handleDeletePersonaRoute));
+ org.get('/chat/personas/:personaId/thread', withAuthResult('Get chat thread route', handleGetThreadRoute));
+ org.post('/chat/personas/:personaId/messages', withAuthResult('Post chat message route', handlePostMessageRoute));
+
// The agent side: its own sign-in and cookie, and every request made for one of the agent's
// tasks, checked against the agent's access and logged with the task.
router.post('/agent-auth/login', requireBrowserOrigin, wrapAsyncRoute('Agent login route', handleAgentLoginRoute));
diff --git a/packages/api/src/utils/env-schema.ts b/packages/api/src/utils/env-schema.ts
index 72483b7d..b573a28e 100644
--- a/packages/api/src/utils/env-schema.ts
+++ b/packages/api/src/utils/env-schema.ts
@@ -107,7 +107,7 @@ export const envSchema = z.object({
OPENROUTER_KEY: documented(z.string().min(1).optional(), {
group: 'Core',
description:
- 'An OpenRouter API key, for the model that fills in the fields of the widget Jev picks on the home page (gpt-oss-120b, on Cerebras or Groq). What is typed there is sent to it. Without it, or without `TYPESAFE_API_KEY`, the home page says it is not set up; everything else works as before.',
+ 'An OpenRouter API key, for the model that fills in the fields of the widget Jev picks on the home page (gpt-oss-120b, on Cerebras or Groq), and for the built-in agent on the Chat page (Claude Sonnet 5.5). What is typed there is sent to it. Without it, or without `TYPESAFE_API_KEY`, the home page says it is not set up, and without it the Chat page does; everything else works as before.',
}),
NODE_ENV: documented(z.enum(['development', 'test', 'production']).default('production'), {
diff --git a/packages/db/package.json b/packages/db/package.json
index 425a07a4..c499c175 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -10,6 +10,7 @@
"./auth": "./src/auth.ts",
"./connection": "./src/connection.ts",
"./feedback": "./src/feedback.ts",
+ "./harness": "./src/harness.ts",
"./vault": "./src/vault.ts",
"./invitation": "./src/invitation.ts",
"./instance-meta": "./src/instance-meta.ts",
diff --git a/packages/db/prisma/migrations/v034_add_built_in_agent_threads/migration.sql b/packages/db/prisma/migrations/v034_add_built_in_agent_threads/migration.sql
new file mode 100644
index 00000000..610ca49c
--- /dev/null
+++ b/packages/db/prisma/migrations/v034_add_built_in_agent_threads/migration.sql
@@ -0,0 +1,123 @@
+-- The Chat page: the organization's personas, agents Personal Agent Proxy runs itself, each named
+-- and given a head, all acting through the organization's one built-in agent login; a thread per
+-- member and persona, the member's conversation with it, and a thread per sub-agent started from
+-- it, each with its log of entries (what the person said, what the model answered with its tool
+-- calls, what each call answered, events that came since, and summaries of the log once it has
+-- grown long), and the jobs that run them (the model on a log, one tool call), claimed by one api
+-- instance at a time with a lease. A sub-agent's task says which persona it ran as. Their requests
+-- come in a third way, `harness`, beside the agent pages and the MCP server.
+
+-- CreateEnum
+CREATE TYPE "AgentEntryKind" AS ENUM ('person', 'assistant', 'tool_result', 'event', 'summary');
+
+-- CreateEnum
+CREATE TYPE "AgentJobKind" AS ENUM ('inference', 'tool');
+
+-- CreateEnum
+CREATE TYPE "AgentJobStatus" AS ENUM ('queued', 'running', 'done', 'failed');
+
+-- AlterEnum
+ALTER TYPE "AuditVia" ADD VALUE IF NOT EXISTS 'harness';
+
+-- AlterTable
+ALTER TABLE "AgentTask" ADD COLUMN "personaId" TEXT;
+
+-- CreateTable
+CREATE TABLE "Persona" (
+ "id" TEXT NOT NULL,
+ "orgId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "look" TEXT NOT NULL,
+ "color" TEXT NOT NULL,
+ "createdById" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Persona_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "AgentThread" (
+ "id" TEXT NOT NULL,
+ "orgId" TEXT NOT NULL,
+ "agentId" TEXT NOT NULL,
+ "personaId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "parentThreadId" TEXT,
+ "parentCallId" TEXT,
+ "title" TEXT,
+ "taskId" TEXT,
+ "finishedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AgentThread_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "AgentEntry" (
+ "id" TEXT NOT NULL,
+ "seq" SERIAL NOT NULL,
+ "threadId" TEXT NOT NULL,
+ "kind" "AgentEntryKind" NOT NULL,
+ "text" TEXT NOT NULL,
+ "toolCalls" TEXT,
+ "callId" TEXT,
+ "isError" BOOLEAN NOT NULL DEFAULT false,
+ "inputTokens" INTEGER,
+ "outputTokens" INTEGER,
+ "coversSeq" INTEGER,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AgentEntry_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "AgentJob" (
+ "id" TEXT NOT NULL,
+ "threadId" TEXT NOT NULL,
+ "kind" "AgentJobKind" NOT NULL,
+ "status" "AgentJobStatus" NOT NULL DEFAULT 'queued',
+ "entryId" TEXT,
+ "callId" TEXT,
+ "runAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "claimedBy" TEXT,
+ "leaseExpiresAt" TIMESTAMP(3),
+ "attempts" INTEGER NOT NULL DEFAULT 0,
+ "error" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AgentJob_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Persona_orgId_createdAt_idx" ON "Persona"("orgId", "createdAt");
+
+-- CreateIndex
+CREATE INDEX "AgentThread_personaId_userId_idx" ON "AgentThread"("personaId", "userId");
+
+-- CreateIndex
+CREATE INDEX "AgentThread_agentId_idx" ON "AgentThread"("agentId");
+
+-- CreateIndex
+CREATE INDEX "AgentThread_parentThreadId_idx" ON "AgentThread"("parentThreadId");
+
+-- CreateIndex
+CREATE INDEX "AgentThread_orgId_createdAt_idx" ON "AgentThread"("orgId", "createdAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "AgentEntry_seq_key" ON "AgentEntry"("seq");
+
+-- CreateIndex
+CREATE INDEX "AgentEntry_threadId_seq_idx" ON "AgentEntry"("threadId", "seq");
+
+-- CreateIndex
+CREATE INDEX "AgentJob_status_runAt_idx" ON "AgentJob"("status", "runAt");
+
+-- CreateIndex
+CREATE INDEX "AgentJob_threadId_idx" ON "AgentJob"("threadId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "AgentJob_entryId_callId_key" ON "AgentJob"("entryId", "callId");
+
diff --git a/packages/db/prisma/migrations/v035_hold_pending_entries/migration.sql b/packages/db/prisma/migrations/v035_hold_pending_entries/migration.sql
new file mode 100644
index 00000000..cf917812
--- /dev/null
+++ b/packages/db/prisma/migrations/v035_hold_pending_entries/migration.sql
@@ -0,0 +1,6 @@
+-- An entry that lands in a thread while the model is answering on its log, the person speaking or
+-- a sub-agent reporting, is pending: not in the log the model reads yet, and given its place after
+-- the answer once that is written.
+
+-- AlterTable
+ALTER TABLE "AgentEntry" ADD COLUMN "pending" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/db/prisma/migrations/v036_add_job_events_and_entry_provenance/migration.sql b/packages/db/prisma/migrations/v036_add_job_events_and_entry_provenance/migration.sql
new file mode 100644
index 00000000..009e95c9
--- /dev/null
+++ b/packages/db/prisma/migrations/v036_add_job_events_and_entry_provenance/migration.sql
@@ -0,0 +1,30 @@
+-- What a debug inspector can't rebuild from a thread's log: an assistant entry's system prompt,
+-- built from the login's grants as they were then; the job that wrote each entry the worker
+-- wrote; and what happened to each job, one row per happening, since the job's row is the
+-- queue's lock and keeps only its last attempt.
+
+-- CreateEnum
+CREATE TYPE "AgentJobEventKind" AS ENUM ('claimed', 'requeued', 'done', 'failed');
+
+-- AlterTable
+ALTER TABLE "AgentEntry" ADD COLUMN "jobId" TEXT,
+ADD COLUMN "system" TEXT;
+
+-- CreateTable
+CREATE TABLE "AgentJobEvent" (
+ "id" TEXT NOT NULL,
+ "jobId" TEXT NOT NULL,
+ "threadId" TEXT NOT NULL,
+ "kind" "AgentJobEventKind" NOT NULL,
+ "workerId" TEXT,
+ "error" TEXT,
+ "at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AgentJobEvent_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "AgentJobEvent_jobId_at_idx" ON "AgentJobEvent"("jobId", "at");
+
+-- CreateIndex
+CREATE INDEX "AgentJobEvent_threadId_at_idx" ON "AgentJobEvent"("threadId", "at");
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 233a917e..b33778d1 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -40,6 +40,7 @@ model User {
ownedOrgs Organization[]
connections Connection[]
agents Agent[]
+ threads AgentThread[]
writeTargets WriteTarget[]
}
@@ -117,6 +118,8 @@ model Organization {
audit AuditEntry[]
feedback AgentFeedback[]
tasks AgentTask[]
+ threads AgentThread[]
+ personas Persona[]
@@index([ownerUserId])
}
@@ -288,6 +291,7 @@ model Agent {
sessions AgentSession[]
oauthCodes OAuthCode[]
oauthGrants OAuthGrant[]
+ threads AgentThread[]
@@index([orgId])
@@index([ownerUserId])
@@ -335,10 +339,12 @@ enum AuditOutcome {
failed
}
-// How an agent came in: the web app's agent pages, or the MCP server.
+// How an agent came in: the web app's agent pages, the MCP server, or the built-in agent's own
+// loop (`harness`).
enum AuditVia {
web
mcp
+ harness
}
// One request an agent made, allowed, denied or failed, or one thing Personal Agent Proxy did
@@ -412,6 +418,9 @@ model AgentTask {
// The member whose own the agent is, who alone sees the task; null for a shared agent's, which
// everyone in the organization sees.
ownerUserId String?
+ // The persona a sub-agent of the Chat page ran the task as (Persona), for the activity log to
+ // say whose it was; null for every other task. As a column, as the agent is.
+ personaId String?
// When it, or the last of its sub-tasks at any depth, was started: one never finished is taken
// as abandoned a day after (@proxy/utils/tasks). Never before any of its sub-tasks', so a parent
// is never abandoned while one is under way. Set with createdAt, and on every task above a
@@ -524,3 +533,197 @@ model OAuthGrant {
@@index([agentId])
@@index([userId])
}
+
+// A persona of the Chat page: an agent Personal Agent Proxy runs itself, as the organization's
+// members see it, named and given a head, one of any number. Every persona acts through the
+// organization's one built-in agent login (Agent, provider `proxy`), whose grants are every
+// persona's: a persona is who did the work, the login is what it may do.
+model Persona {
+ id String @id @default(cuid())
+ orgId String
+ name String
+ // Its head: the look as JSON (`@proxy/utils/harness` Look), in one of the colors.
+ look String
+ color String
+ // Who made it, who may change it beside the organization's owner.
+ createdById String?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
+ threads AgentThread[]
+
+ @@index([orgId, createdAt])
+}
+
+// A thread of the built-in agent, holding its log of entries: a member's conversation with a
+// persona, or a sub-agent started from it. Every call a sub-agent makes is for its thread's task,
+// as any agent's calls are; a task abandoned a day on is replaced by a new one. A conversation has
+// no task: its agent makes no calls but to delegate. Everything that happens to the agent is an
+// entry (AgentEntry), and everything that makes it do something is a job (AgentJob).
+model AgentThread {
+ id String @id @default(cuid())
+ orgId String
+ // The organization's built-in agent login, which the persona acts through: its grants, its
+ // activity log.
+ agentId String
+ // The persona the conversation is with, or the sub-agent works as.
+ personaId String
+ // The member whose conversation with the agent it is, or, for a sub-agent's thread, whose
+ // conversation it was started from: the agent acts as them in it, reaching what they see, and
+ // nobody else sees it.
+ userId String
+ // A sub-agent's thread: the thread whose agent delegated to it (AgentThread; as a column, as a
+ // task's parent is, since its agent, member and organization are the same, and go with them),
+ // and the call in that thread's log it was started by, which its report answers. Null for a
+ // member's conversation, of which there is one per member and agent.
+ parentThreadId String?
+ parentCallId String?
+ // A sub-agent's title, as the agent that delegated to it gave it; null for a conversation.
+ title String?
+ // The task a sub-agent's calls are made for, the sub-agent's own; a conversation has none: its
+ // agent makes no calls but to delegate.
+ taskId String?
+ // When a sub-agent reported, or failed: its work is done, and nothing more runs on its thread.
+ finishedAt DateTime?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
+ agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
+ persona Persona @relation(fields: [personaId], references: [id], onDelete: Cascade)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ entries AgentEntry[]
+ jobs AgentJob[]
+ jobEvents AgentJobEvent[]
+
+ @@index([personaId, userId])
+ @@index([agentId])
+ @@index([parentThreadId])
+ @@index([orgId, createdAt])
+}
+
+// What an entry of a thread's log is: what the person said (a sub-agent's brief, in its thread),
+// what the model answered, with the tool calls it made, what one of those calls answered, an
+// event that came since (what a call run in the background came to, a sub-agent's report), or a
+// summary of the log up to a point, which the model reads in place of what it covers once the log
+// has grown long.
+enum AgentEntryKind {
+ person
+ assistant
+ tool_result
+ event
+ summary
+}
+
+// One entry of a thread's log, in the order of `seq`. The log is the transcript the model reads:
+// an assistant entry carries its tool calls, and each gets a tool_result entry once run. An entry
+// that lands while the model is answering on the log is pending: it is not in the log yet, and
+// gets its place, a new `seq` after the answer, once the answer is written.
+model AgentEntry {
+ id String @id @default(cuid())
+ seq Int @unique @default(autoincrement())
+ threadId String
+ kind AgentEntryKind
+ pending Boolean @default(false)
+ // What the person said, what the model said (maybe nothing beside its calls), or what a call
+ // answered.
+ text String
+ // An assistant entry's tool calls as JSON (`@proxy/utils/harness` ToolCall[]); null otherwise.
+ toolCalls String?
+ // A tool_result or event entry's call, by the id the model gave it; null otherwise.
+ callId String?
+ // A tool_result or event that is an error, in words the model can act on.
+ isError Boolean @default(false)
+ // An assistant entry's cost.
+ inputTokens Int?
+ outputTokens Int?
+ // A summary entry's reach: the `seq` of the last entry it stands for; null otherwise.
+ coversSeq Int?
+ // An assistant entry's system prompt, as the model was sent it: built from the login's grants
+ // as they were then, which nothing else keeps. Null where the model wasn't run for the answer.
+ system String?
+ // The job that wrote the entry (AgentJob; as a column: the two live and die with the thread),
+ // with its attempts and its error; null for what the person wrote.
+ jobId String?
+
+ createdAt DateTime @default(now())
+
+ thread AgentThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
+
+ @@index([threadId, seq])
+}
+
+enum AgentJobKind {
+ inference
+ tool
+}
+
+enum AgentJobStatus {
+ queued
+ running
+ done
+ failed
+}
+
+// A unit of the built-in agent's work, claimed by one api instance at a time: run the model on a
+// thread's log (inference), or run one tool call of an assistant entry (tool). `runAt` is when it
+// may run, which is how a call to sleep is kept. A worker claims a job due with a lease, renews
+// the lease while it works, and finishes the job in the same transaction as the entries it writes,
+// only while it still holds the lease; a lease that runs out gives the job to the next worker.
+model AgentJob {
+ id String @id @default(cuid())
+ threadId String
+ kind AgentJobKind
+ status AgentJobStatus @default(queued)
+ // A tool job's call: the assistant entry it is in and the id the model gave it.
+ entryId String?
+ callId String?
+ runAt DateTime @default(now())
+ // Which worker holds it, and until when, while it is running.
+ claimedBy String?
+ leaseExpiresAt DateTime?
+ attempts Int @default(0)
+ // Why it failed, where it did.
+ error String?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+
+ thread AgentThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
+
+ @@unique([entryId, callId])
+ @@index([status, runAt])
+ @@index([threadId])
+}
+
+enum AgentJobEventKind {
+ claimed
+ requeued
+ done
+ failed
+}
+
+// What happened to a job, one row per happening, written once: a job's row is the queue's lock,
+// overwritten on every claim, so it keeps only its last attempt; this keeps every claim, who
+// made it and when, each failure and why, and each time it was put back. A claim's `at` is when
+// the worker took the job, so near when its turn was prepared.
+model AgentJobEvent {
+ id String @id @default(cuid())
+ jobId String
+ // The job's thread, so a thread's history is one query.
+ threadId String
+ kind AgentJobEventKind
+ // The worker, on claimed, done and failed.
+ workerId String?
+ // Why, on failed, and on requeued: why it is to be tried again.
+ error String?
+ at DateTime @default(now())
+
+ thread AgentThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
+
+ @@index([jobId, at])
+ @@index([threadId, at])
+}
diff --git a/packages/db/src/agent.ts b/packages/db/src/agent.ts
index c8f0e27a..a6981c47 100644
--- a/packages/db/src/agent.ts
+++ b/packages/db/src/agent.ts
@@ -57,6 +57,35 @@ export async function getAgentById(
return wrapDb(() => db.agent.findFirst({where: {id: agentId, orgId}, select: agentSelect}));
}
+/**
+ * The organization's one shared agent of a provider, such as its built-in login, made by nobody
+ * in particular with what `make` gives where there is none yet: the organization's row is locked
+ * meanwhile, so two first uses make one.
+ */
+export async function ensureSharedAgent(
+ orgId: string,
+ providerId: string,
+ make: () => Promise<{name: string; username: string; passwordHash: string}>,
+): Promise> {
+ return wrapDb(() =>
+ db.$transaction(async (tx) => {
+ await tx.$queryRaw`SELECT "id" FROM "Organization" WHERE "id" = ${orgId} FOR UPDATE`;
+ const existing = await tx.agent.findFirst({
+ where: {orgId, providerId, ownerUserId: null},
+ select: agentSelect,
+ orderBy: {createdAt: 'asc'},
+ });
+ if (existing) {
+ return existing;
+ }
+ return tx.agent.create({
+ data: {orgId, providerId, ownerUserId: null, createdById: null, ...(await make())},
+ select: agentSelect,
+ });
+ }),
+ );
+}
+
/** `Ok(null)` means no such agent the viewer sees. */
export async function getAgent(
viewer: MemberViewer,
@@ -74,7 +103,8 @@ export async function getAgent(
export async function createAgent(data: {
orgId: string;
ownerUserId: string | null;
- createdById: string;
+ // Who made it; null for the organization's built-in login, made by nobody in particular.
+ createdById: string | null;
providerId: string;
name: string;
username: string;
diff --git a/packages/db/src/harness.test.ts b/packages/db/src/harness.test.ts
new file mode 100644
index 00000000..db2eaf3c
--- /dev/null
+++ b/packages/db/src/harness.test.ts
@@ -0,0 +1,198 @@
+import {afterAll, beforeAll, describe, expect, test} from 'bun:test';
+import type {Result} from 'ts-results-es';
+
+import type {ApiError} from '@proxy/utils';
+
+import {db} from '.';
+import {
+ appendPersonMessage,
+ appendSummary,
+ claimJob,
+ completeInference,
+ completeToolJob,
+ ensureFirstPersona,
+ ensureMemberThread,
+ failJob,
+ finishJob,
+ listEntries,
+ listJobEvents,
+ listOpenJobs,
+ requeueJob,
+ type ThreadRow,
+} from './harness';
+
+// What the harness writes, against the local database: run with it up (`bun run dev`) and the
+// environment loaded, `bun --env-file=../../.env test src/harness.test.ts` from this package.
+// Without DATABASE_URL, as `bun run test` runs, there is nothing to run against, and this is
+// skipped. Everything made here is one organization's, deleted at the end.
+const databaseUrl = process.env.DATABASE_URL;
+
+function unwrap(result: Result): T {
+ if (result.isErr()) {
+ throw new Error(JSON.stringify(result.error));
+ }
+ return result.value;
+}
+
+describe.skipIf(!databaseUrl)('the harness, against the database', () => {
+ const stamp = `${Date.now()}-${process.pid}`;
+ let userId = '';
+ let orgId = '';
+ let agentId = '';
+ let thread: ThreadRow;
+
+ beforeAll(async () => {
+ const user = await db.user.create({data: {email: `harness-test-${stamp}@example.test`}});
+ userId = user.id;
+ const org = await db.organization.create({
+ data: {name: 'Harness test', slug: `harness-test-${stamp}`, ownerUserId: userId},
+ });
+ orgId = org.id;
+ const agent = await db.agent.create({
+ data: {
+ orgId,
+ providerId: 'built-in',
+ name: 'Built in',
+ username: `harness-test-${stamp}`,
+ passwordHash: '',
+ },
+ });
+ agentId = agent.id;
+ const persona = unwrap(await ensureFirstPersona(orgId));
+ thread = unwrap(await ensureMemberThread({orgId, agentId, personaId: persona.id, userId}));
+ });
+
+ afterAll(async () => {
+ if (orgId !== '') {
+ await db.organization.delete({where: {id: orgId}});
+ }
+ if (userId !== '') {
+ await db.user.delete({where: {id: userId}});
+ }
+ await db.$disconnect();
+ });
+
+ test('a claim is noted, and so is the finish: the answer carries its prompt and its job, a delegation’s placeholder and a summary the job too', async () => {
+ unwrap(await appendPersonMessage(thread.id, 'Find my invoices'));
+ const job = unwrap(await claimJob('worker-a', 60_000));
+ if (!job) {
+ throw new Error('nothing to claim');
+ }
+ expect(job).toMatchObject({
+ threadId: thread.id,
+ kind: 'inference',
+ claimedBy: 'worker-a',
+ attempts: 1,
+ });
+ unwrap(
+ await appendSummary(
+ job.id,
+ 'worker-a',
+ thread.id,
+ {text: 'Nothing yet.', inputTokens: 5, outputTokens: 1},
+ 0,
+ ),
+ );
+ unwrap(
+ await completeInference(
+ job.id,
+ 'worker-a',
+ thread.id,
+ {
+ text: 'Looking.',
+ toolCalls: [
+ {id: 'c1', name: 'delegate', arguments: '{"title":"Find","brief":"Search."}'},
+ ],
+ inputTokens: 10,
+ outputTokens: 2,
+ system: 'You are signed in.',
+ },
+ {c1: {runAt: new Date(), placeholder: 'Started the sub-agent “Find”.'}},
+ ),
+ );
+
+ const entries = unwrap(await listEntries(thread.id));
+ expect(entries.map((entry) => [entry.kind, entry.jobId, entry.system])).toEqual([
+ ['person', null, null],
+ ['summary', job.id, null],
+ ['assistant', job.id, 'You are signed in.'],
+ ['tool_result', job.id, null],
+ ]);
+ const events = unwrap(await listJobEvents(thread.id));
+ expect(events.map((event) => [event.jobId, event.kind, event.workerId, event.error])).toEqual([
+ [job.id, 'claimed', 'worker-a', null],
+ [job.id, 'done', 'worker-a', null],
+ ]);
+ const claimed = events[0];
+ const done = events[1];
+ if (!claimed || !done) {
+ throw new Error('two events');
+ }
+ expect(claimed.at.getTime()).toBeLessThanOrEqual(done.at.getTime());
+ });
+
+ test('a tool job’s result carries the job; a requeue says why; a failure says why, and its answer carries the job', async () => {
+ const tool = unwrap(await claimJob('worker-b', 60_000));
+ if (!tool || tool.kind !== 'tool' || tool.callId !== 'c1') {
+ throw new Error(`expected the delegation’s tool job, got ${JSON.stringify(tool)}`);
+ }
+ unwrap(
+ await completeToolJob(tool.id, 'worker-b', thread.id, {
+ callId: 'c1',
+ text: 'Three invoices.',
+ isError: false,
+ }),
+ );
+ const next = unwrap(await claimJob('worker-c', 60_000));
+ if (!next || next.kind !== 'inference') {
+ throw new Error(`expected the next turn, got ${JSON.stringify(next)}`);
+ }
+ unwrap(
+ await requeueJob(
+ next.id,
+ 'worker-c',
+ thread.id,
+ new Date(),
+ 'The model could not be reached: down',
+ ),
+ );
+ const again = unwrap(await claimJob('worker-d', 60_000));
+ if (!again || again.id !== next.id) {
+ throw new Error(`expected the same turn again, got ${JSON.stringify(again)}`);
+ }
+ expect(again.attempts).toBe(2);
+ unwrap(await failJob(again.id, 'worker-d', 'Given up after 4 attempts'));
+
+ const entries = unwrap(await listEntries(thread.id));
+ expect(entries.slice(-2).map((entry) => [entry.kind, entry.jobId, entry.isError])).toEqual([
+ ['tool_result', tool.id, false],
+ ['assistant', next.id, true],
+ ]);
+ const events = unwrap(await listJobEvents(thread.id));
+ expect(
+ events.slice(2).map((event) => [event.jobId, event.kind, event.workerId, event.error]),
+ ).toEqual([
+ [tool.id, 'claimed', 'worker-b', null],
+ [tool.id, 'done', 'worker-b', null],
+ [next.id, 'claimed', 'worker-c', null],
+ [next.id, 'requeued', 'worker-c', 'The model could not be reached: down'],
+ [next.id, 'claimed', 'worker-d', null],
+ [next.id, 'failed', 'worker-d', 'Given up after 4 attempts'],
+ ]);
+ });
+
+ test('a finish with nothing written is a done event too', async () => {
+ unwrap(await appendPersonMessage(thread.id, 'Try again'));
+ const job = unwrap(await claimJob('worker-e', 60_000));
+ if (!job) {
+ throw new Error('nothing to claim');
+ }
+ unwrap(await finishJob(job.id, 'worker-e'));
+ const events = unwrap(await listJobEvents(thread.id));
+ expect(events.slice(-2).map((event) => [event.jobId, event.kind, event.workerId])).toEqual([
+ [job.id, 'claimed', 'worker-e'],
+ [job.id, 'done', 'worker-e'],
+ ]);
+ expect(unwrap(await listOpenJobs(thread.id))).toEqual([]);
+ });
+});
diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts
new file mode 100644
index 00000000..0a313d25
--- /dev/null
+++ b/packages/db/src/harness.ts
@@ -0,0 +1,1042 @@
+import type {
+ AgentEntryKind,
+ AgentJobEventKind,
+ AgentJobKind,
+ AgentJobStatus,
+ Prisma,
+} from '@prisma/client';
+import {Err, Ok, type Result} from 'ts-results-es';
+
+import {ApiErr, type ApiError, wrapDb} from '@proxy/utils';
+import {
+ cut,
+ DEFAULT_LOOK,
+ type Look,
+ type LogEntry,
+ parseLook,
+ pendingToolCalls,
+ readyForInference,
+ type ToolCall,
+} from '@proxy/utils/harness';
+import {TASK_ABANDONED_AFTER_MS} from '@proxy/utils/tasks';
+
+import {db} from '.';
+import {signedInAgentSelect, type SignedInAgent, toSignedInAgent} from './signed-in-agent';
+
+/**
+ * The Chat page: the organization's personas, each with a thread per member, the member's
+ * conversation with it, and a thread per sub-agent started from it, each with its log and its
+ * jobs. Every persona acts through the organization's one built-in agent login, whose grants and
+ * activity log are every persona's. Every change to a thread's log or its jobs is made with the
+ * thread's row locked. The model is run on a log only once it is ready, and nothing lands in
+ * the log while it runs, or while a call it made is still open: what comes meanwhile, the person
+ * speaking or a sub-agent reporting, is pending until the job ends and every call has its result,
+ * then takes its place after them, and wakes the agent again.
+ * A worker's write is fenced by its lease: it goes through only while the job is still its. A
+ * sub-agent's thread ends with its report, which lands in the thread it was started from as the
+ * answer to the call that started it; a sub-agent's thread is locked before its parent's, never
+ * the other way, so two can't wait on each other.
+ */
+
+export type ThreadRow = {
+ id: string;
+ orgId: string;
+ // The organization's built-in agent login, which the persona acts through.
+ agentId: string;
+ // The persona the conversation is with, or the sub-agent works as.
+ personaId: string;
+ // The member whose conversation it is, or was started from.
+ userId: string;
+ // A sub-agent's thread: the thread it was delegated from, and the call that started it.
+ parentThreadId: string | null;
+ parentCallId: string | null;
+ // A sub-agent's title; null for a conversation.
+ title: string | null;
+ // A sub-agent's task; null for a conversation, whose agent makes no calls but to delegate.
+ taskId: string | null;
+ // When a sub-agent reported, or failed; null while it works, and for a conversation.
+ finishedAt: Date | null;
+ createdAt: Date;
+};
+
+/** What a persona is: its name and its head, a look and a color. */
+export type Persona = {name: string; look: Look; color: string};
+
+/** A persona as the Chat page lists it, with who made it, who may change it beside the organization's owner. */
+export type PersonaRow = Persona & {id: string; createdById: string | null};
+
+export type EntryRow = LogEntry & {
+ id: string;
+ seq: number;
+ threadId: string;
+ // Landed while the model was answering: not in the log until the answer is.
+ pending: boolean;
+ isError: boolean;
+ inputTokens: number | null;
+ outputTokens: number | null;
+ // A summary entry's reach: the seq of the last entry it stands for.
+ coversSeq: number | null;
+ // An assistant entry's system prompt, as the model was sent it; null where it wasn't run.
+ system: string | null;
+ // The job that wrote the entry; null for what the person wrote.
+ jobId: string | null;
+ createdAt: Date;
+};
+
+export type JobRow = {
+ id: string;
+ threadId: string;
+ kind: AgentJobKind;
+ status: AgentJobStatus;
+ entryId: string | null;
+ callId: string | null;
+ runAt: Date;
+ claimedBy: string | null;
+ attempts: number;
+};
+
+/** What happened to a job: claimed by a worker, put back to be tried again (why in `error`), done, or failed (why in `error`). */
+export type JobEventRow = {
+ id: string;
+ jobId: string;
+ threadId: string;
+ kind: AgentJobEventKind;
+ workerId: string | null;
+ error: string | null;
+ at: Date;
+};
+
+/** What is to happen with a call the model made: when its job may run, and, for a delegation, what it is answered at once with, since the report comes later as an event. */
+export type CallPlan = {runAt: Date; placeholder: string | null};
+
+// How much of a sub-agent's report its task keeps as the outcome, and of its brief as the goal.
+const MAX_TASK_TEXT = 2000;
+
+const threadSelect = {
+ id: true,
+ orgId: true,
+ agentId: true,
+ personaId: true,
+ userId: true,
+ parentThreadId: true,
+ parentCallId: true,
+ title: true,
+ taskId: true,
+ finishedAt: true,
+ createdAt: true,
+} as const;
+
+const personaSelect = {
+ id: true,
+ name: true,
+ look: true,
+ color: true,
+ createdById: true,
+} as const;
+
+function toPersona(persona: {
+ id: string;
+ name: string;
+ look: string;
+ color: string;
+ createdById: string | null;
+}): PersonaRow {
+ return {...persona, look: parseLook(persona.look)};
+}
+
+const entrySelect = {
+ id: true,
+ seq: true,
+ threadId: true,
+ kind: true,
+ pending: true,
+ text: true,
+ toolCalls: true,
+ callId: true,
+ isError: true,
+ inputTokens: true,
+ outputTokens: true,
+ coversSeq: true,
+ system: true,
+ jobId: true,
+ createdAt: true,
+} as const;
+
+const jobSelect = {
+ id: true,
+ threadId: true,
+ kind: true,
+ status: true,
+ entryId: true,
+ callId: true,
+ runAt: true,
+ claimedBy: true,
+ attempts: true,
+} as const;
+
+const jobEventSelect = {
+ id: true,
+ jobId: true,
+ threadId: true,
+ kind: true,
+ workerId: true,
+ error: true,
+ at: true,
+} as const;
+
+type EntryRecord = Prisma.AgentEntryGetPayload<{select: typeof entrySelect}>;
+
+type Tx = Prisma.TransactionClient;
+
+function toEntry(entry: EntryRecord): EntryRow {
+ return {
+ ...entry,
+ toolCalls: entry.toolCalls === null ? null : (JSON.parse(entry.toolCalls) as ToolCall[]),
+ };
+}
+
+/** The organization's personas, oldest first, with the last thing said in the member's conversation with each. */
+export async function listPersonas(
+ orgId: string,
+ userId: string,
+): Promise, ApiError>> {
+ return (
+ await wrapDb(() =>
+ db.persona.findMany({
+ where: {orgId},
+ select: {
+ ...personaSelect,
+ threads: {
+ where: {userId, parentThreadId: null},
+ select: {
+ // The agent's turns that only made calls say nothing, so the last with words.
+ entries: {
+ where: {kind: {in: ['person', 'assistant']}, text: {not: ''}},
+ orderBy: {seq: 'desc'},
+ take: 1,
+ select: {kind: true, text: true, createdAt: true},
+ },
+ },
+ },
+ },
+ orderBy: {createdAt: 'asc'},
+ }),
+ )
+ ).map((personas) =>
+ personas.map(({threads, ...persona}) => {
+ const last = threads[0]?.entries[0];
+ return {
+ ...toPersona(persona),
+ lastMessage:
+ last && (last.kind === 'person' || last.kind === 'assistant')
+ ? {kind: last.kind, text: last.text, createdAt: last.createdAt}
+ : null,
+ };
+ }),
+ );
+}
+
+/**
+ * The organization's first persona, General, made by nobody in particular where it has none: the
+ * organization's row is locked meanwhile, so two first openings of Chat make one. The one there
+ * is otherwise, which is the oldest.
+ */
+export async function ensureFirstPersona(orgId: string): Promise> {
+ return (
+ await wrapDb(() =>
+ db.$transaction(async (tx) => {
+ await tx.$queryRaw`SELECT "id" FROM "Organization" WHERE "id" = ${orgId} FOR UPDATE`;
+ const existing = await tx.persona.findFirst({
+ where: {orgId},
+ select: personaSelect,
+ orderBy: {createdAt: 'asc'},
+ });
+ if (existing) {
+ return existing;
+ }
+ return tx.persona.create({
+ data: {
+ orgId,
+ createdById: null,
+ name: 'General',
+ look: JSON.stringify(DEFAULT_LOOK),
+ color: 'blue',
+ },
+ select: personaSelect,
+ });
+ }),
+ )
+ ).map(toPersona);
+}
+
+/** Personas by id, those of the organization among `ids`, for what names them. */
+export async function listPersonasById(
+ orgId: string,
+ ids: string[],
+): Promise> {
+ if (ids.length === 0) {
+ return Ok([]);
+ }
+ return (
+ await wrapDb(() => db.persona.findMany({where: {orgId, id: {in: ids}}, select: personaSelect}))
+ ).map((personas) => personas.map(toPersona));
+}
+
+/** One of the organization's personas; `Ok(null)` for none. */
+export async function getPersona(
+ orgId: string,
+ personaId: string,
+): Promise> {
+ return (
+ await wrapDb(() => db.persona.findFirst({where: {id: personaId, orgId}, select: personaSelect}))
+ ).map((persona) => (persona ? toPersona(persona) : null));
+}
+
+/** A new persona of the organization, made by the member. */
+export async function createPersona(
+ orgId: string,
+ createdById: string,
+ persona: Persona,
+): Promise> {
+ return (
+ await wrapDb(() =>
+ db.persona.create({
+ data: {
+ orgId,
+ createdById,
+ name: persona.name,
+ look: JSON.stringify(persona.look),
+ color: persona.color,
+ },
+ select: personaSelect,
+ }),
+ )
+ ).map(toPersona);
+}
+
+/** Renames a persona or changes its head; `Ok(false)` means no such persona in the organization. */
+export async function updatePersona(
+ orgId: string,
+ personaId: string,
+ persona: Persona,
+): Promise> {
+ return (
+ await wrapDb(() =>
+ db.persona.updateMany({
+ where: {id: personaId, orgId},
+ data: {name: persona.name, look: JSON.stringify(persona.look), color: persona.color},
+ }),
+ )
+ ).map(({count}) => count > 0);
+}
+
+/** Deletes a persona with every member's conversation with it; `Ok(false)` means no such persona in the organization. */
+export async function deletePersona(
+ orgId: string,
+ personaId: string,
+): Promise> {
+ return (await wrapDb(() => db.persona.deleteMany({where: {id: personaId, orgId}}))).map(
+ ({count}) => count > 0,
+ );
+}
+
+/** The member's conversation with the persona; `Ok(null)` before they have one. */
+export async function getMemberThread(
+ personaId: string,
+ userId: string,
+): Promise> {
+ return wrapDb(() =>
+ db.agentThread.findFirst({
+ where: {personaId, userId, parentThreadId: null},
+ select: threadSelect,
+ }),
+ );
+}
+
+/**
+ * The member's conversation with the persona, made where they have none yet, through the
+ * organization's built-in login `agentId`: the one there is otherwise, two first openings at once
+ * making one, since the persona's row is locked meanwhile.
+ */
+export async function ensureMemberThread(data: {
+ orgId: string;
+ agentId: string;
+ personaId: string;
+ userId: string;
+}): Promise> {
+ const {personaId, userId} = data;
+ return wrapDb(() =>
+ db.$transaction(async (tx) => {
+ await tx.$queryRaw`SELECT "id" FROM "Persona" WHERE "id" = ${personaId} FOR UPDATE`;
+ const existing = await tx.agentThread.findFirst({
+ where: {personaId, userId, parentThreadId: null},
+ select: threadSelect,
+ });
+ if (existing) {
+ return existing;
+ }
+ return tx.agentThread.create({data, select: threadSelect});
+ }),
+ );
+}
+
+/** The last thing said in a thread, by the person or the agent. */
+export type LastMessage = {kind: 'person' | 'assistant'; text: string; createdAt: Date};
+
+/**
+ * The thread's agent as it acts in the thread: signed in as the member whose conversation it is,
+ * so it reaches what they see, their own connections and vault beside the shared ones. `Ok(null)`
+ * when the agent is gone or revoked.
+ */
+export async function getThreadAgent(
+ thread: ThreadRow,
+): Promise> {
+ return (
+ await wrapDb(() =>
+ db.agent.findUnique({where: {id: thread.agentId}, select: signedInAgentSelect}),
+ )
+ ).map((agent) => {
+ const signedIn = agent ? toSignedInAgent(agent) : null;
+ return signedIn ? {...signedIn, ownerUserId: thread.userId} : null;
+ });
+}
+
+/** The thread a job is for, by the job; `Ok(null)` when it is gone. */
+export async function getJobThread(threadId: string): Promise> {
+ return wrapDb(() => db.agentThread.findUnique({where: {id: threadId}, select: threadSelect}));
+}
+
+/** The thread's log, in order: what the model reads, without what is pending. */
+export async function listEntries(threadId: string): Promise> {
+ return (
+ await wrapDb(() =>
+ db.agentEntry.findMany({
+ where: {threadId, pending: false},
+ select: entrySelect,
+ orderBy: {seq: 'asc'},
+ }),
+ )
+ ).map((entries) => entries.map(toEntry));
+}
+
+/**
+ * The thread's turns as the person sees them, what they said and what the persona answered in
+ * words, not the calls, their results, the reports or the summaries: the log's, then what is
+ * pending, each in order.
+ */
+export async function listShownEntries(threadId: string): Promise> {
+ return (
+ await wrapDb(() =>
+ db.agentEntry.findMany({
+ where: {threadId, OR: [{kind: 'person'}, {kind: 'assistant', text: {not: ''}}]},
+ select: entrySelect,
+ orderBy: [{pending: 'asc'}, {seq: 'asc'}],
+ }),
+ )
+ ).map((entries) => entries.map(toEntry));
+}
+
+/**
+ * The jobs still to run or running on the thread, and on the sub-agents started from it that
+ * aren't done: what the agent is up to, for the person.
+ */
+export async function listOpenJobs(threadId: string): Promise> {
+ return wrapDb(() =>
+ db.agentJob.findMany({
+ where: {
+ status: {in: ['queued', 'running']},
+ OR: [{threadId}, {thread: {parentThreadId: threadId, finishedAt: null}}],
+ },
+ select: jobSelect,
+ orderBy: {runAt: 'asc'},
+ }),
+ );
+}
+
+/** What happened to the thread's jobs, in order: every claim, requeue, finish and failure. */
+export async function listJobEvents(threadId: string): Promise> {
+ return wrapDb(() =>
+ db.agentJobEvent.findMany({
+ where: {threadId},
+ select: jobEventSelect,
+ orderBy: {at: 'asc'},
+ }),
+ );
+}
+
+/** A new task for a sub-agent's calls, once the one before was abandoned. */
+export async function setThreadTask(
+ threadId: string,
+ taskId: string,
+): Promise> {
+ return (await wrapDb(() => db.agentThread.update({where: {id: threadId}, data: {taskId}}))).map(
+ () => undefined,
+ );
+}
+
+// Locks the thread's row for the rest of the transaction, and gives the thread as it is; null
+// when there is no such thread.
+async function lockThread(tx: Tx, threadId: string): Promise {
+ const rows = await tx.$queryRaw<
+ Array<{id: string}>
+ >`SELECT "id" FROM "AgentThread" WHERE "id" = ${threadId} FOR UPDATE`;
+ if (rows.length === 0) {
+ return null;
+ }
+ return tx.agentThread.findUnique({where: {id: threadId}, select: threadSelect});
+}
+
+// Queues an inference job for the thread unless one is queued or running already, or the thread
+// is done. With the thread locked.
+async function queueInference(tx: Tx, thread: ThreadRow): Promise {
+ if (thread.finishedAt !== null) {
+ return;
+ }
+ const active = await tx.agentJob.count({
+ where: {threadId: thread.id, kind: 'inference', status: {in: ['queued', 'running']}},
+ });
+ if (active > 0) {
+ return;
+ }
+ await tx.agentJob.create({data: {threadId: thread.id, kind: 'inference'}});
+}
+
+// The log from the model's last answer on, without what is pending: whether the log is ready for
+// the model, and whether its calls are answered, show in that much. With the thread locked.
+async function tailOf(tx: Tx, thread: ThreadRow): Promise {
+ const lastAnswer = await tx.agentEntry.findFirst({
+ where: {threadId: thread.id, pending: false, kind: 'assistant'},
+ select: {seq: true},
+ orderBy: {seq: 'desc'},
+ });
+ return (
+ await tx.agentEntry.findMany({
+ where: {threadId: thread.id, pending: false, seq: {gte: lastAnswer?.seq ?? 0}},
+ select: entrySelect,
+ orderBy: {seq: 'asc'},
+ })
+ ).map(toEntry);
+}
+
+// Whether what lands now has to wait: an inference job runs on the thread, whose answer comes
+// first, or the model's last answer has calls without their results yet, which have to follow it
+// directly, as the model reads them; anything between them and it is refused. With the thread
+// locked.
+async function holding(tx: Tx, thread: ThreadRow): Promise {
+ const running = await tx.agentJob.count({
+ where: {threadId: thread.id, kind: 'inference', status: 'running'},
+ });
+ if (running > 0) {
+ return true;
+ }
+ return pendingToolCalls(await tailOf(tx, thread)).length > 0;
+}
+
+// Gives the thread's pending entries their place in the log, in the order they landed, once
+// nothing holds them any more: after what the inference job that just ended wrote, or after the
+// result of the last call still open; and queues an inference job where the log is then ready
+// for one. With the thread locked.
+async function release(tx: Tx, thread: ThreadRow): Promise {
+ if (await holding(tx, thread)) {
+ return;
+ }
+ const pending = await tx.agentEntry.findMany({
+ where: {threadId: thread.id, pending: true},
+ select: {id: true},
+ orderBy: {seq: 'asc'},
+ });
+ for (const {id} of pending) {
+ await tx.$executeRaw`UPDATE "AgentEntry" SET "seq" = nextval(pg_get_serial_sequence('"AgentEntry"', 'seq')), "pending" = false WHERE "id" = ${id}`;
+ }
+ if (readyForInference(await tailOf(tx, thread))) {
+ await queueInference(tx, thread);
+ }
+}
+
+// Lands an entry in the thread: a call's result in the log, directly after the answer that made
+// the call; anything else in the log too where nothing holds it, or pending until what holds it
+// ends (`release`). What lands in the log releases what was pending where it can, and wakes the
+// agent where the log is then ready. With the thread locked.
+async function appendEntry(
+ tx: Tx,
+ thread: ThreadRow,
+ data: {kind: AgentEntryKind; text: string; callId?: string; isError?: boolean; jobId?: string},
+): Promise {
+ const pending = data.kind !== 'tool_result' && (await holding(tx, thread));
+ const entry = await tx.agentEntry.create({
+ data: {threadId: thread.id, ...data, pending},
+ select: entrySelect,
+ });
+ if (!pending) {
+ await release(tx, thread);
+ }
+ return toEntry(entry);
+}
+
+// Answers a call of the thread's log, as its result or, for one answered at once as started, as
+// an event, from the job that has the answer. With the thread locked.
+async function answerCall(
+ tx: Tx,
+ thread: ThreadRow,
+ kind: 'tool_result' | 'event',
+ answer: {callId: string; text: string; isError: boolean},
+ jobId: string,
+): Promise {
+ await appendEntry(tx, thread, {kind, ...answer, jobId});
+}
+
+// Ends a sub-agent's work with its report: its thread is done, its task finished with the report
+// as the outcome (unless abandoned meanwhile, as a task is a day after its last start), and the
+// report answers the call that started it, in the thread it was started from, from the sub-agent's
+// job that ended with it. With the sub-agent's thread locked; its parent's is locked here, after.
+async function endSubAgent(
+ tx: Tx,
+ thread: ThreadRow,
+ report: {text: string; isError: boolean},
+ jobId: string,
+): Promise {
+ if (
+ thread.finishedAt !== null ||
+ thread.parentThreadId === null ||
+ thread.parentCallId === null
+ ) {
+ return;
+ }
+ const now = new Date();
+ await tx.agentThread.update({where: {id: thread.id}, data: {finishedAt: now}});
+ if (thread.taskId !== null) {
+ await tx.agentTask.updateMany({
+ where: {
+ id: thread.taskId,
+ finishedAt: null,
+ lastStartedAt: {gt: new Date(now.getTime() - TASK_ABANDONED_AFTER_MS)},
+ },
+ data: {finishedAt: now, outcome: cut(report.text, MAX_TASK_TEXT)},
+ });
+ }
+ const parent = await lockThread(tx, thread.parentThreadId);
+ if (!parent) {
+ return;
+ }
+ await answerCall(tx, parent, 'event', {callId: thread.parentCallId, ...report}, jobId);
+}
+
+/** Lands what the person said, and wakes the agent. `Ok(null)` means no such thread. */
+export async function appendPersonMessage(
+ threadId: string,
+ text: string,
+): Promise> {
+ return wrapDb(() =>
+ db.$transaction(async (tx) => {
+ const thread = await lockThread(tx, threadId);
+ if (!thread) {
+ return null;
+ }
+ return appendEntry(tx, thread, {kind: 'person', text});
+ }),
+ );
+}
+
+/**
+ * Claims the next job due for `workerId`: one queued whose time has come, or one running whose
+ * worker's lease ran out, the earliest due first. `Ok(null)` when none is due. The claim holds for
+ * `leaseMs`, and `renewLease` keeps it.
+ */
+export async function claimJob(
+ workerId: string,
+ leaseMs: number,
+): Promise> {
+ // Prisma writes a DateTime as the UTC wall clock, in a column without a time zone; now() has
+ // one, and would be read in the server's, so its UTC wall clock is what is compared and
+ // written. The claim is noted as an event in the same transaction.
+ return wrapDb(() =>
+ db.$transaction(async (tx) => {
+ const rows = await tx.$queryRaw>`
+ UPDATE "AgentJob" SET
+ "status" = 'running',
+ "claimedBy" = ${workerId},
+ "leaseExpiresAt" = (now() AT TIME ZONE 'UTC') + make_interval(secs => ${leaseMs / 1000}),
+ "attempts" = "attempts" + 1,
+ "updatedAt" = now() AT TIME ZONE 'UTC'
+ WHERE "id" = (
+ SELECT "id" FROM "AgentJob"
+ WHERE ("status" = 'queued' AND "runAt" <= now() AT TIME ZONE 'UTC')
+ OR ("status" = 'running' AND "leaseExpiresAt" < now() AT TIME ZONE 'UTC')
+ ORDER BY "runAt" ASC
+ LIMIT 1
+ FOR UPDATE SKIP LOCKED
+ )
+ RETURNING "id", "threadId", "kind", "status", "entryId", "callId", "runAt", "claimedBy", "attempts"`;
+ const job = rows[0];
+ if (!job) {
+ return null;
+ }
+ await tx.agentJobEvent.create({
+ data: {jobId: job.id, threadId: job.threadId, kind: 'claimed', workerId},
+ });
+ return job;
+ }),
+ );
+}
+
+/** Keeps the worker's claim on the job; `Ok(false)` when the job is no longer its. */
+export async function renewLease(
+ jobId: string,
+ workerId: string,
+ leaseMs: number,
+): Promise> {
+ return (
+ await wrapDb(
+ () => db.$executeRaw`
+ UPDATE "AgentJob" SET "leaseExpiresAt" = (now() AT TIME ZONE 'UTC') + make_interval(secs => ${leaseMs / 1000}), "updatedAt" = now() AT TIME ZONE 'UTC'
+ WHERE "id" = ${jobId} AND "claimedBy" = ${workerId} AND "status" = 'running'`,
+ )
+ ).map((count) => count > 0);
+}
+
+// Marks the worker's running job as `status`, done or failed, and notes it as an event; false when
+// the job is no longer the worker's, in which case nothing else of the transaction should go
+// through.
+async function fence(
+ tx: Tx,
+ jobId: string,
+ workerId: string,
+ status: 'done' | 'failed',
+ error: string | null = null,
+): Promise {
+ const {count} = await tx.agentJob.updateMany({
+ where: {id: jobId, claimedBy: workerId, status: 'running'},
+ data: {status, error, claimedBy: null, leaseExpiresAt: null},
+ });
+ if (count === 0) {
+ return false;
+ }
+ const {threadId} = await tx.agentJob.findUniqueOrThrow({
+ where: {id: jobId},
+ select: {threadId: true},
+ });
+ await tx.agentJobEvent.create({data: {jobId, threadId, kind: status, workerId, error}});
+ return true;
+}
+
+// Whether the job is still the worker's to write for. With the thread locked.
+async function held(tx: Tx, jobId: string, workerId: string): Promise {
+ const count = await tx.agentJob.count({
+ where: {id: jobId, claimedBy: workerId, status: 'running'},
+ });
+ return count > 0;
+}
+
+/** Marks the worker's job done with nothing written; `Ok(false)` when it was no longer the worker's. */
+export async function finishJob(
+ jobId: string,
+ workerId: string,
+): Promise> {
+ return wrapDb(() => db.$transaction((tx) => fence(tx, jobId, workerId, 'done')));
+}
+
+/**
+ * Marks the worker's job failed, saying why, and keeps the thread going: a tool job's call is
+ * answered with the failure, so the agent isn't left waiting for it, or, where the call was
+ * answered as started already, a delegation, the failure comes as an event, as its report would
+ * have; a turn that failed in the member's conversation is answered with the failure, so the
+ * person sees it and can go on; and a sub-agent whose turn failed reports the failure, so neither
+ * is whoever delegated to it. `Ok(false)` when the job was no longer the worker's.
+ */
+export async function failJob(
+ jobId: string,
+ workerId: string,
+ error: string,
+): Promise> {
+ return wrapDb(() =>
+ db.$transaction(async (tx) => {
+ const job = await tx.agentJob.findUnique({where: {id: jobId}, select: jobSelect});
+ if (!job) {
+ return false;
+ }
+ const thread = await lockThread(tx, job.threadId);
+ if (!(await fence(tx, jobId, workerId, 'failed', error))) {
+ return false;
+ }
+ if (!thread) {
+ return true;
+ }
+ if (job.kind === 'tool' && job.callId !== null) {
+ const answered = await tx.agentEntry.count({
+ where: {threadId: thread.id, kind: 'tool_result', callId: job.callId},
+ });
+ await answerCall(
+ tx,
+ thread,
+ answered > 0 ? 'event' : 'tool_result',
+ {
+ callId: job.callId,
+ text: `This call could not be run: ${error}`,
+ isError: true,
+ },
+ job.id,
+ );
+ return true;
+ }
+ if (job.kind !== 'inference') {
+ return true;
+ }
+ if (thread.parentThreadId !== null) {
+ await endSubAgent(
+ tx,
+ thread,
+ {text: `The sub-agent failed: ${error}`, isError: true},
+ job.id,
+ );
+ return true;
+ }
+ await tx.agentEntry.create({
+ data: {
+ threadId: thread.id,
+ kind: 'assistant' satisfies AgentEntryKind,
+ text: `Something went wrong on my side, so I could not answer: ${error.replace(/\.$/, '')}. Say something to try again.`,
+ isError: true,
+ jobId: job.id,
+ },
+ });
+ await release(tx, thread);
+ return true;
+ }),
+ );
+}
+
+/**
+ * Puts the worker's job back in the queue for later, saying why, and what landed meanwhile in the
+ * log, for it to read then; `Ok(false)` when it was no longer the worker's.
+ */
+export async function requeueJob(
+ jobId: string,
+ workerId: string,
+ threadId: string,
+ runAt: Date,
+ why: string,
+): Promise> {
+ return wrapDb(() =>
+ db.$transaction(async (tx) => {
+ const thread = await lockThread(tx, threadId);
+ const {count} = await tx.agentJob.updateMany({
+ where: {id: jobId, claimedBy: workerId, status: 'running'},
+ data: {status: 'queued', runAt, claimedBy: null, leaseExpiresAt: null},
+ });
+ if (count === 0) {
+ return false;
+ }
+ await tx.agentJobEvent.create({
+ data: {jobId, threadId, kind: 'requeued', workerId, error: why},
+ });
+ if (thread) {
+ await release(tx, thread);
+ }
+ return true;
+ }),
+ );
+}
+
+/** What the model answered: its text, its tool calls, what they cost, and the system prompt it was sent; null where it wasn't run for the answer. */
+export type AssistantTurn = {
+ text: string;
+ toolCalls: ToolCall[];
+ inputTokens: number;
+ outputTokens: number;
+ system: string | null;
+};
+
+/**
+ * Appends a summary of the log through `coversSeq`, from the inference job that made it on its
+ * way to answering; the job goes on. `Ok(false)` when the job was no longer the worker's, and
+ * nothing is written.
+ */
+export async function appendSummary(
+ jobId: string,
+ workerId: string,
+ threadId: string,
+ summary: {text: string; inputTokens: number; outputTokens: number},
+ coversSeq: number,
+): Promise> {
+ return wrapDb(() =>
+ db.$transaction(async (tx) => {
+ await lockThread(tx, threadId);
+ if (!(await held(tx, jobId, workerId))) {
+ return false;
+ }
+ await tx.agentEntry.create({
+ data: {
+ threadId,
+ kind: 'summary' satisfies AgentEntryKind,
+ text: summary.text,
+ inputTokens: summary.inputTokens,
+ outputTokens: summary.outputTokens,
+ coversSeq,
+ jobId,
+ },
+ select: {id: true},
+ });
+ return true;
+ }),
+ );
+}
+
+/**
+ * Finishes an inference job with the model's answer: the assistant entry, and a tool job for each
+ * of its calls, due when its plan says; a call with a placeholder in its plan, a delegation, is
+ * answered with it at once, and what it comes to lands as an event. A sub-agent's answer with no
+ * calls is its report, which ends its work and answers the call that started it. What landed
+ * meanwhile takes its place in the log after the answer, and wakes the agent where the log is
+ * then ready. `Ok(false)` when the job was no longer the worker's, and nothing is written.
+ */
+export async function completeInference(
+ jobId: string,
+ workerId: string,
+ threadId: string,
+ turn: AssistantTurn,
+ plans: Record,
+): Promise> {
+ return wrapDb(() =>
+ db.$transaction(async (tx) => {
+ const thread = await lockThread(tx, threadId);
+ if (!thread || !(await fence(tx, jobId, workerId, 'done'))) {
+ return false;
+ }
+ const entry = await tx.agentEntry.create({
+ data: {
+ threadId,
+ kind: 'assistant' satisfies AgentEntryKind,
+ text: turn.text,
+ toolCalls: turn.toolCalls.length === 0 ? null : JSON.stringify(turn.toolCalls),
+ inputTokens: turn.inputTokens,
+ outputTokens: turn.outputTokens,
+ system: turn.system,
+ jobId,
+ },
+ select: {id: true},
+ });
+ if (turn.toolCalls.length === 0 && thread.parentThreadId !== null) {
+ await endSubAgent(tx, thread, {text: turn.text, isError: false}, jobId);
+ return true;
+ }
+ if (turn.toolCalls.length === 0) {
+ await release(tx, thread);
+ return true;
+ }
+ await tx.agentJob.createMany({
+ data: turn.toolCalls.map((call) => ({
+ threadId,
+ kind: 'tool' satisfies AgentJobKind,
+ entryId: entry.id,
+ callId: call.id,
+ runAt: plans[call.id]?.runAt ?? new Date(),
+ })),
+ });
+ const started: Prisma.AgentEntryCreateManyInput[] = turn.toolCalls.flatMap((call) => {
+ const placeholder = plans[call.id]?.placeholder;
+ if (placeholder === null || placeholder === undefined) {
+ return [];
+ }
+ return [{threadId, kind: 'tool_result', text: placeholder, callId: call.id, jobId}];
+ });
+ if (started.length > 0) {
+ await tx.agentEntry.createMany({data: started});
+ }
+ await release(tx, thread);
+ return true;
+ }),
+ );
+}
+
+/**
+ * Finishes a tool job with what the call answered, and wakes the agent once the log is ready:
+ * every call of its last answer has its result. `Ok(false)` when the job was no longer the
+ * worker's, and nothing is written.
+ */
+export async function completeToolJob(
+ jobId: string,
+ workerId: string,
+ threadId: string,
+ result: {callId: string; text: string; isError: boolean},
+): Promise> {
+ return wrapDb(() =>
+ db.$transaction(async (tx) => {
+ const thread = await lockThread(tx, threadId);
+ if (!thread || !(await fence(tx, jobId, workerId, 'done'))) {
+ return false;
+ }
+ await answerCall(tx, thread, 'tool_result', result, jobId);
+ return true;
+ }),
+ );
+}
+
+/**
+ * Finishes a delegate call's tool job by starting the sub-agent: its task, run as the persona, its
+ * thread with the brief as the first thing said in it, and its first turn queued. The call was answered as the
+ * turn was written; the report lands as an event. `Ok(false)` when the job was no longer the
+ * worker's, and nothing is made.
+ */
+export async function startSubAgent(
+ jobId: string,
+ workerId: string,
+ parent: ThreadRow,
+ call: {id: string; title: string; brief: string},
+): Promise> {
+ return wrapDb(() =>
+ db.$transaction(async (tx) => {
+ await lockThread(tx, parent.id);
+ if (!(await fence(tx, jobId, workerId, 'done'))) {
+ return false;
+ }
+ const now = new Date();
+ const task = await tx.agentTask.create({
+ data: {
+ orgId: parent.orgId,
+ agentId: parent.agentId,
+ ownerUserId: parent.userId,
+ personaId: parent.personaId,
+ parentId: null,
+ title: call.title,
+ goal: cut(call.brief, MAX_TASK_TEXT),
+ via: 'harness',
+ createdAt: now,
+ lastStartedAt: now,
+ },
+ select: {id: true},
+ });
+ const thread = await tx.agentThread.create({
+ data: {
+ orgId: parent.orgId,
+ agentId: parent.agentId,
+ personaId: parent.personaId,
+ userId: parent.userId,
+ parentThreadId: parent.id,
+ parentCallId: call.id,
+ title: call.title,
+ taskId: task.id,
+ },
+ select: {id: true},
+ });
+ await tx.agentEntry.create({
+ data: {threadId: thread.id, kind: 'person' satisfies AgentEntryKind, text: call.brief},
+ });
+ await tx.agentJob.create({data: {threadId: thread.id, kind: 'inference'}});
+ return true;
+ }),
+ );
+}
+
+/** The assistant entry a tool job's call is in; `Err` not found when it is gone. */
+export async function getEntry(entryId: string): Promise> {
+ const found = await wrapDb(() =>
+ db.agentEntry.findUnique({where: {id: entryId}, select: entrySelect}),
+ );
+ if (found.isErr()) {
+ return found;
+ }
+ if (found.value === null) {
+ return Err(ApiErr.notFound('entry', entryId));
+ }
+ return Ok(toEntry(found.value));
+}
diff --git a/packages/db/src/organization.ts b/packages/db/src/organization.ts
index c2bd18bc..9a490cc5 100644
--- a/packages/db/src/organization.ts
+++ b/packages/db/src/organization.ts
@@ -399,11 +399,12 @@ export function sharedVaultFor(data: {
/**
* Takes a person out of an organization: their own connections there go, with their defaults,
* entries and every agent's settings on them, and so do their own agents there, with their logins
- * and MCP clients; the shared ones stay, but the MCP clients they signed in as a shared agent are
- * signed out, and the shared agents they made, whose passwords they were shown, get one nobody
- * knows and are signed out everywhere, so nothing of theirs keeps working there. The organization's
- * owner gives such an agent a new password to sign in with it again. The caller has undone the
- * connections at their providers first, and checked they aren't its owner.
+ * and MCP clients, and their conversations with its built-in agents; the shared ones stay, but the
+ * MCP clients they signed in as a shared agent are signed out, and the shared agents they made,
+ * whose passwords they were shown, get one nobody knows and are signed out everywhere, so nothing
+ * of theirs keeps working there. The organization's owner gives such an agent a new password to
+ * sign in with it again. The caller has undone the connections at their providers first, and
+ * checked they aren't its owner.
*/
export async function removeMembership(data: {
orgId: string;
@@ -416,6 +417,7 @@ export async function removeMembership(data: {
await db.$transaction([
db.connection.deleteMany({where: {orgId, ownerUserId: userId}}),
db.agent.deleteMany({where: {orgId, ownerUserId: userId}}),
+ db.agentThread.deleteMany({where: {orgId, userId}}),
db.agent.updateMany({where: madeShared, data: {passwordHash: unknownPasswordHash}}),
db.agentSession.deleteMany({where: {agent: madeShared}}),
db.oAuthCode.deleteMany({where: {userId, agent: {orgId}}}),
diff --git a/packages/db/src/task.ts b/packages/db/src/task.ts
index bcbccead..710c244e 100644
--- a/packages/db/src/task.ts
+++ b/packages/db/src/task.ts
@@ -11,6 +11,8 @@ import {type Viewer, visibleTo} from './viewer';
export type AgentTaskRow = {
id: string;
agentId: string;
+ // The persona a Chat page sub-agent ran it as; null for every other task.
+ personaId: string | null;
// The task this one is a sub-task of; null for one started on its own.
parentId: string | null;
title: string;
@@ -29,6 +31,7 @@ export type AgentTaskRow = {
const taskSelect = {
id: true,
agentId: true,
+ personaId: true,
parentId: true,
title: true,
goal: true,
@@ -52,6 +55,8 @@ export async function createAgentTask(data: {
orgId: string;
agentId: string;
ownerUserId: string | null;
+ // The persona a Chat page sub-agent runs it as; null for every other task.
+ personaId?: string | null;
parentId: string | null;
title: string;
goal: string;
diff --git a/packages/docs/content/agents/chat.mdx b/packages/docs/content/agents/chat.mdx
new file mode 100644
index 00000000..f8deeb5e
--- /dev/null
+++ b/packages/docs/content/agents/chat.mdx
@@ -0,0 +1,62 @@
+---
+title: Chat with the built-in agent
+description: Talk to an agent Personal Agent Proxy runs itself, with the same access settings and activity log as any agent you connect.
+---
+
+**Chat** in the sidebar is where you talk to agents Personal Agent Proxy runs itself. The
+organization's agents are everyone's, like coworkers: anyone makes one with **+**, giving it a name
+and a head, and whoever made it, or the organization's owner, renames or deletes it. All of them
+act through one entry on the **Agent permissions** page, Chat, shared with everyone, as
+Claude or ChatGPT are one entry each however many assistants run inside them: what the agents may
+do with each shared connection is set there, once for all of them, and every call any of them makes
+is in the activity log, by the task it was made for, which says which agent it was.
+
+Your conversation with an agent is yours alone, as a direct message is: nobody else in the
+organization sees it. In it, the agent acts as you, so it reaches what you see: the connections
+shared with everyone, the ones you connected yourself and your own vault, beside the
+organization's. On your own connections it follows their defaults, since a shared agent has no
+settings of its own there. What it does in your conversation is in your activity log alone.
+
+The agent you talk with does nothing to your accounts itself. It hands each piece of work to a
+sub-agent: a short-lived agent with the same access, started on a brief, which does the work and
+reports back. Each sub-agent is a task in the activity log, titled as the agent titled it, with
+every call it made under it and its report as the outcome.
+
+What it does differently from an agent you connect is where it runs: in Personal Agent Proxy, not
+on another company's servers. The model is Claude Sonnet 5.5, reached through OpenRouter with a
+host that neither keeps prompts nor trains on them, so a self-hosted Personal Agent Proxy needs an
+`OPENROUTER_KEY`.
+
+## How a conversation runs
+
+Say what you want done. The agent answers, in a conversation and nothing else: the sub-agents it
+starts, what they asked for and what they found are in the activity log, not on the page, each as
+a task with the agent's head beside it. Sub-agents started together run side by side, and the agent
+is free while they work: you can say more, and it answers; a report comes to it as a message when
+the sub-agent is done, and it tells you what matters of it or starts the next piece. A call a
+sub-agent is not allowed is refused and logged as blocked, as it would be for any agent.
+Everything keeps running after you leave the page, and the page shows the conversation as it is
+when you come back.
+
+A sub-agent can also wait: asked to check on something later, or to do something at a time, it
+sleeps until then, and the page says until when. It wakes with its own log and nothing else, so it
+says what it will do before it sleeps.
+
+A conversation can go on for as long as you like. Once it has grown long, the agent reads a
+summary of the earlier part in place of it, and keeps the last turns as they were; the page shows
+the whole conversation as before.
+
+**Activity** under the input opens the activity log, where every sub-agent's task is listed with
+the agent's head and name.
+
+
+Chat is being tried out with some organizations first, so it is only offered to an organization
+that has it turned on. On a self-hosted Personal Agent Proxy, turn it on for every organization in
+its database:
+
+```sql
+UPDATE "Organization" SET experiments = array_append(experiments, 'harness')
+WHERE NOT 'harness' = ANY(experiments);
+```
+
+
diff --git a/packages/docs/content/organizations.mdx b/packages/docs/content/organizations.mdx
index 11362605..11d80d99 100644
--- a/packages/docs/content/organizations.mdx
+++ b/packages/docs/content/organizations.mdx
@@ -47,7 +47,7 @@ An agent login is yours or shared with the organization, the same way a connecti
its access, revoke it, hand it a new password and delete it; everyone else sees it as it is.
Once anyone else is in the organization, or invited to it, **New agent** asks who the agent is for,
-and **Agents** lists the two apart. On the page of an agent of your own, **Share with everyone**
+and **Agent permissions** lists the two apart. On the page of an agent of your own, **Share with everyone**
under **Sharing** shares it, for good. What it did on shared connections so far becomes everyone's
with it; what it did on any other connection, your own or one since disconnected, stays yours,
along with the tasks it did that for. A login made when an MCP client signs in is yours. The activity log shows what shared agents did on shared connections, and what
diff --git a/packages/docs/src/nav.ts b/packages/docs/src/nav.ts
index fcd7c0bf..6d30929f 100644
--- a/packages/docs/src/nav.ts
+++ b/packages/docs/src/nav.ts
@@ -33,7 +33,7 @@ export const NAV: {title: string; pages: NavEntry[]}[] = [
'connections/custom-mcp',
],
},
- {title: 'Agents', pages: ['agents/mcp']},
+ {title: 'Agent permissions', pages: ['agents/mcp', 'agents/chat']},
{
title: 'Self-hosting',
pages: [
diff --git a/packages/integrations/src/agent-providers.test.ts b/packages/integrations/src/agent-providers.test.ts
index 0477cc64..7e484e28 100644
--- a/packages/integrations/src/agent-providers.test.ts
+++ b/packages/integrations/src/agent-providers.test.ts
@@ -2,6 +2,7 @@ import {describe, expect, test} from 'bun:test';
import {
AGENT_PROVIDERS,
+ BUILT_IN_AGENT_PROVIDER,
findAgentProvider,
loginProviderIdFor,
suggestAgentProvider,
@@ -49,6 +50,11 @@ describe('agent providers', () => {
}
});
+ test('finds the built-in agent by id, which is not on offer', () => {
+ expect(findAgentProvider('proxy')).toBe(BUILT_IN_AGENT_PROVIDER);
+ expect(AGENT_PROVIDERS).not.toContain(BUILT_IN_AGENT_PROVIDER);
+ });
+
test('uses the same Claude Duet logo in the app, on the website and in the README', async () => {
const appLogo = await Bun.file(
new URL('../../web/public/agent-providers/claude-duet.png', import.meta.url),
@@ -72,6 +78,14 @@ describe('agent providers', () => {
expect(findAgentProvider('other')).toBeUndefined();
});
+ test('the built-in agent uses the app’s own icon', async () => {
+ expect(
+ await Bun.file(
+ new URL(`../../web/public${BUILT_IN_AGENT_PROVIDER.faviconUrl}`, import.meta.url),
+ ).exists(),
+ ).toBe(true);
+ });
+
test("suggests the provider an MCP client's name points at", () => {
expect(suggestAgentProvider('Claude')?.id).toBe('claude');
expect(suggestAgentProvider('Claude Duet')?.id).toBe('claude');
diff --git a/packages/integrations/src/agent-providers.ts b/packages/integrations/src/agent-providers.ts
index 9d303031..5cacea44 100644
--- a/packages/integrations/src/agent-providers.ts
+++ b/packages/integrations/src/agent-providers.ts
@@ -10,7 +10,8 @@ export type AgentProviderId =
| 'claude-desktop'
| 'hermes'
| 'chatgpt'
- | 'custom';
+ | 'custom'
+ | 'proxy';
export type AgentProvider = {
id: AgentProviderId;
@@ -21,9 +22,10 @@ export type AgentProvider = {
company: string;
/**
* How the agent gets its access: `mcp` adds Personal Agent Proxy as a connector and signs in
- * over OAuth, which makes its login; `login` is handed a username and password.
+ * over OAuth, which makes its login; `login` is handed a username and password; `built-in` is
+ * Personal Agent Proxy's own agent, which needs neither.
*/
- connects: 'mcp' | 'login';
+ connects: 'mcp' | 'login' | 'built-in';
/**
* Runs on the person's own computer, so it reaches a Personal Agent Proxy there, such as one
* in development at `http://localhost`. Every other agent needs a public https address.
@@ -43,6 +45,20 @@ export type AgentProvider = {
faviconUrl?: string;
};
+/**
+ * The agents Personal Agent Proxy runs itself, on the Chat page: agent logins like any other for
+ * their access and their activity, made on the Chat page, never handed out, so not among the
+ * products a person can make a login for.
+ */
+export const BUILT_IN_AGENT_PROVIDER: AgentProvider = {
+ id: 'proxy',
+ name: 'Personal Agent Proxy',
+ company: 'Personal Agent Proxy',
+ connects: 'built-in',
+ local: true,
+ faviconUrl: '/favicon.svg',
+};
+
// The agent products a person can connect, any number of logins of each. A product with a
// loginProviderId signs in through that provider's account, so its logins are that provider's.
export const AGENT_PROVIDERS: AgentProvider[] = [
@@ -148,7 +164,11 @@ export const AGENT_PROVIDERS: AgentProvider[] = [
},
];
+/** A provider by id, the built-in agent's among them. */
export function findAgentProvider(id: string): AgentProvider | undefined {
+ if (id === BUILT_IN_AGENT_PROVIDER.id) {
+ return BUILT_IN_AGENT_PROVIDER;
+ }
return AGENT_PROVIDERS.find((provider) => provider.id === id);
}
diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts
index 8d93ab57..47dec9a4 100644
--- a/packages/integrations/src/index.ts
+++ b/packages/integrations/src/index.ts
@@ -1,6 +1,7 @@
export {
AGENT_PROVIDERS,
AGENT_NAME_MAX,
+ BUILT_IN_AGENT_PROVIDER,
findAgentProvider,
loginProviderIdFor,
suggestAgentProvider,
diff --git a/packages/ui/package.json b/packages/ui/package.json
index d2156ed9..d361bef4 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -16,6 +16,7 @@
"dependencies": {
"@base-ui/react": "^1.8.0",
"@fontsource-variable/geist": "^5.3.0",
+ "@shadcn/react": "^0.3.1",
"class-variance-authority": "^0.7.1",
"cn": "^0.2.6",
"lucide-react": "^1.43.0",
diff --git a/packages/ui/src/components/bubble.tsx b/packages/ui/src/components/bubble.tsx
new file mode 100644
index 00000000..c725efd1
--- /dev/null
+++ b/packages/ui/src/components/bubble.tsx
@@ -0,0 +1,73 @@
+import * as React from 'react';
+import {mergeProps} from '@base-ui/react/merge-props';
+import {useRender} from '@base-ui/react/use-render';
+import {cva, type VariantProps} from 'class-variance-authority';
+import {cn} from 'cn';
+
+const bubbleVariants = cva(
+ 'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full',
+ {
+ variants: {
+ variant: {
+ default:
+ '*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
+ secondary:
+ '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
+ muted:
+ '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
+ tinted:
+ '*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
+ outline:
+ '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
+ ghost:
+ 'border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50',
+ destructive:
+ '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+function Bubble({
+ variant = 'default',
+ align = 'start',
+ className,
+ ...props
+}: React.ComponentProps<'div'> &
+ VariantProps & {
+ align?: 'start' | 'end';
+ }) {
+ return (
+
+ );
+}
+
+function BubbleContent({className, render, ...props}: useRender.ComponentProps<'div'>) {
+ return useRender({
+ defaultTagName: 'div',
+ props: mergeProps<'div'>(
+ {
+ className: cn(
+ 'w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50',
+ className,
+ ),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: 'bubble-content',
+ },
+ });
+}
+
+export {Bubble, BubbleContent};
diff --git a/packages/ui/src/components/input-group.tsx b/packages/ui/src/components/input-group.tsx
new file mode 100644
index 00000000..63f44f11
--- /dev/null
+++ b/packages/ui/src/components/input-group.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import * as React from 'react';
+import {cva, type VariantProps} from 'class-variance-authority';
+import {cn} from 'cn';
+
+import {Button} from '@proxy/ui/components/button';
+import {Textarea} from '@proxy/ui/components/textarea';
+
+function InputGroup({className, ...props}: React.ComponentProps<'div'>) {
+ return (
+
[data-slot=trace-line]]:flex *:has-[>[data-slot=trace-line]]:w-fit *:has-[>[data-slot=trace-line]]:max-w-full *:has-[>[data-slot=trace-line]]:min-w-0 *:has-[>[data-slot=trace-line]]:rounded-md *:has-[>[data-slot=trace-line]]:text-left *:has-[>[data-slot=trace-line]]:outline-none *:has-[>[data-slot=trace-line]]:focus-visible:ring-3 *:has-[>[data-slot=trace-line]]:focus-visible:ring-ring/50',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TraceLine({
+ className,
+ status,
+ ...props
+}: React.ComponentProps<'span'> & {
+ status?: 'running' | 'error' | 'pending';
+}) {
+ return (
+
+ );
+}
+
+function TraceIcon({className, ...props}: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+function TraceTitle({className, ...props}: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+export {Trace, TraceLine, TraceIcon, TraceTitle};
diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css
index ea570e82..dba5ce13 100644
--- a/packages/ui/src/styles.css
+++ b/packages/ui/src/styles.css
@@ -55,6 +55,14 @@
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
+ --color-avatar-red: var(--avatar-red);
+ --color-avatar-orange: var(--avatar-orange);
+ --color-avatar-yellow: var(--avatar-yellow);
+ --color-avatar-green: var(--avatar-green);
+ --color-avatar-blue: var(--avatar-blue);
+ --color-avatar-purple: var(--avatar-purple);
+ --color-avatar-teal: var(--avatar-teal);
+ --color-avatar-pink: var(--avatar-pink);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
@@ -116,10 +124,19 @@
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent: oklch(0.95 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
+ /* A persona's avatar colors (@proxy/utils/harness AVATAR_COLORS), one hue each. */
+ --avatar-red: oklch(0.64 0.21 25);
+ --avatar-orange: oklch(0.72 0.17 55);
+ --avatar-yellow: oklch(0.8 0.16 90);
+ --avatar-green: oklch(0.68 0.17 150);
+ --avatar-blue: oklch(0.62 0.19 255);
+ --avatar-purple: oklch(0.62 0.2 300);
+ --avatar-teal: oklch(0.7 0.13 195);
+ --avatar-pink: oklch(0.7 0.19 350);
}
.dark {
@@ -154,6 +171,14 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
+ --avatar-red: oklch(0.7 0.19 25);
+ --avatar-orange: oklch(0.76 0.16 55);
+ --avatar-yellow: oklch(0.84 0.15 90);
+ --avatar-green: oklch(0.74 0.16 150);
+ --avatar-blue: oklch(0.7 0.17 255);
+ --avatar-purple: oklch(0.7 0.18 300);
+ --avatar-teal: oklch(0.76 0.12 195);
+ --avatar-pink: oklch(0.75 0.17 350);
}
@layer base {
@@ -170,3 +195,10 @@
@apply font-sans;
}
}
+
+/* The message scroller's viewport fades its bottom only while there is more below
+ (message-scroller.tsx). A browser that drives the fade from the scroll position itself
+ (shadcn's scroll-fade) animates over this; one that can't gets the fade lifted at the end. */
+[data-slot='message-scroller-viewport'][data-scrollable-end='false'] {
+ --scroll-fade-b: 0px;
+}
diff --git a/packages/utils/package.json b/packages/utils/package.json
index 430545d1..37f584a0 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -6,6 +6,7 @@
"exports": {
".": "./src/index.ts",
"./days": "./src/days.ts",
+ "./harness": "./src/harness.ts",
"./tasks": "./src/tasks.ts",
"./units": "./src/units.ts",
"./transaction-categories": "./src/transaction-categories.ts"
diff --git a/packages/utils/src/experiments.ts b/packages/utils/src/experiments.ts
index 33dc1c99..2b5c326a 100644
--- a/packages/utils/src/experiments.ts
+++ b/packages/utils/src/experiments.ts
@@ -8,6 +8,9 @@
export const EXPERIMENTS = {
// The Ask quick action: what a meeting, document, email or issue says, answered from reading it.
ask: 'Ask quick action',
+ // The built-in agent: a conversation with an agent Personal Agent Proxy runs itself, behind its
+ // own gate, on the Chat page.
+ harness: 'Built-in agent',
} as const satisfies Record;
export type Experiment = keyof typeof EXPERIMENTS;
diff --git a/packages/utils/src/harness.test.ts b/packages/utils/src/harness.test.ts
new file mode 100644
index 00000000..be2fc66d
--- /dev/null
+++ b/packages/utils/src/harness.test.ts
@@ -0,0 +1,160 @@
+import {describe, expect, test} from 'bun:test';
+
+import {
+ AVATAR_COLORS,
+ callArguments,
+ cut,
+ DEFAULT_LOOK,
+ delegateArgs,
+ isAvatarColor,
+ type LogEntry,
+ parseLook,
+ pendingToolCalls,
+ readyForInference,
+ shuffleLook,
+ sleepUntil,
+} from './harness';
+
+const person = (text: string): LogEntry => ({kind: 'person', text, toolCalls: null, callId: null});
+const assistant = (text: string, calls: Array<{id: string; name: string}> = []): LogEntry => ({
+ kind: 'assistant',
+ text,
+ toolCalls: calls.map((call) => ({...call, arguments: '{}'})),
+ callId: null,
+});
+const result = (callId: string): LogEntry => ({
+ kind: 'tool_result',
+ text: 'ok',
+ toolCalls: null,
+ callId,
+});
+const event = (callId: string): LogEntry => ({
+ kind: 'event',
+ text: 'report',
+ toolCalls: null,
+ callId,
+});
+
+describe('pendingToolCalls', () => {
+ test('is the last answer’s calls without a result, and none before an answer', () => {
+ expect(pendingToolCalls([person('hi')])).toEqual([]);
+ const log = [
+ person('hi'),
+ assistant('', [
+ {id: 'a', name: 'read'},
+ {id: 'b', name: 'read'},
+ ]),
+ result('a'),
+ ];
+ expect(pendingToolCalls(log).map((call) => call.id)).toEqual(['b']);
+ expect(pendingToolCalls([...log, result('b')])).toEqual([]);
+ });
+
+ test('only the last answer’s calls count', () => {
+ const log = [assistant('', [{id: 'a', name: 'read'}]), assistant('done')];
+ expect(pendingToolCalls(log)).toEqual([]);
+ });
+});
+
+describe('readyForInference', () => {
+ test('after the person spoke, or every call has its result, but not after the model’s own answer', () => {
+ expect(readyForInference([])).toBe(false);
+ expect(readyForInference([person('hi')])).toBe(true);
+ expect(readyForInference([person('hi'), assistant('hello')])).toBe(false);
+ const calling = [person('hi'), assistant('', [{id: 'a', name: 'read'}])];
+ expect(readyForInference(calling)).toBe(false);
+ expect(readyForInference([...calling, result('a')])).toBe(true);
+ });
+
+ test('after an event, as after the person, but not while a call is still unanswered', () => {
+ const delegated = [
+ person('hi'),
+ assistant('', [{id: 'd', name: 'delegate'}]),
+ result('d'),
+ assistant('Started.'),
+ ];
+ expect(readyForInference(delegated)).toBe(false);
+ expect(readyForInference([...delegated, event('d')])).toBe(true);
+ expect(
+ readyForInference([
+ ...delegated,
+ person('and?'),
+ assistant('', [{id: 'e', name: 'delegate'}]),
+ event('d'),
+ ]),
+ ).toBe(false);
+ });
+});
+
+describe('callArguments', () => {
+ test('are what the model wrote, parsed, or null where that was not JSON', () => {
+ expect(callArguments({id: 'c1', name: 'read', arguments: '{"tool":"list"}'})).toEqual({
+ tool: 'list',
+ });
+ expect(callArguments({id: 'c1', name: 'read', arguments: '{"tool":'})).toBeNull();
+ });
+});
+
+describe('cut', () => {
+ test('keeps a text within the limit as it is, and ends a longer one with an ellipsis at the limit', () => {
+ expect(cut('short', 10)).toBe('short');
+ expect(cut('x'.repeat(10), 10)).toBe('x'.repeat(10));
+ expect(cut('x'.repeat(11), 10)).toBe(`${'x'.repeat(9)}…`);
+ });
+});
+
+describe('delegateArgs', () => {
+ test('are a title, trimmed and cut to a task’s, and a brief, or nothing', () => {
+ expect(delegateArgs({title: ' Find the invoices ', brief: ' Search the mailbox. '})).toEqual({
+ title: 'Find the invoices',
+ brief: 'Search the mailbox.',
+ });
+ expect(delegateArgs({title: 'x'.repeat(130), brief: 'Go'})?.title).toHaveLength(120);
+ expect(delegateArgs({title: 'Find', brief: ' '})).toBeNull();
+ expect(delegateArgs({brief: 'Go'})).toBeNull();
+ expect(delegateArgs('go')).toBeNull();
+ });
+});
+
+describe('sleepUntil', () => {
+ const now = new Date('2026-10-21T10:00:00Z');
+
+ test('takes a time or seconds from now, never in the past and at most a week', () => {
+ expect(sleepUntil({until: '2026-10-21T10:05:00Z'}, now)).toEqual(
+ new Date('2026-10-21T10:05:00Z'),
+ );
+ expect(sleepUntil({seconds: 120}, now)).toEqual(new Date('2026-10-21T10:02:00Z'));
+ expect(sleepUntil({until: '2026-10-21T09:00:00Z'}, now)).toEqual(now);
+ expect(sleepUntil({seconds: 60 * 60 * 24 * 30}, now)).toEqual(new Date('2026-10-28T10:00:00Z'));
+ });
+
+ test('is null for anything else', () => {
+ expect(sleepUntil({until: 'tomorrow'}, now)).toBeNull();
+ expect(sleepUntil({}, now)).toBeNull();
+ expect(sleepUntil('soon', now)).toBeNull();
+ });
+});
+
+describe('heads', () => {
+ test('are one of eight colors, told from anything else', () => {
+ expect(AVATAR_COLORS).toHaveLength(8);
+ expect(isAvatarColor('blue')).toBe(true);
+ expect(isAvatarColor('brown')).toBe(false);
+ });
+
+ test('a look is read from its JSON, and the default stands in for none or nonsense', () => {
+ const look = {nose: 0.5, noseOut: 0.3, eye: 30, eyeTheta: 60, eyeLat: 4, eyes: 1 as const};
+ expect(parseLook(JSON.stringify(look))).toEqual(look);
+ expect(parseLook(null)).toEqual(DEFAULT_LOOK);
+ expect(parseLook('{"nose":9}')).toEqual(DEFAULT_LOOK);
+ expect(parseLook('not json')).toEqual(DEFAULT_LOOK);
+ });
+
+ test('a shuffled look is within the family’s range, rounded as the lab rounds, with two eyes', () => {
+ const low = shuffleLook(() => 0);
+ const high = shuffleLook(() => 0.999999);
+ expect(low).toEqual({nose: 0.4, noseOut: 0.2, eye: 22, eyeTheta: 45, eyeLat: -8, eyes: 2});
+ expect(high).toEqual({nose: 0.58, noseOut: 0.6, eye: 34, eyeTheta: 70, eyeLat: 20, eyes: 2});
+ expect(parseLook(JSON.stringify(shuffleLook()))).not.toEqual(DEFAULT_LOOK);
+ });
+});
diff --git a/packages/utils/src/harness.ts b/packages/utils/src/harness.ts
new file mode 100644
index 00000000..4abd2166
--- /dev/null
+++ b/packages/utils/src/harness.ts
@@ -0,0 +1,223 @@
+import {Result} from 'ts-results-es';
+import {z} from 'zod';
+
+/**
+ * The built-in agent's log, as the api writes it and the web app reads it: what is shared between
+ * them. The log is the transcript: the person's messages, the model's answers with the tool calls
+ * it made, what each call answered, events that came since (a sub-agent's report), and, once it
+ * has grown long, summaries of it up to a point, which the model reads in place of what they
+ * cover. A call is pending until its result is in the log, and the model is run again only once
+ * none is: a delegation is answered at once, that the sub-agent has started, and its report lands
+ * as an event when it is done.
+ */
+
+/** A tool call the model made, as the model API gives it: its arguments are the JSON it wrote. */
+export type ToolCall = {id: string; name: string; arguments: string};
+
+/** A call's arguments as the model wrote them, parsed; null where they aren't JSON. */
+export function callArguments(call: ToolCall): unknown {
+ const parsed = Result.wrap((): unknown => JSON.parse(call.arguments));
+ return parsed.isOk() ? parsed.value : null;
+}
+
+/** The text cut to `max` characters, the last of them an ellipsis where it was longer. */
+export function cut(text: string, max: number): string {
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
+}
+
+export type LogEntry = {
+ kind: 'person' | 'assistant' | 'tool_result' | 'event' | 'summary';
+ text: string;
+ toolCalls: ToolCall[] | null;
+ // A tool_result's or an event's call, by the id the model gave it.
+ callId: string | null;
+};
+
+/** The calls of the last assistant entry that have no result yet; none when the log is ready for the model. */
+export function pendingToolCalls(entries: LogEntry[]): ToolCall[] {
+ const last = entries.findLastIndex((entry) => entry.kind === 'assistant');
+ if (last === -1) {
+ return [];
+ }
+ const answered = new Set(
+ entries
+ .slice(last + 1)
+ .filter((entry) => entry.kind === 'tool_result')
+ .map((entry) => entry.callId),
+ );
+ return (entries[last]?.toolCalls ?? []).filter((call) => !answered.has(call.id));
+}
+
+/**
+ * Whether the model should be run on the log: something has come since it last answered, and
+ * every call it made has its result.
+ */
+export function readyForInference(entries: LogEntry[]): boolean {
+ if (pendingToolCalls(entries).length > 0) {
+ return false;
+ }
+ const last = entries.at(-1);
+ if (!last) {
+ return false;
+ }
+ return last.kind !== 'assistant';
+}
+
+/** The name of the one tool the main agent has: a sub-agent, started on a brief, which reports back. */
+export const DELEGATE_TOOL = 'delegate';
+
+/** The name of the one tool a sub-agent has beyond its connections: a wake-up later. */
+export const SLEEP_TOOL = 'sleep_until';
+
+// How long a sub-agent's title may be: a task's.
+const MAX_TITLE = 120;
+
+/** A delegate call's arguments: the sub-agent's title, cut to a task's, and its brief. Null when they are not that. */
+export function delegateArgs(args: unknown): {title: string; brief: string} | null {
+ if (typeof args !== 'object' || args === null) {
+ return null;
+ }
+ const {title, brief} = args as {title?: unknown; brief?: unknown};
+ if (
+ typeof title !== 'string' ||
+ typeof brief !== 'string' ||
+ title.trim() === '' ||
+ brief.trim() === ''
+ ) {
+ return null;
+ }
+ return {title: cut(title.trim(), MAX_TITLE), brief: brief.trim()};
+}
+
+// How long a sleep may be at most.
+const MAX_SLEEP_MS = 7 * 24 * 60 * 60 * 1000;
+
+/**
+ * When a call to sleep wakes: `{until}` as an ISO time, or `{seconds}` from now; in the past is
+ * now, and more than a week is cut to a week. Null when the arguments say neither.
+ */
+export function sleepUntil(args: unknown, now: Date): Date | null {
+ if (typeof args !== 'object' || args === null) {
+ return null;
+ }
+ const {until, seconds} = args as {until?: unknown; seconds?: unknown};
+ const requested =
+ typeof seconds === 'number'
+ ? new Date(now.getTime() + seconds * 1000)
+ : typeof until === 'string'
+ ? new Date(until)
+ : null;
+ if (requested === null || Number.isNaN(requested.getTime())) {
+ return null;
+ }
+ const earliest = Math.max(requested.getTime(), now.getTime());
+ return new Date(Math.min(earliest, now.getTime() + MAX_SLEEP_MS));
+}
+
+/**
+ * A built-in agent's head, as the head lab (design/head-lab) makes it: a sphere in one of eight
+ * colors, with painted eyes and a darker nose sphere. A look is what the person keeps beside its
+ * name: the shape of its features, in the lab's units; the color is kept beside it. How it is
+ * turned is not part of a look: the app poses a head as the moment asks.
+ */
+export const AVATAR_COLORS = [
+ 'red',
+ 'orange',
+ 'yellow',
+ 'green',
+ 'teal',
+ 'blue',
+ 'purple',
+ 'pink',
+] as const;
+
+export type AvatarColor = (typeof AVATAR_COLORS)[number];
+
+export function isAvatarColor(value: string): value is AvatarColor {
+ return (AVATAR_COLORS as readonly string[]).includes(value);
+}
+
+/** A head's features: the nose's size as a share of the head and how far it sits out, the eyes' size and place in degrees, and how many eyes. */
+export type Look = {
+ nose: number;
+ noseOut: number;
+ eye: number;
+ eyeTheta: number;
+ eyeLat: number;
+ eyes: 1 | 2;
+};
+
+// What the lab's sliders allow of each.
+const LOOK_RANGES: Record, [number, number]> = {
+ nose: [0.36, 0.6],
+ noseOut: [0.1, 0.7],
+ eye: [18, 36],
+ eyeTheta: [40, 75],
+ eyeLat: [-12, 24],
+};
+
+/** The lab's first look (design/head-lab/looks/2026-10-27-blue.json), which a head without one gets. */
+export const DEFAULT_LOOK: Look = {
+ nose: 0.48,
+ noseOut: 0.35,
+ eye: 27,
+ eyeTheta: 55,
+ eyeLat: 8,
+ eyes: 2,
+};
+
+/** The look as the api takes it and the web app reads it: the lab's units, within what its sliders allow. */
+export const lookSchema = z.object({
+ nose: z.number().min(LOOK_RANGES.nose[0]).max(LOOK_RANGES.nose[1]),
+ noseOut: z.number().min(LOOK_RANGES.noseOut[0]).max(LOOK_RANGES.noseOut[1]),
+ eye: z.number().min(LOOK_RANGES.eye[0]).max(LOOK_RANGES.eye[1]),
+ eyeTheta: z.number().min(LOOK_RANGES.eyeTheta[0]).max(LOOK_RANGES.eyeTheta[1]),
+ eyeLat: z.number().min(LOOK_RANGES.eyeLat[0]).max(LOOK_RANGES.eyeLat[1]),
+ eyes: z.union([z.literal(1), z.literal(2)]),
+});
+
+/** A look from the JSON it is kept as; the default where there is none, or it isn't one. */
+export function parseLook(json: string | null): Look {
+ if (json === null) {
+ return DEFAULT_LOOK;
+ }
+ const parsed = Result.wrap((): unknown => JSON.parse(json));
+ if (parsed.isErr()) {
+ return DEFAULT_LOOK;
+ }
+ const look = lookSchema.safeParse(parsed.value);
+ return look.success ? look.data : DEFAULT_LOOK;
+}
+
+// Rounded to what the lab's shuffle gives, so looks read the same in both.
+function between(random: () => number, low: number, high: number, decimals: number): number {
+ return Number((low + random() * (high - low)).toFixed(decimals));
+}
+
+/** A new look, as the lab's shuffle makes one: features within the family's range, two eyes. */
+export function shuffleLook(random: () => number = Math.random): Look {
+ return {
+ nose: between(random, 0.4, 0.58, 3),
+ noseOut: between(random, 0.2, 0.6, 2),
+ eye: between(random, 22, 34, 1),
+ eyeTheta: Math.round(between(random, 45, 70, 0)),
+ eyeLat: Math.round(between(random, -8, 20, 0)),
+ eyes: 2,
+ };
+}
+
+// How much of a message a preview of it shows.
+const PREVIEW_CHARS = 120;
+
+/** A message as a line of words, for a list: its Markdown marks dropped, its whitespace folded, cut to a line. */
+export function preview(text: string): string {
+ return text
+ .replace(/```[\s\S]*?```/g, ' ')
+ .replace(/^\s*(?:[-*+]|\d+\.|#{1,6}|>)\s+/gm, '')
+ .replace(/^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)*\|?\s*$/gm, '')
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+ .replace(/[*_`~|]+/g, '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, PREVIEW_CHARS);
+}
diff --git a/packages/web/package.json b/packages/web/package.json
index 168e3a92..9a3edb60 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -20,9 +20,12 @@
"lucide-react": "^1.43.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
+ "react-markdown": "10",
"react-plaid-link": "^5.0.0",
"react-router": "^8.3.1",
+ "remark-gfm": "4",
"tailwindcss": "^4.3.3",
+ "three": "0.170.0",
"ts-results-es": "^7.1.0",
"zod": "^4.5.4"
},
@@ -32,6 +35,7 @@
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
+ "@types/three": "0.170.0",
"@vitejs/plugin-react": "^6.1.0",
"happy-dom": "^20.14.5",
"oxlint": "^1.79.0",
diff --git a/packages/web/src/client/harness-client.ts b/packages/web/src/client/harness-client.ts
new file mode 100644
index 00000000..70e3c66e
--- /dev/null
+++ b/packages/web/src/client/harness-client.ts
@@ -0,0 +1,90 @@
+import {AVATAR_COLORS, lookSchema} from '@proxy/utils/harness';
+import {z} from 'zod';
+import {apiRequest, apiSend, orgApi} from '@/client/request';
+
+// The Chat page's api: the organization's personas, each named and given a head, which every
+// member sees, the person's own conversation with each, its turns, and what they say in it.
+
+// A persona as the person makes or changes it: its name and its head, a look in a color.
+const personaSchema = z.object({
+ name: z.string(),
+ look: lookSchema,
+ color: z.enum(AVATAR_COLORS),
+});
+
+const chatPersonaSchema = personaSchema.extend({
+ id: z.string(),
+ // Whether the person may rename, reface or delete it: they made it, or own the organization.
+ mayChange: z.boolean(),
+ // The last thing said in the person's conversation with it, for the list; null before anything was.
+ lastMessage: z
+ .object({kind: z.enum(['person', 'assistant']), text: z.string(), at: z.string()})
+ .nullable(),
+});
+
+// A turn of the conversation: what the person said, or what the persona answered, which is what
+// went wrong where its turn failed. What the persona did in between is in the activity log, not
+// here.
+const entrySchema = z.object({
+ id: z.string(),
+ kind: z.enum(['person', 'assistant']),
+ text: z.string(),
+ isError: z.boolean(),
+ at: z.string(),
+});
+
+const threadLogSchema = z.object({
+ persona: chatPersonaSchema,
+ entries: z.array(entrySchema),
+ // Whether the persona is at work on the conversation, sub-agents included, or asleep until a time.
+ busy: z.boolean(),
+ sleepingUntil: z.string().nullable(),
+});
+
+export type Persona = z.infer;
+export type ChatPersona = z.infer;
+export type ChatEntry = z.infer;
+export type ThreadLog = z.infer;
+
+export function listPersonas(org: string) {
+ return apiRequest(
+ 'GET',
+ `${orgApi(org)}/chat/personas`,
+ z.object({personas: z.array(chatPersonaSchema)}),
+ );
+}
+
+export function createPersona(org: string, persona: Persona) {
+ return apiRequest(
+ 'POST',
+ `${orgApi(org)}/chat/personas`,
+ z.object({persona: chatPersonaSchema}),
+ persona,
+ );
+}
+
+export function updatePersona(org: string, personaId: string, persona: Persona) {
+ return apiRequest(
+ 'PUT',
+ `${orgApi(org)}/chat/personas/${personaId}`,
+ z.object({persona: chatPersonaSchema}),
+ persona,
+ );
+}
+
+export function deletePersona(org: string, personaId: string) {
+ return apiSend('DELETE', `${orgApi(org)}/chat/personas/${personaId}`);
+}
+
+export function getThread(org: string, personaId: string) {
+ return apiRequest('GET', `${orgApi(org)}/chat/personas/${personaId}/thread`, threadLogSchema);
+}
+
+export function postMessage(org: string, personaId: string, text: string) {
+ return apiRequest(
+ 'POST',
+ `${orgApi(org)}/chat/personas/${personaId}/messages`,
+ z.object({entry: entrySchema}),
+ {text},
+ );
+}
diff --git a/packages/web/src/client/schemas.ts b/packages/web/src/client/schemas.ts
index 5861fc34..c7085b2b 100644
--- a/packages/web/src/client/schemas.ts
+++ b/packages/web/src/client/schemas.ts
@@ -1,4 +1,5 @@
import {mcpToolSchema} from '@proxy/integrations';
+import {AVATAR_COLORS, lookSchema} from '@proxy/utils/harness';
import {z} from 'zod';
// The api's response shapes, shared by the human side's and the agent side's clients.
@@ -68,8 +69,8 @@ export const auditEntrySchema = z.object({
// What went wrong, for a failed request: the api's kind of error, with the provider's HTTP
// status where it answered with one, such as `provider_unreachable 429`.
error: z.string().nullable(),
- // The web app's agent pages, or the MCP server.
- via: z.enum(['web', 'mcp']),
+ // The web app's agent pages, the MCP server, or the built-in agent's own loop.
+ via: z.enum(['web', 'mcp', 'harness']),
// The agent's task it was made for, among the answer's tasks; none for entries from before
// tasks, and for what Personal Agent Proxy did itself.
taskId: z.string().nullable(),
@@ -86,12 +87,16 @@ export const auditEntrySchema = z.object({
export const activityTaskSchema = z.object({
id: z.string(),
agentId: z.string(),
+ // The persona of the Chat page a sub-agent ran it as, with its head; null for every other task.
+ persona: z
+ .object({id: z.string(), name: z.string(), look: lookSchema, color: z.enum(AVATAR_COLORS)})
+ .nullable(),
// The agent's task it is a sub-task of, handed to one of its sub-agents; null for one started
// on its own.
parentId: z.string().nullable(),
title: z.string(),
goal: z.string(),
- via: z.enum(['web', 'mcp']),
+ via: z.enum(['web', 'mcp', 'harness']),
startedAt: z.string(),
// Null while the agent hasn't called it finished.
finishedAt: z.string().nullable(),
diff --git a/packages/web/src/components/app-shell.tsx b/packages/web/src/components/app-shell.tsx
index a726a0cb..b851709a 100644
--- a/packages/web/src/components/app-shell.tsx
+++ b/packages/web/src/components/app-shell.tsx
@@ -1,7 +1,12 @@
-import type {ReactNode} from 'react';
+import {PanelLeftIcon} from 'lucide-react';
+import {type ReactNode, useState} from 'react';
+import {useLocation} from 'react-router';
import {AppSidebar} from '@/components/nav';
+import {Button} from '@proxy/ui/components/button';
+import {Sheet, SheetContent, SheetTitle} from '@proxy/ui/components/sheet';
import {SidebarTrigger} from '@proxy/ui/components/sidebar';
import {useDesktop} from '@proxy/ui/hooks/use-media-query';
+import {cn} from '@proxy/ui/lib/utils';
type Props = {
children: ReactNode;
@@ -9,21 +14,72 @@ type Props = {
// button alone in its corner.
title?: ReactNode;
actions?: ReactNode;
+ // A second sidebar beside the first, as Chat's conversations: a column on desktop, and on a
+ // phone a sheet opened from the header, named by `panelLabel`.
+ panel?: ReactNode;
+ panelLabel?: string;
+ // `inside`: the page is as tall as the window and scrolls a part of itself, as Chat scrolls its
+ // log, so the header, the sidebars and the rest stay put, the overscroll bounce included.
+ // Otherwise the whole page scrolls.
+ scroll?: 'page' | 'inside';
};
+// The second sidebar on a phone: a sheet from the left. It is keyed by the path, so a move
+// elsewhere closes it.
+function PanelSheet({panel, label}: {panel: ReactNode; label: string}) {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+ {label}
+ {panel}
+
+
+ );
+}
+
// The human side's frame: the sidebar, a header with the page's title and actions, and the page
// in one centered column.
-export function AppShell({children, title, actions}: Props) {
+export function AppShell({
+ children,
+ title,
+ actions,
+ panel,
+ panelLabel = 'Panel',
+ scroll = 'page',
+}: Props) {
const desktop = useDesktop();
+ const {pathname} = useLocation();
return (
<>
-
diff --git a/packages/web/src/components/audit-entry-dialog.tsx b/packages/web/src/components/audit-entry-dialog.tsx
index ff3d5b64..6c311467 100644
--- a/packages/web/src/components/audit-entry-dialog.tsx
+++ b/packages/web/src/components/audit-entry-dialog.tsx
@@ -37,6 +37,9 @@ function formatMoment(iso: string): string {
});
}
+// How the request came in, as the entry says.
+const THROUGH = {web: 'Agent web pages', mcp: 'MCP', harness: 'Built-in agent'} as const;
+
function Detail({label, children}: {label: string; children: ReactNode}) {
return (
@@ -178,7 +181,7 @@ function AuditEntryContent({
{action ? action.label : Unknown}
- {entry.via === 'mcp' ? 'MCP' : 'Agent web pages'}
+ {THROUGH[entry.via]}
);
diff --git a/packages/web/src/components/audit-list.test.tsx b/packages/web/src/components/audit-list.test.tsx
index dc63007f..1e56ee05 100644
--- a/packages/web/src/components/audit-list.test.tsx
+++ b/packages/web/src/components/audit-list.test.tsx
@@ -48,6 +48,7 @@ const task: ActivityTask = {
startedAt: '2026-10-14T21:00:00.000Z',
lastStartedAt: '2026-10-14T21:00:00.000Z',
finishedAt: null,
+ persona: null,
parentId: null,
outcome: null,
};
diff --git a/packages/web/src/components/audit-list.tsx b/packages/web/src/components/audit-list.tsx
index ce70aae7..20ead5e1 100644
--- a/packages/web/src/components/audit-list.tsx
+++ b/packages/web/src/components/audit-list.tsx
@@ -2,6 +2,7 @@ import {useState} from 'react';
import {AuditEntryDialog} from '@/components/audit-entry-dialog';
import {EmptyRows, RowHeader, RowList} from '@/components/row-list';
import {AgentLogo, IntegrationLogo, ProxyLogo} from '@/components/brand-logo';
+import {Head} from '@/components/head';
import {connectionLabel, describeEntry, locateEntry} from '@/lib/access';
import {formatDateTime} from '@/lib/format';
import type {ActivityTask, AgentLogin, AuditEntry, Connection} from '@/lib/types';
@@ -27,11 +28,28 @@ function NamedLogo({name, logo}: {name: string; logo: ReactNode}) {
);
}
-// The agent an entry was made by, or Personal Agent Proxy for what it did itself, with who asked.
-function EntryAgentLogo({entry, agent}: {entry: AuditEntry; agent: AgentLogin | undefined}) {
+// The agent an entry was made by, or Personal Agent Proxy for what it did itself, with who asked;
+// a persona of the Chat page, with its head, where the entry was made for one of its tasks.
+function EntryAgentLogo({
+ entry,
+ agent,
+ task,
+}: {
+ entry: AuditEntry;
+ agent: AgentLogin | undefined;
+ task: ActivityTask | undefined;
+}) {
if (entry.agentId === null) {
return } />;
}
+ if (task?.persona) {
+ return (
+ }
+ />
+ );
+ }
return (
{formatDateTime(entry.at)}
- {showAgent && }
+ {showAgent && }
diff --git a/packages/web/src/components/head.tsx b/packages/web/src/components/head.tsx
new file mode 100644
index 00000000..fa385d76
--- /dev/null
+++ b/packages/web/src/components/head.tsx
@@ -0,0 +1,199 @@
+import {useEffect, useRef} from 'react';
+import type {Look} from '@proxy/utils/harness';
+import {cn} from '@proxy/ui/lib/utils';
+import {FACING, loadStage, paintHead, pixels, type Pose, REST} from '@/lib/head';
+
+// A persona's head, drawn as the head lab draws it (lib/head.ts). It is decorative: the text
+// beside one always names it.
+
+/** A still head, `size` CSS pixels wide, in its pose; at rest unless another is given. */
+export function Head({
+ look,
+ color,
+ size,
+ pose = REST,
+ className,
+}: {
+ look: Look;
+ color: string;
+ size: number;
+ pose?: Pose;
+ className?: string;
+}) {
+ const ref = useRef(null);
+ // Drawn again only when the head itself changes, by value: the page reads its agents again as
+ // it goes, and gets new objects of the same heads each time.
+ const key = JSON.stringify([look, color, pose]);
+ const latest = useRef({look, color, pose});
+ useEffect(() => {
+ latest.current = {look, color, pose};
+ }, [look, color, pose]);
+ useEffect(() => {
+ let current = true;
+ void loadStage().then((stage) => {
+ if (!current || !stage || !ref.current) {
+ return;
+ }
+ const spec = latest.current;
+ paintHead(
+ stage,
+ ref.current,
+ {look: spec.look, color: spec.color},
+ spec.pose,
+ pixels(size),
+ false,
+ );
+ });
+ return () => {
+ current = false;
+ };
+ }, [key, size]);
+ return (
+
+ );
+}
+
+// How far the head turns toward the cursor, in degrees, at the edge of the window, and how fast
+// it follows: the share of the way it goes each frame.
+const TURN = 45;
+const NOD = 30;
+const TILT = 10;
+const FOLLOW = 0.15;
+
+// Where the pointer last was on the page, from any move or press since the app loaded, so a head
+// that appears, as in a dialog opened by a click, looks at it from its first frame rather than
+// waiting for the pointer to move again; and the live heads on the page, each set going again as
+// it moves.
+let pointer: {x: number; y: number} | null = null;
+const wakers = new Set<() => void>();
+if (typeof window !== 'undefined') {
+ const note = (event: PointerEvent) => {
+ pointer = {x: event.clientX, y: event.clientY};
+ for (const wake of wakers) {
+ wake();
+ }
+ };
+ window.addEventListener('pointermove', note, {passive: true});
+ window.addEventListener('pointerdown', note, {passive: true});
+}
+
+function clamp(value: number): number {
+ return Math.max(-1, Math.min(1, value));
+}
+
+// Where the head should look to meet the pointer: turned and nodded toward it by how far it is
+// from the head across the window, tilted a little with the turn; at you while the pointer is
+// unknown.
+function toward(canvas: HTMLCanvasElement): Pose {
+ if (pointer === null) {
+ return {...FACING};
+ }
+ const box = canvas.getBoundingClientRect();
+ const dx = clamp((pointer.x - (box.left + box.width / 2)) / (window.innerWidth / 2));
+ const dy = clamp((pointer.y - (box.top + box.height / 2)) / (window.innerHeight / 2));
+ return {rotX: dy * NOD, rotY: FACING.rotY + dx * TURN, rotZ: -dx * TILT};
+}
+
+/**
+ * A head that looks at the cursor while `watching`: facing you, turning toward the pointer
+ * wherever it goes on the page; not watching, it settles back to rest. Either way it is drawn
+ * only while it has somewhere to go: once it is where it should look, it stays, drawn no more
+ * until the pointer moves, or it is to look elsewhere.
+ */
+export function LiveHead({
+ look,
+ color,
+ size,
+ watching = true,
+ className,
+}: {
+ look: Look;
+ color: string;
+ size: number;
+ watching?: boolean;
+ className?: string;
+}) {
+ const ref = useRef(null);
+ // The look, color and whether to watch as they are now, read each frame, so a change shows
+ // without restarting; and the loop, to set going again once it has settled: as these change,
+ // and as the pointer moves.
+ const spec = useRef({look, color});
+ const watch = useRef(watching);
+ const wake = useRef<(() => void) | null>(null);
+ useEffect(() => {
+ spec.current = {look, color};
+ watch.current = watching;
+ wake.current?.();
+ }, [look, color, watching]);
+ useEffect(() => {
+ const canvas = ref.current;
+ if (!canvas) {
+ return;
+ }
+ let current = true;
+ let running = false;
+ let frame = 0;
+ // Starts looking at the pointer, or at rest, then goes a share of the way to where it
+ // should look each frame.
+ const pose = watch.current ? toward(canvas) : {...REST};
+ void loadStage().then((stage) => {
+ if (!current || !stage) {
+ return;
+ }
+ const tick = () => {
+ if (!current) {
+ return;
+ }
+ const target = watch.current ? toward(canvas) : REST;
+ const settled =
+ Math.abs(target.rotX - pose.rotX) < 0.05 &&
+ Math.abs(target.rotY - pose.rotY) < 0.05 &&
+ Math.abs(target.rotZ - pose.rotZ) < 0.05;
+ if (settled) {
+ Object.assign(pose, target);
+ }
+ if (!settled) {
+ pose.rotX += (target.rotX - pose.rotX) * FOLLOW;
+ pose.rotY += (target.rotY - pose.rotY) * FOLLOW;
+ pose.rotZ += (target.rotZ - pose.rotZ) * FOLLOW;
+ }
+ paintHead(stage, canvas, spec.current, pose, pixels(size), true);
+ if (settled) {
+ running = false;
+ return;
+ }
+ frame = requestAnimationFrame(tick);
+ };
+ wake.current = () => {
+ if (running || !current) {
+ return;
+ }
+ running = true;
+ frame = requestAnimationFrame(tick);
+ };
+ wakers.add(wake.current);
+ wake.current();
+ });
+ return () => {
+ current = false;
+ if (wake.current) {
+ wakers.delete(wake.current);
+ }
+ wake.current = null;
+ cancelAnimationFrame(frame);
+ };
+ }, [size]);
+ return (
+
+ );
+}
diff --git a/packages/web/src/components/markdown.tsx b/packages/web/src/components/markdown.tsx
new file mode 100644
index 00000000..4c5e6bd8
--- /dev/null
+++ b/packages/web/src/components/markdown.tsx
@@ -0,0 +1,88 @@
+import ReactMarkdown, {type Components} from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import {cn} from '@proxy/ui/lib/utils';
+
+// What a persona answers, as Markdown: paragraphs, lists, tables, code and links, styled to sit
+// in a chat bubble. Every link opens in a new tab, since the conversation stays here. Each
+// component takes the element's own props, without the syntax tree node react-markdown hands
+// along, which is no attribute of an element.
+
+const components: Components = {
+ p: ({node: _node, className, ...props}) => (
+
+ ),
+ a: ({node: _node, className, ...props}) => (
+
+ ),
+ ul: ({node: _node, className, ...props}) => (
+
+ This conversation is yours alone. {current.name} hands each piece of work to a
+ sub-agent, which can use what is shared with everyone and what you connected
+ yourself, as far as its access allows. Every call is in the activity log.
+