From 8721ae8a102b3ebbe8719c09efde4984c76a365f Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:22:10 +0000 Subject: [PATCH 01/19] add a built-in agent: chat with an agent proxy runs itself, behind its own gate A Chat page, behind the `harness` experiment, talks to an agent login Personal Agent Proxy runs itself. It has grants and an activity log like any agent, and every call it makes goes through the same MCP tool dispatch as Claude's, for the conversation's task. The model is Claude Sonnet 5.5 over OpenRouter. Everything lives in the database: a thread per conversation, an append-only log of entries (the person, the model with its tool calls, each call's result), and a job per unit of work, inference on a log or one tool call. A worker in each api instance claims jobs with a lease and finishes them in the same transaction as what they write, fenced by the lease, so an instance that dies mid-job loses nothing: the next worker picks the job up. A write that was interrupted is never made again; the model is told its outcome is unknown. One tool beyond the connections, sleep_until, is a job due later. The web app gets a second sidebar beside the icon rail for the conversations, and a page mode that scrolls a part of itself so the header, sidebars and input stay put. --- packages/api/src/activity/route.ts | 6 +- packages/api/src/agent-side/tools.ts | 5 +- packages/api/src/agents/create-agent-login.ts | 4 + packages/api/src/harness/built-in-agent.ts | 34 ++ packages/api/src/harness/inference.test.ts | 126 ++++++ packages/api/src/harness/inference.ts | 134 ++++++ packages/api/src/harness/model.test.ts | 65 +++ packages/api/src/harness/model.ts | 83 ++++ packages/api/src/harness/route.test.ts | 92 ++++ packages/api/src/harness/route.ts | 97 ++++ packages/api/src/harness/thread-task.ts | 43 ++ packages/api/src/harness/tool-job.test.ts | 102 +++++ packages/api/src/harness/tool-job.ts | 94 ++++ packages/api/src/harness/tool-surface.test.ts | 41 ++ packages/api/src/harness/tool-surface.ts | 56 +++ packages/api/src/harness/worker.test.ts | 67 +++ packages/api/src/harness/worker.ts | 95 ++++ packages/api/src/index.ts | 6 + packages/api/src/mcp/tools.ts | 28 +- packages/api/src/server/create-api-router.ts | 9 + packages/api/src/utils/env-schema.ts | 2 +- packages/db/package.json | 1 + .../migration.sql | 83 ++++ packages/db/prisma/schema.prisma | 104 ++++- packages/db/src/agent.ts | 15 + packages/db/src/harness.ts | 427 ++++++++++++++++++ packages/docs/content/agents/chat.mdx | 40 ++ packages/docs/src/nav.ts | 2 +- .../integrations/src/agent-providers.test.ts | 19 +- packages/integrations/src/agent-providers.ts | 26 +- packages/integrations/src/index.ts | 1 + packages/ui/src/styles.css | 2 +- packages/utils/package.json | 1 + packages/utils/src/experiments.ts | 3 + packages/utils/src/harness.test.ts | 68 +++ packages/utils/src/harness.ts | 74 +++ packages/web/src/client/harness-client.ts | 64 +++ packages/web/src/client/schemas.ts | 6 +- packages/web/src/components/app-shell.tsx | 71 ++- .../web/src/components/audit-entry-dialog.tsx | 5 +- packages/web/src/components/nav.tsx | 14 +- packages/web/src/loaders.ts | 18 + packages/web/src/pages/agent-page.tsx | 7 +- packages/web/src/pages/chat-page.tsx | 347 ++++++++++++++ packages/web/src/router.tsx | 6 + 45 files changed, 2561 insertions(+), 32 deletions(-) create mode 100644 packages/api/src/harness/built-in-agent.ts create mode 100644 packages/api/src/harness/inference.test.ts create mode 100644 packages/api/src/harness/inference.ts create mode 100644 packages/api/src/harness/model.test.ts create mode 100644 packages/api/src/harness/model.ts create mode 100644 packages/api/src/harness/route.test.ts create mode 100644 packages/api/src/harness/route.ts create mode 100644 packages/api/src/harness/thread-task.ts create mode 100644 packages/api/src/harness/tool-job.test.ts create mode 100644 packages/api/src/harness/tool-job.ts create mode 100644 packages/api/src/harness/tool-surface.test.ts create mode 100644 packages/api/src/harness/tool-surface.ts create mode 100644 packages/api/src/harness/worker.test.ts create mode 100644 packages/api/src/harness/worker.ts create mode 100644 packages/db/prisma/migrations/v024_add_built_in_agent_threads/migration.sql create mode 100644 packages/db/src/harness.ts create mode 100644 packages/docs/content/agents/chat.mdx create mode 100644 packages/utils/src/harness.test.ts create mode 100644 packages/utils/src/harness.ts create mode 100644 packages/web/src/client/harness-client.ts create mode 100644 packages/web/src/pages/chat-page.tsx diff --git a/packages/api/src/activity/route.ts b/packages/api/src/activity/route.ts index 71914980..d4f14af0 100644 --- a/packages/api/src/activity/route.ts +++ b/packages/api/src/activity/route.ts @@ -22,8 +22,8 @@ export type AuditEntryResponse = { recordTitle: string | null; query: string | null; outcome: 'allowed' | 'denied'; - // 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; @@ -35,7 +35,7 @@ export type ActivityTaskResponse = { agentId: string; 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; diff --git a/packages/api/src/agent-side/tools.ts b/packages/api/src/agent-side/tools.ts index 42ce3166..54893d6b 100644 --- a/packages/api/src/agent-side/tools.ts +++ b/packages/api/src/agent-side/tools.ts @@ -110,8 +110,9 @@ export function listAgentTools(signedIn: SignedInAgent, connectionId: string): P }); } -// Who calls and which way it came in: the web app's agent pages, or the MCP server. -export type AgentCaller = {agent: SignedInAgent; via: 'web' | 'mcp'}; +// Who calls and which way it came in: the web app's agent pages, the MCP server, or the built-in +// agent's own loop. +export type AgentCaller = {agent: SignedInAgent; via: 'web' | 'mcp' | 'harness'}; /** Who runs a tool, and the task it is run for, by its id. */ // PRE-TASK CLIENTS: null for a pre-task client's call (mcp/pre-task-clients.ts). diff --git a/packages/api/src/agents/create-agent-login.ts b/packages/api/src/agents/create-agent-login.ts index dbf3b087..d7062879 100644 --- a/packages/api/src/agents/create-agent-login.ts +++ b/packages/api/src/agents/create-agent-login.ts @@ -32,6 +32,10 @@ function nameFor(provider: AgentProvider, name: string | undefined): Result> { return Do(async ($) => { const provider = $(requirePresent(findAgentProvider(providerId), ApiErr.parseError(`Unknown agent ${providerId}`))); + // The built-in agent is made by Personal Agent Proxy itself (harness/built-in-agent.ts), never handed out. + if (provider.connects === 'built-in') { + return $(Err(ApiErr.parseError(`${provider.name} is not an agent a login is made for`))); + } const name = $(nameFor(provider, givenName)); const password = generatePassword(); const passwordHash = await Bun.password.hash(password); 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..22b13dc7 --- /dev/null +++ b/packages/api/src/harness/built-in-agent.ts @@ -0,0 +1,34 @@ +import {randomBytes} from 'node:crypto'; + +import type {Result} from 'ts-results-es'; + +import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils'; +import {createAgent, getSignedInAgentByProvider, isProviderConflict, type SignedInAgent} from '@proxy/db/agent'; +import {BUILT_IN_AGENT_PROVIDER} from '@proxy/integrations'; + +/** + * The organization's built-in agent: an Agent like any other, with grants and an activity log of + * its own, so the person sets what it may do where they set it for Claude. Made the first time it + * is needed, with a password nobody knows, since it never signs in: its loop runs in the api. + */ +export function requireBuiltInAgent(orgId: string): Promise> { + return Do(async ($) => { + const existing = $(await getSignedInAgentByProvider(orgId, BUILT_IN_AGENT_PROVIDER.id)); + if (existing) { + return existing; + } + const created = await createAgent({ + orgId, + providerId: BUILT_IN_AGENT_PROVIDER.id, + name: BUILT_IN_AGENT_PROVIDER.name, + username: `proxy-${randomBytes(6).toString('hex')}`, + passwordHash: await Bun.password.hash(randomBytes(32).toString('base64url')), + }); + // Two first chats at once: the other one made it. + if (created.isErr() && !isProviderConflict(created.error)) { + return $(created); + } + const made = $(await getSignedInAgentByProvider(orgId, BUILT_IN_AGENT_PROVIDER.id)); + return $(requirePresent(made, ApiErr.internalError(new Error('The built-in agent was made and is not there')))); + }); +} diff --git a/packages/api/src/harness/inference.test.ts b/packages/api/src/harness/inference.test.ts new file mode 100644 index 00000000..cd9a1a13 --- /dev/null +++ b/packages/api/src/harness/inference.test.ts @@ -0,0 +1,126 @@ +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 finishIdleInference = mock(async () => Ok(true)); +const finishJob = mock(async () => Ok(true)); +const completeInference = mock(async () => Ok(true)); +const requeueJob = mock(async () => Ok(true)); +mock.module('@proxy/db/harness', () => ({getJobThread, listEntries, finishIdleInference, finishJob, completeInference, requeueJob})); + +const requireBuiltInAgent = mock(async () => Ok({agentId: 'agent-1', orgId: 'org-1', providerId: 'proxy', name: 'Personal Agent Proxy', experiments: []})); +mock.module('./built-in-agent', () => ({requireBuiltInAgent})); + +const ensureThreadTask = mock(async () => Ok('task-1')); +mock.module('./thread-task', () => ({ensureThreadTask})); + +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})); + +mock.module('./tool-surface', () => ({MODEL_TOOLS: [{name: 'read', description: 'Reads', parameters: {}}]})); + +const now = new Date('2026-10-21T10:00:00Z'); +const thread = {id: 'thread-1', orgId: 'org-1', agentId: 'agent-1', taskId: 'task-1', createdAt: now}; +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}; + +function entry(kind: 'person' | 'assistant' | 'tool_result', text: string, extra: Partial<{toolCalls: Array<{id: string; name: string; arguments: string}>; callId: string}> = {}) { + return {id: `e-${text}`, seq: 1, threadId: 'thread-1', kind, text, toolCalls: extra.toolCalls ?? null, callId: extra.callId ?? null, isError: false, inputTokens: null, outputTokens: 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 log, as the chat form, and writes its answer with a job per call', async () => { + const {runInference} = await import('./inference'); + listEntries.mockResolvedValue( + Ok([ + entry('person', 'Hi'), + 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, 'w1', now); + + expect(result.isOk()).toBe(true); + expect(ensureThreadTask).toHaveBeenCalledWith(thread, now); + 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('You are talking with the person directly'); + 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: 'Hi'}, + {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([{name: 'read', description: 'Reads', parameters: {}}]); + expect(completeInference).toHaveBeenCalledWith( + 'job-1', + 'w1', + 'thread-1', + {text: '', toolCalls: [{id: 'c2', name: 'sleep_until', arguments: '{"seconds":60}'}, {id: 'c3', name: 'read', arguments: '{}'}], inputTokens: 10, outputTokens: 2}, + {c2: new Date('2026-10-21T10:01:00Z')}, + ); + }); + + test('a log with a call still running is left alone, and a thread that is gone ends the job', async () => { + const {runInference} = await import('./inference'); + listEntries.mockResolvedValue(Ok([entry('person', 'Hi'), entry('assistant', '', {toolCalls: [{id: 'c1', name: 'read', arguments: '{}'}]})])); + await runInference(job, 'w1', now); + expect(finishIdleInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1'); + expect(askModel).not.toHaveBeenCalled(); + + getJobThread.mockResolvedValue(Ok(null)); + await runInference(job, 'w1', now); + expect(finishJob).toHaveBeenCalledWith('job-1', 'w1'); + }); + + 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', new Date('2026-10-21T10:00:05Z')); + 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('after thirty answers without the person, it stops and says so', 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')}), {}); + }); +}); diff --git a/packages/api/src/harness/inference.ts b/packages/api/src/harness/inference.ts new file mode 100644 index 00000000..966db755 --- /dev/null +++ b/packages/api/src/harness/inference.ts @@ -0,0 +1,134 @@ +import {Err, Result} from 'ts-results-es'; + +import {ApiErr, type ApiError, Do} from '@proxy/utils'; +import {readyForInference, SLEEP_TOOL, sleepUntil, type ToolCall} from '@proxy/utils/harness'; +import {completeInference, type EntryRow, finishIdleInference, finishJob, getJobThread, 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 {requireBuiltInAgent} from './built-in-agent'; +import {askModel, type ModelMessage} from './model'; +import {ensureThreadTask} from './thread-task'; +import {MODEL_TOOLS} from './tool-surface'; + +/** + * An inference job: the model, run once on a thread's log. 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. The log not ready, as when the person spoke while a call was still running, is left for + * the job the last result queues. A model that can't be reached is tried again a few times, then + * the person is told. + */ + +// How the built-in agent is to go about it, where an MCP client is told about tasks. +const FLOW = + 'You are talking with the person directly, on the Chat page of Personal Agent Proxy; what you write is shown to them as it is, so write plain text, short, and answer what they asked. 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 their params. read runs the tools that only read; run runs the ones that change something or reach other people. Every call is made for this conversation’s 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 conversation, so say in your message what you will do then.'; + +// How many answers the model may give in a row without the person saying anything, calls and +// wake-ups included, before it stops and waits for them. +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.`; + +// The log as the model reads it: a tool result as the answer to its call, in the chat form. +function toMessages(entries: EntryRow[]): ModelMessage[] { + return entries.map((entry): 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}; + } + 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}}))} : {}), + }; + }); +} + +// How many answers the model has given since the person last spoke. +function turnsAlone(entries: EntryRow[]): number { + const lastPerson = entries.findLastIndex((entry) => entry.kind === 'person'); + return entries.slice(lastPerson + 1).filter((entry) => entry.kind === 'assistant').length; +} + +// When each call's job is due: a sleep's when it ends, every other's now. +function dueTimes(calls: ToolCall[], now: Date): Record { + return Object.fromEntries( + calls.flatMap((call) => { + if (call.name !== SLEEP_TOOL) { + return []; + } + const parsed = Result.wrap((): unknown => JSON.parse(call.arguments)); + return [[call.id, (parsed.isOk() ? sleepUntil(parsed.value, now) : null) ?? now]]; + }), + ); +} + +// Answers the person with `text` alone, as the model would, when the model can't be run. +function answerWith(job: JobRow, workerId: string, text: string): Promise> { + return completeInference(job.id, workerId, job.threadId, {text, toolCalls: [], inputTokens: 0, outputTokens: 0}, {}); +} + +function systemPrompt(thread: ThreadRow, now: Date): Promise> { + return Do(async ($) => { + const signedIn = $(await requireBuiltInAgent(thread.orgId)); + const instructions = $(await agentInstructions(signedIn, FLOW)); + return `${instructions}\n\nThe time now is ${now.toISOString()}.`; + }); +} + +/** Runs the job: one turn of the model on its thread's log, written back; nothing when the log isn't ready. */ +export function runInference(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 entries = $(await listEntries(thread.id)); + if (!readyForInference(entries)) { + $(await finishIdleInference(job.id, workerId, thread.id)); + return; + } + if (turnsAlone(entries) >= MAX_TURNS_ALONE) { + $(await answerWith(job, workerId, TOO_LONG)); + return; + } + const key = env.OPENROUTER_KEY; + if (!key) { + $(await answerWith(job, workerId, NOT_SET_UP)); + return; + } + $(await ensureThreadTask(thread, now)); + const system = $(await systemPrompt(thread, now)); + const turn = await askModel({key, messages: [{role: 'system', content: system}, ...toMessages(entries)], tools: MODEL_TOOLS, maxTokens: MAX_TOKENS, timeoutMs: TIMEOUT_MS}); + if (turn.isErr() && turn.error.kind !== 'provider_unreachable') { + return $(Err(turn.error)); + } + if (turn.isErr()) { + log.warn('The built-in agent’s model could not be reached', {attempt: job.attempts, ...serializeError('cause' in turn.error ? turn.error.cause : turn.error)}); + if (job.attempts < MAX_MODEL_ATTEMPTS) { + $(await requeueJob(job.id, workerId, new Date(now.getTime() + RETRY_MS * job.attempts))); + return; + } + $(await answerWith(job, workerId, UNREACHABLE)); + return; + } + const written = $(await completeInference(job.id, workerId, thread.id, turn.value, dueTimes(turn.value.toolCalls, now))); + if (!written) { + return $(Err(ApiErr.conflict('The job was no longer this worker’s'))); + } + }); +} diff --git a/packages/api/src/harness/model.test.ts b/packages/api/src/harness/model.test.ts new file mode 100644 index 00000000..ceabef41 --- /dev/null +++ b/packages/api/src/harness/model.test.ts @@ -0,0 +1,65 @@ +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 answers with an error is unreachable', async () => { + status = 502; + const turn = await askModel(request); + + expect(turn.unwrapErr().kind).toBe('provider_unreachable'); + }); +}); diff --git a/packages/api/src/harness/model.ts b/packages/api/src/harness/model.ts new file mode 100644 index 00000000..187cddca --- /dev/null +++ b/packages/api/src/harness/model.ts @@ -0,0 +1,83 @@ +import {Err, type Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {ApiErr, type ApiError, Do, 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. + */ + +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(ApiErr.providerUnreachable(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..d3110037 --- /dev/null +++ b/packages/api/src/harness/route.test.ts @@ -0,0 +1,92 @@ +import {beforeEach, describe, expect, mock, test} from 'bun:test'; +import {Ok} from 'ts-results-es'; + +const listThreads = mock(); +const createThread = mock(); +const getThread = mock(); +const listEntries = mock(); +const listOpenJobs = mock(); +const appendPersonMessage = mock(); +mock.module('@proxy/db/harness', () => ({listThreads, createThread, getThread, listEntries, listOpenJobs, appendPersonMessage})); + +mock.module('../utils/user-org', () => ({requireUserOrgId: mock(async () => Ok('org-1'))})); + +const requireBuiltInAgent = mock(async () => Ok({agentId: 'agent-1', orgId: 'org-1', providerId: 'proxy', name: 'Personal Agent Proxy', experiments: ['harness']})); +mock.module('./built-in-agent', () => ({requireBuiltInAgent})); + +const createThreadTask = mock(async () => Ok('task-1')); +mock.module('./thread-task', () => ({createThreadTask})); + +const now = new Date('2026-10-21T10:00:00Z'); +const thread = {id: 'thread-1', orgId: 'org-1', agentId: 'agent-1', taskId: 'task-1', createdAt: now}; + +function request(params: Record = {}, body: unknown = undefined, experiments: string[] = ['harness']) { + return {user: {userId: 'user-1', email: 'a@example.com', experiments}, params, body} as never; +} + +beforeEach(() => { + mock.clearAllMocks(); + listThreads.mockResolvedValue(Ok([{...thread, title: 'Hi'}])); + createThread.mockResolvedValue(Ok(thread)); + getThread.mockResolvedValue(Ok(thread)); + listEntries.mockResolvedValue(Ok([{id: 'e1', seq: 1, threadId: 'thread-1', kind: 'person', text: 'Hi', toolCalls: null, callId: null, isError: false, inputTokens: null, outputTokens: null, createdAt: now}])); + listOpenJobs.mockResolvedValue(Ok([])); + appendPersonMessage.mockResolvedValue(Ok({id: 'e2', seq: 2, threadId: 'thread-1', kind: 'person', text: 'Hello', toolCalls: null, callId: null, isError: false, inputTokens: null, outputTokens: null, createdAt: now})); +}); + +describe('the chat routes', () => { + test('are refused without the experiment', async () => { + const {handleListThreadsRoute, handlePostMessageRoute} = await import('./route'); + + expect((await handleListThreadsRoute(request({}, undefined, []))).unwrapErr().kind).toBe('forbidden'); + expect((await handlePostMessageRoute(request({threadId: 'thread-1'}, {text: 'Hi'}, []))).unwrapErr().kind).toBe('forbidden'); + expect(appendPersonMessage).not.toHaveBeenCalled(); + }); + + test('list the threads with what the person first said as the title', async () => { + const {handleListThreadsRoute} = await import('./route'); + + expect((await handleListThreadsRoute(request())).unwrap()).toEqual({threads: [{id: 'thread-1', createdAt: '2026-10-21T10:00:00.000Z', taskId: 'task-1', title: 'Hi'}]}); + }); + + test('make a thread for the built-in agent, with a task for its calls', async () => { + const {handleCreateThreadRoute} = await import('./route'); + + const made = await handleCreateThreadRoute(request()); + + expect(requireBuiltInAgent).toHaveBeenCalledWith('org-1'); + expect(createThreadTask).toHaveBeenCalledWith('org-1', 'agent-1'); + expect(createThread).toHaveBeenCalledWith({orgId: 'org-1', agentId: 'agent-1', taskId: 'task-1'}); + expect(made.unwrap().thread).toEqual({id: 'thread-1', createdAt: '2026-10-21T10:00:00.000Z', taskId: 'task-1', title: null}); + }); + + test('give the log with whether the agent 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({threadId: 'thread-1'}))).unwrap(); + expect(idle.entries).toEqual([{id: 'e1', kind: 'person', text: 'Hi', toolCalls: null, callId: null, isError: false, at: '2026-10-21T10:00:00.000Z'}]); + expect(idle).toMatchObject({busy: false, sleepingUntil: null}); + + listOpenJobs.mockResolvedValue(Ok([{...base, runAt: new Date(Date.now() - 1000)}])); + expect((await handleGetThreadRoute(request({threadId: 'thread-1'}))).unwrap()).toMatchObject({busy: true, sleepingUntil: null}); + + listOpenJobs.mockResolvedValue(Ok([{...base, runAt: soon}])); + expect((await handleGetThreadRoute(request({threadId: 'thread-1'}))).unwrap()).toMatchObject({busy: false, sleepingUntil: soon.toISOString()}); + + getThread.mockResolvedValue(Ok(null)); + expect((await handleGetThreadRoute(request({threadId: 'nope'}))).unwrapErr()).toMatchObject({kind: 'not_found', resource: 'thread'}); + }); + + test('append what the person says, trimmed, and refuse nothing or too much', async () => { + const {handlePostMessageRoute} = await import('./route'); + + const posted = await handlePostMessageRoute(request({threadId: 'thread-1'}, {text: ' Hello \n'})); + expect(appendPersonMessage).toHaveBeenCalledWith('thread-1', 'Hello'); + expect(posted.unwrap().entry).toMatchObject({id: 'e2', kind: 'person', text: 'Hello'}); + + expect((await handlePostMessageRoute(request({threadId: 'thread-1'}, {text: ' '}))).unwrapErr().kind).toBe('parse_error'); + expect((await handlePostMessageRoute(request({threadId: 'thread-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..e52df3d4 --- /dev/null +++ b/packages/api/src/harness/route.ts @@ -0,0 +1,97 @@ +import {Err, type Result} from 'ts-results-es'; +import {z} from 'zod'; + +import {ApiErr, type ApiError, Do, hasExperiment, parseSchema, requirePresent} from '@proxy/utils'; +import type {ToolCall} from '@proxy/utils/harness'; +import {appendPersonMessage, createThread, type EntryRow, getThread, listEntries, listOpenJobs, listThreads, type ThreadRow} from '@proxy/db/harness'; + +import type {AuthenticatedRequest} from '../server/middleware/require-auth'; +import {requireUserOrgId} from '../utils/user-org'; +import {requireBuiltInAgent} from './built-in-agent'; +import {createThreadTask} from './thread-task'; + +/** + * The Chat page's routes: the person's threads with the built-in agent, one thread's log, 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; + +export type ThreadResponse = {id: string; createdAt: string; taskId: string; title: string | null}; + +export type EntryResponse = {id: string; kind: 'person' | 'assistant' | 'tool_result'; text: string; toolCalls: ToolCall[] | null; callId: string | null; isError: boolean; at: string}; + +/** The thread's log, and what the agent is up to: busy on it, asleep until a time, or waiting for the person. */ +export type ThreadLogResponse = {thread: ThreadResponse; entries: EntryResponse[]; busy: boolean; sleepingUntil: string | null}; + +function toThread(thread: ThreadRow & {title?: string | null}): ThreadResponse { + return {id: thread.id, createdAt: thread.createdAt.toISOString(), taskId: thread.taskId, title: thread.title ?? null}; +} + +function toEntry(entry: EntryRow): EntryResponse { + return {id: entry.id, kind: entry.kind, text: entry.text, toolCalls: entry.toolCalls, callId: entry.callId, isError: entry.isError, at: entry.createdAt.toISOString()}; +} + +function requireHarness(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + if (!hasExperiment(request.user, 'harness')) { + return $(Err(ApiErr.forbidden())); + } + return $(await requireUserOrgId(request)); + }); +} + +/** `GET /api/chat/threads`: the organization's threads, newest first. */ +export function handleListThreadsRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const orgId = $(await requireHarness(request)); + return {threads: $(await listThreads(orgId)).map(toThread)}; + }); +} + +/** `POST /api/chat/threads`: a new thread, with the built-in agent made where the organization has none yet. */ +export function handleCreateThreadRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const orgId = $(await requireHarness(request)); + const agent = $(await requireBuiltInAgent(orgId)); + const taskId = $(await createThreadTask(orgId, agent.agentId)); + return {thread: toThread($(await createThread({orgId, agentId: agent.agentId, taskId})))}; + }); +} + +/** `GET /api/chat/threads/:threadId`: the thread's log and whether the agent is busy on it. */ +export function handleGetThreadRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const orgId = $(await requireHarness(request)); + const threadId = request.params.threadId ?? ''; + const thread = $(requirePresent($(await getThread(orgId, threadId)), ApiErr.notFound('thread', threadId))); + const [entries, jobs] = await Promise.all([listEntries(thread.id), listOpenJobs(thread.id)]); + const open = $(jobs); + const now = Date.now(); + // Asleep when every open job is a tool job due later; the first to be due is when it wakes. + const later = open.filter((job) => job.runAt.getTime() > now); + const asleep = open.length > 0 && later.length === open.length; + const first = later.map((job) => job.runAt.getTime()).sort((a, b) => a - b)[0]; + return { + thread: toThread(thread), + entries: $(entries).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/chat/threads/:threadId/messages` with `{text}`: what the person says; the agent is woken. */ +export function handlePostMessageRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const orgId = $(await requireHarness(request)); + const {text} = $(parseSchema(messageBodySchema, request.body)); + const threadId = request.params.threadId ?? ''; + $(requirePresent($(await getThread(orgId, threadId)), ApiErr.notFound('thread', threadId))); + const entry = $(requirePresent($(await appendPersonMessage(threadId, text)), ApiErr.notFound('thread', threadId))); + return {entry: toEntry(entry)}; + }); +} diff --git a/packages/api/src/harness/thread-task.ts b/packages/api/src/harness/thread-task.ts new file mode 100644 index 00000000..d81414b6 --- /dev/null +++ b/packages/api/src/harness/thread-task.ts @@ -0,0 +1,43 @@ +import type {Result} from 'ts-results-es'; + +import {type ApiError, Do} from '@proxy/utils'; +import {taskEndedAt} from '@proxy/utils/tasks'; +import {createAgentTask, getAgentTask} from '@proxy/db/task'; +import {listEntries, setThreadTask, type ThreadRow} from '@proxy/db/harness'; + +/** + * The task a thread's calls are made for, as every agent's calls are, so the activity log says + * which conversation each request was for. A thread starts with one, and gets a new one when it + * was abandoned, a day after it started, since the gate refuses calls for one that was. + */ + +const TITLE = 'Chat'; +const MAX_GOAL = 2000; + +// What the task is for: what the person first said in the thread, as far as a goal holds. +function goalOf(entries: Array<{kind: string; text: string}>): string { + const first = entries.find((entry) => entry.kind === 'person')?.text.trim() ?? ''; + if (first === '') { + return 'A conversation on the Chat page'; + } + return first.length > MAX_GOAL ? `${first.slice(0, MAX_GOAL - 1)}…` : first; +} + +/** A thread's first task, before it has a log. */ +export function createThreadTask(orgId: string, agentId: string): Promise> { + return Do(async ($) => $(await createAgentTask({orgId, agentId, title: TITLE, goal: 'A conversation on the Chat page', via: 'harness'})).id); +} + +/** The thread'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 ($) => { + const task = $(await getAgentTask(thread.orgId, thread.agentId, thread.taskId)); + if (task && taskEndedAt({startedAt: task.createdAt, finishedAt: task.finishedAt}, now) === null) { + return task.id; + } + const entries = $(await listEntries(thread.id)); + const made = $(await createAgentTask({orgId: thread.orgId, agentId: thread.agentId, title: TITLE, goal: goalOf(entries), via: 'harness'})); + $(await setThreadTask(thread.id, made.id)); + return made.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..0403fd65 --- /dev/null +++ b/packages/api/src/harness/tool-job.test.ts @@ -0,0 +1,102 @@ +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)); +mock.module('@proxy/db/harness', () => ({getJobThread, getEntry, completeToolJob, failJob, finishJob})); + +const signedIn = {agentId: 'agent-1', orgId: 'org-1', providerId: 'proxy', name: 'Personal Agent Proxy', experiments: []}; +mock.module('./built-in-agent', () => ({requireBuiltInAgent: mock(async () => Ok(signedIn))})); + +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'); +const thread = {id: 'thread-1', orgId: 'org-1', agentId: 'agent-1', taskId: 'task-1', createdAt: now}; + +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, 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 thread’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 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..be6fd131 --- /dev/null +++ b/packages/api/src/harness/tool-job.ts @@ -0,0 +1,94 @@ +import {Result} from 'ts-results-es'; + +import {type ApiError, Do} from '@proxy/utils'; +import {SLEEP_TOOL, sleepUntil} from '@proxy/utils/harness'; +import {completeToolJob, failJob, finishJob, getEntry, getJobThread, type JobRow, type ThreadRow} from '@proxy/db/harness'; + +import {callMcpTool} from '../mcp/tools'; +import {requireBuiltInAgent} from './built-in-agent'; +import {ensureThreadTask} from './thread-task'; +import {isGateTool} from './tool-surface'; + +/** + * A tool job: one call the model made, run and answered in the log. The MCP server's tools go + * through the gate as the built-in agent, for the thread's 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}; +} + +function parseArgs(json: string): Record | null { + const parsed = Result.wrap((): unknown => JSON.parse(json)); + if (parsed.isErr() || typeof parsed.value !== 'object' || parsed.value === null || Array.isArray(parsed.value)) { + return null; + } + return parsed.value as Record; +} + +function cut(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, for the thread's task. +function runGateTool(thread: ThreadRow, name: string, args: Record, now: Date): Promise> { + return Do(async ($) => { + const signedIn = $(await requireBuiltInAgent(thread.orgId)); + const taskId = $(await ensureThreadTask(thread, now)); + const result = await callMcpTool(signedIn, name, {...args, task: taskId}, false, 'harness'); + return {text: cut(result.content.map((part) => part.text).join('\n')), isError: result.isError === true}; + }); +} + +function answer(thread: ThreadRow, job: JobRow, call: {name: string; arguments: string}, now: Date): Promise> { + return Do(async ($) => { + const args = parseArgs(call.arguments); + if (args === null) { + return error('The arguments were not a JSON object.'); + } + if (call.name === SLEEP_TOOL) { + 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)) { + 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. */ +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 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..4171f0aa --- /dev/null +++ b/packages/api/src/harness/tool-surface.test.ts @@ -0,0 +1,41 @@ +import {describe, expect, test} from 'bun:test'; + +import {MCP_TOOLS} from '../mcp/tools'; +import {isGateTool, MODEL_TOOLS} from './tool-surface'; + +type Params = {properties: Record; required: string[]}; + +function tool(name: string) { + const found = MODEL_TOOLS.find((candidate) => candidate.name === name); + if (!found) { + throw new Error(`No model tool ${name}`); + } + return {...found, parameters: found.parameters as Params}; +} + +describe('the built-in agent’s tools', () => { + test('are the MCP server’s without the two about tasks, and a sleep', () => { + expect(MODEL_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); + }); +}); diff --git a/packages/api/src/harness/tool-surface.ts b/packages/api/src/harness/tool-surface.ts new file mode 100644 index 00000000..c55c4a11 --- /dev/null +++ b/packages/api/src/harness/tool-surface.ts @@ -0,0 +1,56 @@ +import {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 MCP server's, as any agent gets them, less the two about + * tasks, since every call in a thread is for the thread's task and the loop fills the task in + * (inference.ts, tool-job.ts); and one of its own, to be woken later. Nothing else: everything it + * does to the person's accounts goes through the same gate as Claude's calls. + */ + +// The MCP tools the built-in agent doesn't need: its task is the thread's. +const TASK_TOOLS = new Set(['start_task', 'finish_task']); + +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 person asked for at a time they 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 telling the person a service isn’t connected: their 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 model is given, in the order the MCP server lists its. */ +export const MODEL_TOOLS: ModelTool[] = [...MCP_TOOLS.filter((tool) => !TASK_TOOLS.has(tool.name)).map(withoutTask), SLEEP]; + +/** 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..4975e918 --- /dev/null +++ b/packages/api/src/harness/worker.test.ts @@ -0,0 +1,67 @@ +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'))); + runToolJob.mockRejectedValueOnce(new Error('boom')); + claimJob + .mockResolvedValueOnce(Ok(inference)) + .mockResolvedValueOnce(Ok(tool)) + .mockResolvedValueOnce(Ok({...inference, id: 'j3', attempts: 5})) + .mockResolvedValue(Ok(null)); + + const worker = startHarnessWorker(); + await settle(); + await worker.stop(); + + expect(failJob).toHaveBeenCalledWith('j1', expect.any(String), expect.stringContaining('no longer mine')); + expect(failJob).toHaveBeenCalledWith('j2', expect.any(String), 'boom'); + expect(failJob).toHaveBeenCalledWith('j3', expect.any(String), 'Given up after 4 attempts'); + expect(runInference).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/harness/worker.ts b/packages/api/src/harness/worker.ts new file mode 100644 index 00000000..e458caf1 --- /dev/null +++ b/packages/api/src/harness/worker.ts @@ -0,0 +1,95 @@ +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 {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 agent's jobs, one at a time, in each api instance: it claims + * the next job due, keeps its lease while the job runs, and claims the next as soon as it is done, + * so a turn's calls 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 job it is on, up to a point. + */ + +const POLL_MS = 500; +const LEASE_MS = 120_000; +const RENEW_MS = 30_000; +// After this many claims, a job that keeps failing is given up. +const MAX_ATTEMPTS = 4; +// How long stopping waits for the job 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); +} + +// 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 failure = result.isErr() ? result.error : result.value.isErr() ? result.value.error : null; + if (failure === null) { + return; + } + log.error('Built-in agent job failed', {jobId: job.id, kind: job.kind, attempt: job.attempts, ...serializeError(failure)}); + const message = failure instanceof Error ? failure.message : JSON.stringify(failure); + 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; + let current: Promise = Promise.resolve(); + + const loop = async (): Promise => { + while (!stopping) { + 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; + } + current = runClaimed(claimed.value, workerId); + await current; + } + }; + void loop(); + log.info('Built-in agent worker started', {workerId}); + + return { + stop: async () => { + stopping = true; + await Promise.race([current, 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/tools.ts b/packages/api/src/mcp/tools.ts index 9ad767fb..70132045 100644 --- a/packages/api/src/mcp/tools.ts +++ b/packages/api/src/mcp/tools.ts @@ -169,8 +169,21 @@ function vaultInstructions(connections: AgentConnection[]): string[] { ]; } +// 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 their params. 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, and start a new task for the next thing the person asks for.'; + /** 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)); @@ -181,7 +194,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}/connections/new rather than a separate connector, so it works with every agent they use, with the access they choose.`, @@ -238,10 +251,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)); } @@ -274,9 +290,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 b4310006..3c59c017 100644 --- a/packages/api/src/server/create-api-router.ts +++ b/packages/api/src/server/create-api-router.ts @@ -36,6 +36,7 @@ import {handleConnectMysqlRoute, handleTestMysqlRoute} from '../connections/mysq import {handleConnectPlaidRoute, handleCreatePlaidLinkTokenRoute} from '../connections/plaid/route'; import {handleConnectPostgresRoute, handleTestPostgresRoute} from '../connections/postgres/route'; import {handleDeleteConnectionRoute, handleGetConnectionRoute, handleListConnectionsRoute, handleRenameConnectionRoute, handleSetConnectionDefaultsRoute} from '../connections/route'; +import {handleCreateThreadRoute, handleGetThreadRoute, handleListThreadsRoute, handlePostMessageRoute} from '../harness/route'; import {handleFillIntentRoute, handlePickIntentRoute} from '../intent/route'; import {handleMcpMethodNotAllowedRoute, handleMcpRoute} from '../mcp/route'; import {handleDeleteMeRoute, handleFinishOnboardingRoute, handleMeRoute} from '../me/route'; @@ -158,6 +159,14 @@ export function createApiRouter(): express.Router { router.get('/api/widgets/accounts', withAuthResult('Widget bank accounts route', handleBankAccountsRoute)); router.get('/api/widgets/transactions', withAuthResult('Widget bank transactions route', handleBankTransactionsRoute)); + // The Chat page: the person's threads with the built-in agent, whose loop runs in the api + // (harness/worker.ts); a message queues its work. + router.use('/api/chat', requireBrowserOrigin); + router.get('/api/chat/threads', withAuthResult('List chat threads route', handleListThreadsRoute)); + router.post('/api/chat/threads', withAuthResult('Create chat thread route', handleCreateThreadRoute)); + router.get('/api/chat/threads/:threadId', withAuthResult('Get chat thread route', handleGetThreadRoute)); + router.post('/api/chat/threads/:threadId/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 a7dc8d64..5627c7fc 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", "./instance-meta": "./src/instance-meta.ts", "./oauth": "./src/oauth.ts", diff --git a/packages/db/prisma/migrations/v024_add_built_in_agent_threads/migration.sql b/packages/db/prisma/migrations/v024_add_built_in_agent_threads/migration.sql new file mode 100644 index 00000000..ab934417 --- /dev/null +++ b/packages/db/prisma/migrations/v024_add_built_in_agent_threads/migration.sql @@ -0,0 +1,83 @@ +-- The built-in agent: a thread per conversation with its log of entries (what the person said, +-- what the model answered with its tool calls, and what each call answered), and the jobs that +-- run it (the model on a log, one tool call), claimed by one api instance at a time with a lease. +-- Its 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'); + +-- CreateEnum +CREATE TYPE "AgentJobKind" AS ENUM ('inference', 'tool'); + +-- CreateEnum +CREATE TYPE "AgentJobStatus" AS ENUM ('queued', 'running', 'done', 'failed'); + +-- AlterEnum +ALTER TYPE "AuditVia" ADD VALUE 'harness'; + +-- CreateTable +CREATE TABLE "AgentThread" ( + "id" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "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, + "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 "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/schema.prisma b/packages/db/prisma/schema.prisma index 28f3cb6b..e6ce7bab 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -108,6 +108,7 @@ model Organization { audit AuditEntry[] feedback AgentFeedback[] tasks AgentTask[] + threads AgentThread[] } model OrgMember { @@ -280,10 +281,12 @@ enum AuditOutcome { denied } -// 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 or denied, or one thing Personal Agent Proxy did itself for a @@ -428,3 +431,102 @@ model OAuthGrant { @@index([clientId]) @@index([agentId]) } + +// The built-in agent's conversation with a person: one thread, holding its log of entries. Every +// call made in it is for the thread's task, as any agent's calls are; a task abandoned a day on is +// replaced by a new one. Everything that happens to the agent is an entry, and everything that +// makes it do something is a job (AgentJob). +model AgentThread { + id String @id @default(cuid()) + orgId String + // The built-in agent of the organization, an Agent like any other, with grants of its own. + agentId String + taskId String + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + entries AgentEntry[] + jobs AgentJob[] + + @@index([orgId, createdAt]) +} + +// What an entry of a thread's log is: what the person said, what the model answered, with the tool +// calls it made, or what one of those calls answered. +enum AgentEntryKind { + person + assistant + tool_result +} + +// 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. +model AgentEntry { + id String @id @default(cuid()) + seq Int @unique @default(autoincrement()) + threadId String + kind AgentEntryKind + // 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 entry's call, by the id the model gave it; null otherwise. + callId String? + // A tool_result that is an error, in words the model can act on. + isError Boolean @default(false) + // An assistant entry's cost. + inputTokens Int? + outputTokens Int? + + 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]) +} diff --git a/packages/db/src/agent.ts b/packages/db/src/agent.ts index a373f034..99c4a088 100644 --- a/packages/db/src/agent.ts +++ b/packages/db/src/agent.ts @@ -164,6 +164,21 @@ export async function createAgentSession(data: { export type {SignedInAgent} from './signed-in-agent'; +/** + * The organization's agent of one provider, as a signed-in agent would be read, for the built-in + * agent, which signs in nowhere; `Ok(null)` for none, or one revoked. + */ +export async function getSignedInAgentByProvider( + orgId: string, + providerId: string, +): Promise> { + return ( + await wrapDb(() => + db.agent.findFirst({where: {orgId, providerId}, select: signedInAgentSelect}), + ) + ).map((agent) => (agent ? toSignedInAgent(agent) : null)); +} + /** `Ok(null)` means no live session: unknown, expired, or the agent has been revoked. */ export async function getSignedInAgent( tokenHash: string, diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts new file mode 100644 index 00000000..1c466ea8 --- /dev/null +++ b/packages/db/src/harness.ts @@ -0,0 +1,427 @@ +import type {AgentEntryKind, AgentJobKind, AgentJobStatus, Prisma} from '@prisma/client'; +import {Err, Ok, type Result} from 'ts-results-es'; + +import {ApiErr, type ApiError, wrapDb} from '@proxy/utils'; +import {type LogEntry, pendingToolCalls, type ToolCall} from '@proxy/utils/harness'; + +import {db} from '.'; + +/** + * The built-in agent's threads, their logs and their jobs. Every change to a thread's log or its + * jobs is made with the thread's row locked, so a result landing and the model being run on the + * log never race: either the inference job sees the result, or the result sees no job and queues + * one. A worker's write is fenced by its lease: it goes through only while the job is still its. + */ + +export type ThreadRow = { + id: string; + orgId: string; + agentId: string; + taskId: string; + createdAt: Date; +}; + +export type EntryRow = LogEntry & { + id: string; + seq: number; + threadId: string; + isError: boolean; + inputTokens: number | null; + outputTokens: number | 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; +}; + +const threadSelect = {id: true, orgId: true, agentId: true, taskId: true, createdAt: true} as const; + +const entrySelect = { + id: true, + seq: true, + threadId: true, + kind: true, + text: true, + toolCalls: true, + callId: true, + isError: true, + inputTokens: true, + outputTokens: 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; + +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[]), + }; +} + +/** A new thread for the person's organization, its calls made for `taskId`. */ +export async function createThread(data: { + orgId: string; + agentId: string; + taskId: string; +}): Promise> { + return wrapDb(() => db.agentThread.create({data, select: threadSelect})); +} + +/** The organization's threads, newest first, each with what the person first said, as its title. */ +export async function listThreads( + orgId: string, +): Promise, ApiError>> { + return ( + await wrapDb(() => + db.agentThread.findMany({ + where: {orgId}, + select: { + ...threadSelect, + entries: {where: {kind: 'person'}, orderBy: {seq: 'asc'}, take: 1, select: {text: true}}, + }, + orderBy: {createdAt: 'desc'}, + }), + ) + ).map((threads) => + threads.map(({entries, ...thread}) => ({...thread, title: entries[0]?.text ?? null})), + ); +} + +/** `Ok(null)` means no such thread in the organization. */ +export async function getThread( + orgId: string, + threadId: string, +): Promise> { + return wrapDb(() => + db.agentThread.findFirst({where: {id: threadId, orgId}, select: threadSelect}), + ); +} + +/** 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. */ +export async function listEntries(threadId: string): Promise> { + return ( + await wrapDb(() => + db.agentEntry.findMany({where: {threadId}, select: entrySelect, orderBy: {seq: 'asc'}}), + ) + ).map((entries) => entries.map(toEntry)); +} + +/** Whether the agent is busy on the thread: a job of it queued or running, a sleep among them. */ +export async function isThreadBusy(threadId: string): Promise> { + return ( + await wrapDb(() => db.agentJob.count({where: {threadId, status: {in: ['queued', 'running']}}})) + ).map((count) => count > 0); +} + +/** The jobs still to run or running on the thread: what the agent is up to, for the person. */ +export async function listOpenJobs(threadId: string): Promise> { + return wrapDb(() => + db.agentJob.findMany({ + where: {threadId, status: {in: ['queued', 'running']}}, + select: jobSelect, + orderBy: {runAt: 'asc'}, + }), + ); +} + +/** A new task for the thread'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; false 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`; + return rows.length > 0; +} + +// Queues an inference job for the thread unless one is queued or running already. With the +// thread locked. +async function queueInference(tx: Tx, threadId: string): Promise { + const active = await tx.agentJob.count({ + where: {threadId, kind: 'inference', status: {in: ['queued', 'running']}}, + }); + if (active > 0) { + return; + } + await tx.agentJob.create({data: {threadId, kind: 'inference'}}); +} + +/** Appends 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) => { + if (!(await lockThread(tx, threadId))) { + return null; + } + const entry = await tx.agentEntry.create({ + data: {threadId, kind: 'person' satisfies AgentEntryKind, text}, + select: entrySelect, + }); + await queueInference(tx, threadId); + return toEntry(entry); + }), + ); +} + +/** + * 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> { + return ( + await wrapDb( + () => + db.$queryRaw>` + UPDATE "AgentJob" SET + "status" = 'running', + "claimedBy" = ${workerId}, + "leaseExpiresAt" = now() + make_interval(secs => ${leaseMs / 1000}), + "attempts" = "attempts" + 1, + "updatedAt" = now() + WHERE "id" = ( + SELECT "id" FROM "AgentJob" + WHERE ("status" = 'queued' AND "runAt" <= now()) + OR ("status" = 'running' AND "leaseExpiresAt" < now()) + ORDER BY "runAt" ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING "id", "threadId", "kind", "status", "entryId", "callId", "runAt", "claimedBy", "attempts"`, + ) + ).map((rows) => rows[0] ?? null); +} + +/** 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() + make_interval(secs => ${leaseMs / 1000}), "updatedAt" = now() + WHERE "id" = ${jobId} AND "claimedBy" = ${workerId} AND "status" = 'running'`, + ) + ).map((count) => count > 0); +} + +// Marks the worker's running job as `status`; 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: AgentJobStatus, + 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}, + }); + return count > 0; +} + +// Gives the job back to the queue, to run at `runAt`; false when it is no longer the worker's. +async function requeue(tx: Tx, jobId: string, workerId: string, runAt: Date): Promise { + const {count} = await tx.agentJob.updateMany({ + where: {id: jobId, claimedBy: workerId, status: 'running'}, + data: {status: 'queued', runAt, claimedBy: null, leaseExpiresAt: null}, + }); + 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; `Ok(false)` when it was no longer the worker's. */ +export async function failJob( + jobId: string, + workerId: string, + error: string, +): Promise> { + return wrapDb(() => db.$transaction((tx) => fence(tx, jobId, workerId, 'failed', error))); +} + +/** Puts the worker's job back in the queue for later; `Ok(false)` when it was no longer the worker's. */ +export async function requeueJob( + jobId: string, + workerId: string, + runAt: Date, +): Promise> { + return wrapDb(() => db.$transaction((tx) => requeue(tx, jobId, workerId, runAt))); +} + +/** What the model answered: its text, its tool calls, and what they cost. */ +export type AssistantTurn = { + text: string; + toolCalls: ToolCall[]; + inputTokens: number; + outputTokens: number; +}; + +/** + * Finishes an inference job with the model's answer: the assistant entry, and a tool job for each + * of its calls, due at `runAt` where a call says when (a sleep), else now. `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, + runAt: Record, +): Promise> { + return wrapDb(() => + db.$transaction(async (tx) => { + await lockThread(tx, threadId); + if (!(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, + }, + select: {id: true}, + }); + if (turn.toolCalls.length > 0) { + await tx.agentJob.createMany({ + data: turn.toolCalls.map((call) => ({ + threadId, + kind: 'tool' satisfies AgentJobKind, + entryId: entry.id, + callId: call.id, + runAt: runAt[call.id] ?? new Date(), + })), + }); + } + return true; + }), + ); +} + +/** + * Finishes a tool job with what the call answered, and wakes the agent once every call of the + * 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) => { + await lockThread(tx, threadId); + if (!(await fence(tx, jobId, workerId, 'done'))) { + return false; + } + await tx.agentEntry.create({ + data: { + threadId, + kind: 'tool_result' satisfies AgentEntryKind, + text: result.text, + callId: result.callId, + isError: result.isError, + }, + }); + const entries = ( + await tx.agentEntry.findMany({ + where: {threadId}, + select: entrySelect, + orderBy: {seq: 'asc'}, + }) + ).map(toEntry); + if (pendingToolCalls(entries).length === 0) { + await queueInference(tx, threadId); + } + return true; + }), + ); +} + +/** + * Finishes an inference job that found nothing to do, with the thread locked, so a result landing + * meanwhile queues the next one. `Ok(false)` when the job was no longer the worker's. + */ +export async function finishIdleInference( + jobId: string, + workerId: string, + threadId: string, +): Promise> { + return wrapDb(() => + db.$transaction(async (tx) => { + await lockThread(tx, threadId); + return fence(tx, jobId, workerId, 'done'); + }), + ); +} + +/** 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/docs/content/agents/chat.mdx b/packages/docs/content/agents/chat.mdx new file mode 100644 index 00000000..c9fe8909 --- /dev/null +++ b/packages/docs/content/agents/chat.mdx @@ -0,0 +1,40 @@ +--- +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 a conversation with an agent Personal Agent Proxy runs itself. It is an +agent login like any other, named Personal Agent Proxy on the Agents page: what it may do with each +connection is set there, as for Claude or ChatGPT, and every call it makes is in the activity log, +by the conversation it was made for. It can use what you connected, and the vault as its memory. + +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, or makes calls first: each shows as a card under its +message, with what it asked and, once it has it, what came back. A call it is not allowed is +refused and logged as blocked, as it would be for any agent. It keeps working after you leave the +page, and the page shows the conversation as it is when you come back. + +It 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 the conversation and nothing else, so it says +what it will do before it sleeps. + +**Activity for this conversation** under the input opens the activity log narrowed to the +conversation's task. + + +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/src/nav.ts b/packages/docs/src/nav.ts index 568cb7fb..7adf1ccd 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: 'Agents', 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 49706ff6..f5c53609 100644 --- a/packages/integrations/src/agent-providers.test.ts +++ b/packages/integrations/src/agent-providers.test.ts @@ -1,6 +1,11 @@ import {describe, expect, test} from 'bun:test'; -import {AGENT_PROVIDERS, findAgentProvider, suggestAgentProvider} from './agent-providers'; +import { + AGENT_PROVIDERS, + BUILT_IN_AGENT_PROVIDER, + findAgentProvider, + suggestAgentProvider, +} from './agent-providers'; describe('agent providers', () => { test('have unique ids', () => { @@ -30,11 +35,21 @@ describe('agent providers', () => { } }); - test('finds a provider by id', () => { + test('finds a provider by id, the built-in agent too, which is not on offer', () => { expect(findAgentProvider('dot')?.name).toBe('Dots'); + expect(findAgentProvider('proxy')).toBe(BUILT_IN_AGENT_PROVIDER); + expect(AGENT_PROVIDERS).not.toContain(BUILT_IN_AGENT_PROVIDER); 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 Code')?.id).toBe('claude-code'); diff --git a/packages/integrations/src/agent-providers.ts b/packages/integrations/src/agent-providers.ts index 82b685aa..ace2d571 100644 --- a/packages/integrations/src/agent-providers.ts +++ b/packages/integrations/src/agent-providers.ts @@ -9,7 +9,8 @@ export type AgentProviderId = | 'claude-desktop' | 'hermes' | 'chatgpt' - | 'custom'; + | 'custom' + | 'proxy'; export type AgentProvider = { id: AgentProviderId; @@ -18,9 +19,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. @@ -40,6 +42,20 @@ export type AgentProvider = { faviconUrl?: string; }; +/** + * The agent Personal Agent Proxy runs itself, on the Chat page: an agent login like any other for + * its access and its activity, made by Personal Agent Proxy the first time the person chats, never + * handed out, so it is not among the providers 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 agents an organization can make a login for: one each, and any number of custom ones. export const AGENT_PROVIDERS: AgentProvider[] = [ { @@ -135,7 +151,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 0d222458..dc029408 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,5 +1,6 @@ export { AGENT_PROVIDERS, + BUILT_IN_AGENT_PROVIDER, CUSTOM_AGENT_NAME_MAX, findAgentProvider, suggestAgentProvider, diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index ea570e82..5df76e29 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -116,7 +116,7 @@ --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); 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..d3e057ec --- /dev/null +++ b/packages/utils/src/harness.test.ts @@ -0,0 +1,68 @@ +import {describe, expect, test} from 'bun:test'; + +import {type LogEntry, pendingToolCalls, readyForInference, 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, +}); + +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); + }); +}); + +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(); + }); +}); diff --git a/packages/utils/src/harness.ts b/packages/utils/src/harness.ts new file mode 100644 index 00000000..cb8be80b --- /dev/null +++ b/packages/utils/src/harness.ts @@ -0,0 +1,74 @@ +/** + * 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, and what each call answered. A call is pending until its result is in the log, and the + * model is run again only once none is. + */ + +/** 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}; + +export type LogEntry = { + kind: 'person' | 'assistant' | 'tool_result'; + text: string; + toolCalls: ToolCall[] | null; + 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 built-in agent has beyond its connections: a wake-up later. */ +export const SLEEP_TOOL = 'sleep_until'; + +// 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)); +} diff --git a/packages/web/src/client/harness-client.ts b/packages/web/src/client/harness-client.ts new file mode 100644 index 00000000..72547697 --- /dev/null +++ b/packages/web/src/client/harness-client.ts @@ -0,0 +1,64 @@ +import {z} from 'zod'; +import {apiRequest} from '@/client/request'; + +// The Chat page's api: the person's threads with the built-in agent, a thread's log, and what +// they say in it. + +const threadSchema = z.object({ + id: z.string(), + createdAt: z.string(), + // The task every call in the thread is made for, which the activity log can be narrowed to. + taskId: z.string(), + // What the person first said, as the thread's name; null before they said anything. + title: z.string().nullable(), +}); + +const toolCallSchema = z.object({id: z.string(), name: z.string(), arguments: z.string()}); + +const entrySchema = z.object({ + id: z.string(), + kind: z.enum(['person', 'assistant', 'tool_result']), + text: z.string(), + // An assistant entry's calls; null otherwise. + toolCalls: z.array(toolCallSchema).nullable(), + // A tool result's call; null otherwise. + callId: z.string().nullable(), + isError: z.boolean(), + at: z.string(), +}); + +const threadLogSchema = z.object({ + thread: threadSchema, + entries: z.array(entrySchema), + // Whether the agent is at work on the thread, or asleep until a time. + busy: z.boolean(), + sleepingUntil: z.string().nullable(), +}); + +export type ChatThread = z.infer; +export type ChatEntry = z.infer; +export type ChatToolCall = z.infer; +export type ThreadLog = z.infer; + +export function listThreads() { + return apiRequest('GET', '/api/chat/threads', z.object({threads: z.array(threadSchema)})); +} + +export function createThread() { + return apiRequest('POST', '/api/chat/threads', z.object({thread: threadSchema})); +} + +export function getThread(threadId: string) { + return apiRequest('GET', `/api/chat/threads/${threadId}`, threadLogSchema); +} + +export function postMessage(threadId: string, text: string) { + return apiRequest( + 'POST', + `/api/chat/threads/${threadId}/messages`, + z.object({entry: entrySchema}), + { + text, + }, + ); +} diff --git a/packages/web/src/client/schemas.ts b/packages/web/src/client/schemas.ts index 92777644..d6842ba3 100644 --- a/packages/web/src/client/schemas.ts +++ b/packages/web/src/client/schemas.ts @@ -53,8 +53,8 @@ export const auditEntrySchema = z.object({ recordTitle: z.string().nullable(), query: z.string().nullable(), outcome: z.enum(['allowed', 'denied']), - // 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(), @@ -66,7 +66,7 @@ export const activityTaskSchema = z.object({ agentId: z.string(), 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 ( <> -
+ {panel !== undefined && desktop && ( + + )} +
{title !== undefined && (
{!desktop && } + {!desktop && panel !== undefined && ( + + )} {title}
{actions &&
{actions}
} @@ -34,7 +90,14 @@ export function AppShell({children, title, actions}: Props) {
)} -
+
{children}
diff --git a/packages/web/src/components/audit-entry-dialog.tsx b/packages/web/src/components/audit-entry-dialog.tsx index 564a6f3c..80a5459f 100644 --- a/packages/web/src/components/audit-entry-dialog.tsx +++ b/packages/web/src/components/audit-entry-dialog.tsx @@ -36,6 +36,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 (
@@ -174,7 +177,7 @@ function AuditEntryContent({ {action ? action.label : Unknown} - {entry.via === 'mcp' ? 'MCP' : 'Agent web pages'} + {THROUGH[entry.via]} ); diff --git a/packages/web/src/components/nav.tsx b/packages/web/src/components/nav.tsx index 3d916e0d..408b899c 100644 --- a/packages/web/src/components/nav.tsx +++ b/packages/web/src/components/nav.tsx @@ -3,15 +3,18 @@ import { HomeIcon, LibraryIcon, LogOutIcon, + MessageSquareIcon, PlugIcon, ScrollTextIcon, SettingsIcon, type LucideIcon, } from 'lucide-react'; +import type {Experiment} from '@proxy/utils'; import {Link, useLocation, useRouteLoaderData} from 'react-router'; import {signOut} from '@/client/auth-client'; import type {Me} from '@/client/me-client'; import {ThemeMenu} from '@/components/theme-menu'; +import {useExperiments} from '@/hooks/use-experiment'; import { Sidebar, SidebarContent, @@ -23,10 +26,12 @@ import { useSidebar, } from '@proxy/ui/components/sidebar'; -type Destination = {to: string; label: string; icon: LucideIcon}; +// `experiment` is the experiment a destination is behind, shown only with it on. +type Destination = {to: string; label: string; icon: LucideIcon; experiment?: Experiment}; const DESTINATIONS: Destination[] = [ {to: '/', label: 'Home', icon: HomeIcon}, + {to: '/chat', label: 'Chat', icon: MessageSquareIcon, experiment: 'harness'}, {to: '/connections', label: 'Connections', icon: PlugIcon}, {to: '/vault', label: 'Vault', icon: LibraryIcon}, {to: '/agents', label: 'Agents', icon: BotIcon}, @@ -44,14 +49,19 @@ function isActive(pathname: string, to: string): boolean { export function AppSidebar() { const {pathname} = useLocation(); const me = useRouteLoaderData('human'); + const experiments = useExperiments(); const {setOpenMobile} = useSidebar(); + const destinations = DESTINATIONS.filter( + (destination) => + destination.experiment === undefined || experiments.includes(destination.experiment), + ); return ( - {DESTINATIONS.map((destination) => ( + {destinations.map((destination) => ( (change: Promise>) => { const result = await change; if (result.isErr()) { @@ -95,7 +98,7 @@ function AgentDetail({id}: {id: string}) { } actions={ <> - {!revoked && ( + {!revoked && !builtIn && ( diff --git a/packages/web/src/pages/chat-page.tsx b/packages/web/src/pages/chat-page.tsx new file mode 100644 index 00000000..f993989c --- /dev/null +++ b/packages/web/src/pages/chat-page.tsx @@ -0,0 +1,347 @@ +import {ChevronRightIcon, MoonIcon, PlusIcon, SearchIcon, SendIcon} from 'lucide-react'; +import {type KeyboardEvent, useEffect, useRef, useState} from 'react'; +import {Link, useLoaderData, useNavigate, useRevalidator} from 'react-router'; +import { + type ChatEntry, + type ChatThread, + type ChatToolCall, + createThread, + getThread, + postMessage, + type ThreadLog, +} from '@/client/harness-client'; +import {AppShell, PageTitle} from '@/components/app-shell'; +import {Button} from '@proxy/ui/components/button'; +import {Input} from '@proxy/ui/components/input'; +import {Textarea} from '@proxy/ui/components/textarea'; +import {cn} from '@proxy/ui/lib/utils'; +import {formatDateTime} from '@/lib/format'; +import {describeFetchError} from '@/lib/loader-utils'; +import type {chatLoader} from '@/loaders'; + +// How often the log is read again while the agent is at work, and while it is not: a reply, or a +// wake-up, shows within a moment either way. +const BUSY_POLL_MS = 1000; +const IDLE_POLL_MS = 4000; + +// How much of a call's arguments the card shows on its line. +const ARGS_PREVIEW = 80; + +function preview(json: string): string { + const text = json.replace(/\s+/g, ' '); + return text.length > ARGS_PREVIEW ? `${text.slice(0, ARGS_PREVIEW - 1)}…` : text; +} + +// One call the agent made, with what it answered once it has: opened for the whole of both. +function ToolCallCard({call, result}: {call: ChatToolCall; result: ChatEntry | undefined}) { + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
+
{call.arguments}
+ {result && ( +
+							{result.text}
+						
+ )} +
+ )} +
+ ); +} + +// The log as a conversation: what the person said on the right, what the agent said on the left +// with its calls under it, each call's answer folded into its card. +function Log({entries}: {entries: ChatEntry[]}) { + const results = new Map( + entries.filter((entry) => entry.kind === 'tool_result').map((entry) => [entry.callId, entry]), + ); + return ( +
    + {entries + .filter((entry) => entry.kind !== 'tool_result') + .map((entry) => ( +
  1. + {entry.text !== '' && ( +
    + {entry.text} +
    + )} + {entry.toolCalls?.map((call) => ( +
    + +
    + ))} + {formatDateTime(entry.at)} +
  2. + ))} +
+ ); +} + +// What a thread is called: what the person first said in it, or when it was started. +function threadLabel(thread: ChatThread): string { + return thread.title ?? `New conversation · ${formatDateTime(thread.createdAt)}`; +} + +// The conversations in the second sidebar: a search over their names, a row to start a new one, +// and the recent ones newest first, the open one lit. +function ConversationList({ + threads, + current, + onNew, +}: { + threads: ChatThread[]; + current: string; + onNew: () => void; +}) { + const [search, setSearch] = useState(''); + const needle = search.trim().toLowerCase(); + const shown = threads.filter( + (thread) => needle === '' || threadLabel(thread).toLowerCase().includes(needle), + ); + const rowClass = + 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'; + + return ( +
+
+ + setSearch(event.target.value)} + placeholder="Search" + aria-label="Search conversations" + className="h-9 bg-background pl-8" + /> +
+ +
+ Recents + {shown.length === 0 && ( + + {needle === '' ? 'No conversations yet.' : 'Nothing matches.'} + + )} +
    + {shown.map((thread) => ( +
  • + + {threadLabel(thread)} + +
  • + ))} +
+
+
+ ); +} + +type Loaded = Awaited>; + +// The page: the conversation in its address, started over when the address changes, so what one +// conversation showed never lingers on another. +export function ChatPage() { + const loaded = useLoaderData(); + return ; +} + +// A conversation with the built-in agent: its log, read again as the agent works, and an input. +// The agent's calls go through the same gate as any agent's, so its activity is in the log by the +// conversation's task. +function Conversation({loaded}: {loaded: Loaded}) { + const navigate = useNavigate(); + const revalidator = useRevalidator(); + const [log, setLog] = useState(loaded.log); + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + // The log, which scrolls on its own; the input under it stays put. + const logView = useRef(null); + const threadId = loaded.log.thread.id; + // The thread as the list names it, which the log's own row doesn't. + const current = loaded.threads.find((thread) => thread.id === threadId) ?? loaded.log.thread; + + // The log as it is now, while the page is open. + useEffect(() => { + let cancelled = false; + const refresh = async () => { + const result = await getThread(threadId); + if (!cancelled && result.isOk()) { + setLog(result.value); + } + }; + const timer = setInterval(() => void refresh(), log.busy ? BUSY_POLL_MS : IDLE_POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [threadId, log.busy]); + + useEffect(() => { + logView.current?.scrollTo({top: logView.current.scrollHeight}); + }, [log.entries.length, log.busy]); + + const send = async () => { + const message = text.trim(); + if (message === '' || sending) { + return; + } + setSending(true); + setError(null); + const posted = await postMessage(threadId, message); + setSending(false); + if (posted.isErr()) { + setError(describeFetchError(posted.error)); + return; + } + setText(''); + const refreshed = await getThread(threadId); + if (refreshed.isOk()) { + setLog(refreshed.value); + } + // The first message names the thread in the list. + if (current.title === null) { + await revalidator.revalidate(); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void send(); + } + }; + + const startThread = async () => { + const made = await createThread(); + if (made.isErr()) { + setError(describeFetchError(made.error)); + return; + } + void navigate(`/chat/${made.value.thread.id}`); + }; + + return ( + Chat} + panel={ + void startThread()} + /> + } + panelLabel="Conversations" + scroll="inside" + > +
+
+ {log.entries.length === 0 ? ( +

+ Say what you want done. The agent can use what you connected, as far as its access on + the Agents page allows, and every call it makes is in the activity log. +

+ ) : ( + + )} + {log.busy &&

Working…

} + {log.sleepingUntil && ( +

+ + Asleep until {formatDateTime(log.sleepingUntil)} +

+ )} +
+
+
+ {error &&

{error}

} +
+ +
+ + + + + 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" +} From e97d754cbf8e88602eda25d8f48bcfea5e3fcddf Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:07:00 +0000 Subject: [PATCH 08/19] give the built-in agent one tool, to delegate, and run each piece of work as a sub-agent that is a task The agent talking with the person makes no calls to their accounts itself: it has one tool, delegate, which starts a sub-agent on a title and a brief. A sub-agent is a thread of its own, with the gate tools and a sleep, and a task of its own, titled as the agent titled it, with the brief as its goal and the report as its outcome. The conversation has no task any more. Every delegation runs in the background: the call is answered at once, that the sub-agent has started, which keeps the chat form whole, and the report lands later as an event, a new kind of entry the model reads as a message, as it reads the person. So the agent is free while sub-agents work, several at once, and a person's message is never held up by one. A sub-agent's first answer without a call is its report, which finishes its thread and its task and answers the call that started it, in one transaction, the sub-agent's thread locked before its parent's. A job that is given up answers its call with the failure, and a sub-agent whose turn failed reports it, so nothing waits forever. The worker runs a few jobs at once. The page shows a delegation as a line with its title, opening to the brief, the report and the sub-agent's activity. The migration for the threads, never deployed, is rewritten in place. --- packages/api/src/harness/context.test.ts | 27 ++ packages/api/src/harness/context.ts | 41 +- packages/api/src/harness/inference.test.ts | 104 +++-- packages/api/src/harness/inference.ts | 84 ++-- packages/api/src/harness/route.test.ts | 19 +- packages/api/src/harness/route.ts | 31 +- packages/api/src/harness/thread-task.ts | 44 +- packages/api/src/harness/tool-job.test.ts | 61 ++- packages/api/src/harness/tool-job.ts | 45 +- packages/api/src/harness/tool-surface.test.ts | 23 +- packages/api/src/harness/tool-surface.ts | 47 ++- packages/api/src/harness/worker.ts | 29 +- .../migration.sql | 24 +- packages/db/prisma/schema.prisma | 36 +- packages/db/src/harness.ts | 392 ++++++++++++++---- packages/docs/content/agents/chat.mdx | 26 +- packages/utils/src/harness.test.ts | 39 ++ packages/utils/src/harness.ts | 40 +- packages/web/src/client/harness-client.ts | 24 +- packages/web/src/pages/chat-page.tsx | 111 ++++- 20 files changed, 944 insertions(+), 303 deletions(-) diff --git a/packages/api/src/harness/context.test.ts b/packages/api/src/harness/context.test.ts index eb278846..5a4da93a 100644 --- a/packages/api/src/harness/context.test.ts +++ b/packages/api/src/harness/context.test.ts @@ -72,6 +72,33 @@ describe('toMessages', () => { 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('with summaries on summaries, only the last counts', () => { const log = [...long, entry(12, 'summary', 'First.', {coversSeq: 4}), entry(13, 'summary', 'Second.', {coversSeq: 8})]; diff --git a/packages/api/src/harness/context.ts b/packages/api/src/harness/context.ts index 0ae97302..6fedb855 100644 --- a/packages/api/src/harness/context.ts +++ b/packages/api/src/harness/context.ts @@ -1,13 +1,15 @@ -import {pendingToolCalls} from '@proxy/utils/harness'; +import {DELEGATE_TOOL, delegateArgs, pendingToolCalls} from '@proxy/utils/harness'; import type {EntryRow} from '@proxy/db/harness'; import type {ModelMessage} from './model'; /** - * The log as the model reads it. 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 in the turns - * before the current one, which is cleared: it is the bulk, and the model can ask again. + * 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. */ /** Once a request to the model was this many tokens, the log is summarized before the next. */ @@ -18,15 +20,29 @@ const KEEP_TURNS = 2; 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 result was cleared from the conversation to save room. Call again if you need it.)'; +const CLEARED = '(This was cleared from the conversation to save room. Ask again if you need it.)'; -function toMessage(entry: EntryRow): ModelMessage { +// 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 = entries.flatMap((entry) => entry.toolCalls ?? []).find((candidate) => candidate.id === event.callId); + const parsed = call?.name === DELEGATE_TOOL ? delegateArgs(JSON.parse(call.arguments)) : 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}`}; } @@ -47,17 +63,18 @@ function sinceSummary(entries: EntryRow[]): {summary: EntryRow | null; tail: Ent } /** - * The log in the chat form, from its last summary on. With `clearOld`, what tools answered 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. + * 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(toMessage); + return tail.map((entry) => toMessage(entries, entry)); } const current = clearOld ? tail.findLastIndex((entry) => entry.kind === 'person') : -1; - return [toMessage(summary), ...tail.map((entry, index) => (entry.kind === 'tool_result' && index < current ? toMessage({...entry, text: CLEARED}) : toMessage(entry)))]; + 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)))]; } /** diff --git a/packages/api/src/harness/inference.test.ts b/packages/api/src/harness/inference.test.ts index 45ac72d3..737eebff 100644 --- a/packages/api/src/harness/inference.test.ts +++ b/packages/api/src/harness/inference.test.ts @@ -16,25 +16,26 @@ 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, finishIdleInference, finishJob, completeInference, requeueJob, appendSummary, getThreadAgent})); -const ensureThreadTask = mock(async () => Ok('task-1')); -mock.module('./thread-task', () => ({ensureThreadTask})); - 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})); -mock.module('./tool-surface', () => ({MODEL_TOOLS: [{name: 'read', description: 'Reads', parameters: {}}]})); +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'); -const thread = {id: 'thread-1', orgId: 'org-1', agentId: 'agent-1', userId: 'user-1', taskId: 'task-1', createdAt: now}; +// 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' | 'summary', + 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}> = {}, ) { @@ -64,18 +65,20 @@ beforeEach(() => { }); describe('runInference', () => { - test('runs the model on the log, as the chat form, and writes its answer with a job per call', async () => { + 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'); listEntries.mockResolvedValue( - Ok([entry('person', 'Hi'), entry('assistant', '', {toolCalls: [{id: 'c1', name: 'read', arguments: '{}'}]}), entry('tool_result', '{"records":[]}', {callId: 'c1'})]), + Ok([ + 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'}), + ]), ); askModel.mockResolvedValue( Ok({ - text: '', - toolCalls: [ - {id: 'c2', name: 'sleep_until', arguments: '{"seconds":60}'}, - {id: 'c3', name: 'read', arguments: '{}'}, - ], + 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, }), @@ -84,25 +87,41 @@ describe('runInference', () => { const result = await runInference(job, 'w1', now); expect(result.isOk()).toBe(true); - expect(ensureThreadTask).toHaveBeenCalledWith(thread, now); // 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.any(String)); + 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('You are talking with the person directly'); 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: 'Hi'}, - {role: 'assistant', content: null, tool_calls: [{id: 'c1', type: 'function', function: {name: 'read', arguments: '{}'}}]}, - {role: 'tool', tool_call_id: 'c1', content: '{"records":[]}'}, + {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([{name: 'read', description: 'Reads', parameters: {}}]); + 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, + }, + {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}'}, @@ -110,9 +129,41 @@ describe('runInference', () => { ], inputTokens: 10, outputTokens: 2, - }, - {c2: new Date('2026-10-21T10:01:00Z')}, + }), ); + + 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('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 log with a call still running is left alone, and a thread that is gone ends the job', async () => { @@ -181,14 +232,17 @@ describe('runInference', () => { 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', async () => { + 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')}), {}); + 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')}), {}); }); }); diff --git a/packages/api/src/harness/inference.ts b/packages/api/src/harness/inference.ts index fb36cfc3..f0a1fa6b 100644 --- a/packages/api/src/harness/inference.ts +++ b/packages/api/src/harness/inference.ts @@ -1,37 +1,55 @@ import {Err, Result} from 'ts-results-es'; import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils'; -import {readyForInference, SLEEP_TOOL, sleepUntil, type ToolCall} from '@proxy/utils/harness'; -import {appendSummary, completeInference, type EntryRow, finishIdleInference, finishJob, getJobThread, getThreadAgent, type JobRow, listEntries, requeueJob, type ThreadRow} from '@proxy/db/harness'; +import {DELEGATE_TOOL, delegateArgs, readyForInference, SLEEP_TOOL, sleepUntil, type ToolCall} from '@proxy/utils/harness'; +import { + appendSummary, + type CallPlan, + completeInference, + type EntryRow, + finishIdleInference, + 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} from './context'; import {askModel, type ModelTurn} from './model'; -import {ensureThreadTask} from './thread-task'; -import {MODEL_TOOLS} from './tool-surface'; +import {toolsFor} from './tool-surface'; /** * An inference job: the model, run once on a thread's log. 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. The log not ready, as when the person spoke while a call was still running, is left for - * the job the last result queues. 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. + * ends, and a delegation is answered at once as started, its report to come as an event. The log + * not ready, as when the person spoke while a call was still running, is left for the job the + * last result queues. 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. A sub-agent's answer with no calls is its report, and ends its work. */ -// How the built-in agent is to go about it, where an MCP client is told about tasks. -const FLOW = - 'You are talking with the person directly, on the Chat page of Personal Agent Proxy; what you write is shown to them as it is, so write plain text, short, and answer what they asked. 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 this conversation’s 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 conversation, so say in your message what you will do then. A long conversation is summarized for you as it goes, and older results are cleared from it: take a summary as what happened, and don’t mention that the conversation was summarized.'; +// 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 it is, so write plain text, short, and answer what they asked. 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 conversation is summarized, by the same model, for itself. const SUMMARIZE = - 'You are summarizing a conversation between a person and their agent, 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. Leave out what no longer matters. Plain prose, from the agent’s side, no title.'; + 'You are summarizing a conversation between a person and their agent, 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 and -// wake-ups included, before it stops and waits for them. +// 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. const MAX_TURNS_ALONE = 30; // How often a model that can't be reached is tried, and how long to wait between tries. @@ -44,6 +62,7 @@ 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.`; // How many answers the model has given since the person last spoke. function turnsAlone(entries: EntryRow[]): number { @@ -51,15 +70,23 @@ function turnsAlone(entries: EntryRow[]): number { return entries.slice(lastPerson + 1).filter((entry) => entry.kind === 'assistant').length; } -// When each call's job is due: a sleep's when it ends, every other's now. -function dueTimes(calls: ToolCall[], now: Date): Record { +// 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.flatMap((call) => { - if (call.name !== SLEEP_TOOL) { - return []; - } + calls.map((call) => { const parsed = Result.wrap((): unknown => JSON.parse(call.arguments)); - return [[call.id, (parsed.isOk() ? sleepUntil(parsed.value, now) : null) ?? now]]; + const args = parsed.isOk() ? parsed.value : null; + 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}]; }), ); } @@ -70,11 +97,11 @@ function answerWith(job: JobRow, workerId: string, text: string): Promise> { return Do(async ($) => { const signedIn = $(requirePresent($(await getThreadAgent(thread)), ApiErr.notFound('agent', thread.agentId))); - const instructions = $(await agentInstructions(signedIn, FLOW)); + const instructions = $(await agentInstructions(signedIn, thread.parentThreadId === null ? MAIN_FLOW : SUB_FLOW)); return `${instructions}\n\nThe time now is ${now.toISOString()}.`; }); } @@ -110,15 +137,15 @@ function askOnLog(job: JobRow, workerId: string, thread: ThreadRow, entries: Ent return Do(async ($) => { const current = $(await compact(job, workerId, entries, key)); const system = $(await systemPrompt(thread, now)); - return $(await askModel({key, messages: [{role: 'system', content: system}, ...toMessages(current, {clearOld: true})], tools: MODEL_TOOLS, maxTokens: MAX_TOKENS, timeoutMs: TIMEOUT_MS})); + return $(await askModel({key, messages: [{role: 'system', content: system}, ...toMessages(current, {clearOld: true})], tools: toolsFor(thread), maxTokens: MAX_TOKENS, timeoutMs: TIMEOUT_MS})); }); } -/** Runs the job: one turn of the model on its thread's log, written back; nothing when the log isn't ready. */ +/** Runs the job: one turn of the model on its thread's log, written back; nothing when the log isn't ready, or the thread is done. */ export function runInference(job: JobRow, workerId: string, now = new Date()): Promise> { return Do(async ($) => { const thread = $(await getJobThread(job.threadId)); - if (!thread) { + if (!thread || thread.finishedAt !== null) { $(await finishJob(job.id, workerId)); return; } @@ -128,7 +155,7 @@ export function runInference(job: JobRow, workerId: string, now = new Date()): P return; } if (turnsAlone(entries) >= MAX_TURNS_ALONE) { - $(await answerWith(job, workerId, TOO_LONG)); + $(await answerWith(job, workerId, thread.parentThreadId === null ? TOO_LONG : TOO_LONG_SUB)); return; } const key = env.OPENROUTER_KEY; @@ -136,7 +163,6 @@ export function runInference(job: JobRow, workerId: string, now = new Date()): P $(await answerWith(job, workerId, NOT_SET_UP)); return; } - $(await ensureThreadTask(thread, now)); const turn = await askOnLog(job, workerId, thread, entries, key, now); if (turn.isErr() && turn.error.kind !== 'provider_unreachable') { return $(Err(turn.error)); @@ -150,7 +176,7 @@ export function runInference(job: JobRow, workerId: string, now = new Date()): P $(await answerWith(job, workerId, UNREACHABLE)); return; } - const written = $(await completeInference(job.id, workerId, thread.id, turn.value, dueTimes(turn.value.toolCalls, now))); + const written = $(await completeInference(job.id, workerId, thread.id, turn.value, plans(thread, turn.value.toolCalls, now))); if (!written) { return $(Err(ApiErr.conflict('The job was no longer this worker’s'))); } diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index 6d6f47e8..eeaa05a1 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -8,8 +8,9 @@ const getMemberThread = mock(); const ensureMemberThread = mock(); const listEntries = mock(); const listOpenJobs = mock(); +const listSubAgents = mock(); const appendPersonMessage = mock(); -mock.module('@proxy/db/harness', () => ({listChatAgents, getChatAgent, updateChatAgent, getMemberThread, ensureMemberThread, listEntries, listOpenJobs, appendPersonMessage})); +mock.module('@proxy/db/harness', () => ({listChatAgents, getChatAgent, updateChatAgent, getMemberThread, ensureMemberThread, listEntries, listOpenJobs, listSubAgents, appendPersonMessage})); const deleteAgent = mock(async () => Ok(true)); mock.module('@proxy/db/agent', () => ({deleteAgent})); @@ -21,13 +22,10 @@ mock.module('../utils/user-org', () => ({requireViewer})); const createBuiltInAgent = mock(async () => Ok({id: 'agent-2', createdById: 'user-1'})); mock.module('./built-in-agent', () => ({createBuiltInAgent})); -const createThreadTask = mock(async () => Ok('task-1')); -mock.module('./thread-task', () => ({createThreadTask})); - const now = new Date('2026-10-21T10:00:00Z'); const agent = {id: 'agent-1', name: 'Finance', shape: 'circle', color: 'blue', createdById: 'user-1'}; const agentResponse = {id: 'agent-1', name: 'Finance', shape: 'circle', color: 'blue', mayChange: true, lastMessage: null}; -const thread = {id: 'thread-1', orgId: 'org-1', agentId: 'agent-1', userId: 'user-1', taskId: 'task-1', createdAt: now}; +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}; function request(params: Record = {}, body: unknown = undefined) { return {user: {userId: 'user-1', email: 'a@example.com'}, params, body} as never; @@ -61,6 +59,7 @@ beforeEach(() => { ]), ); listOpenJobs.mockResolvedValue(Ok([])); + listSubAgents.mockResolvedValue(Ok([{id: 'thread-2', parentCallId: 'c1', title: 'Find the invoices', taskId: 'task-2', finishedAt: now}])); 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, createdAt: now}), ); @@ -135,23 +134,23 @@ describe('the chat routes', () => { expect((await handleDeleteChatAgentRoute(request({agentId: 'nope'}))).unwrapErr()).toMatchObject({kind: 'not_found'}); }); - test('give the viewer’s conversation with the agent, begun with a task where there was none, without its summaries, with whether the agent is busy or asleep', async () => { + test('give the viewer’s conversation with the agent, begun where there was none, without its summaries, with its sub-agents and whether the agent 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({agentId: 'agent-1'}))).unwrap(); expect(idle.agent).toEqual(agentResponse); - expect(idle.thread).toEqual({id: 'thread-1', taskId: 'task-1'}); + expect(idle.thread).toEqual({id: 'thread-1'}); expect(idle.entries).toEqual([{id: 'e1', kind: 'person', text: 'Hi', toolCalls: null, callId: null, isError: false, at: '2026-10-21T10:00:00.000Z'}]); + expect(idle.subAgents).toEqual([{callId: 'c1', title: 'Find the invoices', taskId: 'task-2', finishedAt: '2026-10-21T10:00:00.000Z'}]); expect(idle).toMatchObject({busy: false, sleepingUntil: null}); - expect(createThreadTask).not.toHaveBeenCalled(); + expect(ensureMemberThread).not.toHaveBeenCalled(); getMemberThread.mockResolvedValue(Ok(null)); listOpenJobs.mockResolvedValue(Ok([{...base, runAt: new Date(Date.now() - 1000)}])); expect((await handleGetThreadRoute(request({agentId: 'agent-1'}))).unwrap()).toMatchObject({busy: true, sleepingUntil: null}); - expect(createThreadTask).toHaveBeenCalledWith({orgId: 'org-1', agentId: 'agent-1', userId: 'user-1'}); - expect(ensureMemberThread).toHaveBeenCalledWith({orgId: 'org-1', agentId: 'agent-1', userId: 'user-1', taskId: 'task-1'}); + expect(ensureMemberThread).toHaveBeenCalledWith({orgId: 'org-1', agentId: 'agent-1', userId: 'user-1'}); listOpenJobs.mockResolvedValue(Ok([{...base, runAt: soon}])); expect((await handleGetThreadRoute(request({agentId: 'agent-1'}))).unwrap()).toMatchObject({busy: false, sleepingUntil: soon.toISOString()}); diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts index d864223a..00401d0e 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -15,6 +15,7 @@ import { listChatAgents, listEntries, listOpenJobs, + listSubAgents, type ThreadRow, updateChatAgent, } from '@proxy/db/harness'; @@ -24,13 +25,13 @@ import type {AuthenticatedRequest} from '../server/middleware/require-auth'; import {manages} from '../utils/manages'; import {type OrgViewer, requireViewer} from '../utils/user-org'; import {createBuiltInAgent} from './built-in-agent'; -import {createThreadTask} from './thread-task'; /** * The Chat page's routes, under the organization: its built-in agents, which every member sees, - * each named and given a face, and the member's own conversation with each, its log 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. + * each named and given a face, 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; @@ -38,10 +39,13 @@ const MAX_MESSAGE = 20_000; /** A built-in agent as the page lists it: its persona, 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 ChatAgentResponse = {id: string; name: string; shape: string; color: string; mayChange: boolean; lastMessage: {kind: 'person' | 'assistant'; text: string; at: string} | null}; -export type EntryResponse = {id: string; kind: 'person' | 'assistant' | 'tool_result'; text: string; toolCalls: ToolCall[] | null; callId: string | null; isError: boolean; at: string}; +export type EntryResponse = {id: string; kind: 'person' | 'assistant' | 'tool_result' | 'event'; text: string; toolCalls: ToolCall[] | null; callId: string | null; isError: boolean; at: string}; -/** The member's conversation with an agent: the agent, the thread with the task its calls are made for, its log, and what the agent is up to: busy on it, asleep until a time, or waiting for the person. */ -export type ThreadLogResponse = {agent: ChatAgentResponse; thread: {id: string; taskId: string}; entries: EntryResponse[]; busy: boolean; sleepingUntil: string | null}; +/** A sub-agent started from the conversation: the call that started it, its title, the task its calls are made for, which the activity log can be narrowed to, and when it was done. */ +export type SubAgentResponse = {callId: string; title: string; taskId: string; finishedAt: string | null}; + +/** The member's conversation with an agent: the agent, the thread, its log, the sub-agents started from it, and what the agent is up to: busy on it, asleep until a time, or waiting for the person. */ +export type ThreadLogResponse = {agent: ChatAgentResponse; thread: {id: string}; entries: EntryResponse[]; subAgents: SubAgentResponse[]; busy: boolean; sleepingUntil: string | null}; // How much of the last message the list shows. const PREVIEW_CHARS = 120; @@ -108,16 +112,14 @@ function requireManagedAgent(viewer: OrgViewer, request: AuthenticatedRequest): }); } -// The viewer's thread with the agent, made the first time, with a task for its calls. +// The viewer's conversation with the agent, begun the first time. function requireThread(viewer: OrgViewer, agent: ChatAgentRow): Promise> { return Do(async ($) => { const existing = $(await getMemberThread(agent.id, viewer.userId)); if (existing) { return existing; } - const made = {orgId: viewer.orgId, agentId: agent.id, userId: viewer.userId}; - const taskId = $(await createThreadTask(made)); - return $(await ensureMemberThread({...made, taskId})); + return $(await ensureMemberThread({orgId: viewer.orgId, agentId: agent.id, userId: viewer.userId})); }); } @@ -164,13 +166,13 @@ export function handleDeleteChatAgentRoute(request: AuthenticatedRequest): Promi }); } -/** `GET /api/orgs/:org/chat/agents/:agentId/thread`: the viewer's conversation with the agent, begun where there was none, and whether the agent is busy on it. */ +/** `GET /api/orgs/:org/chat/agents/:agentId/thread`: the viewer's conversation with the agent, begun where there was none, its sub-agents, and whether the agent is busy on it, sub-agents included. */ export function handleGetThreadRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { const viewer = $(await requireChat(request)); const agent = $(await requireAgent(viewer, request)); const thread = $(await requireThread(viewer, agent)); - const [entries, jobs] = await Promise.all([listEntries(thread.id), listOpenJobs(thread.id)]); + const [entries, jobs, subAgents] = await Promise.all([listEntries(thread.id), listOpenJobs(thread.id), listSubAgents(thread.id)]); const open = $(jobs); const now = Date.now(); // Asleep when every open job is a tool job due later; the first to be due is when it wakes. @@ -179,8 +181,9 @@ export function handleGetThreadRoute(request: AuthenticatedRequest): Promise job.runAt.getTime()).sort((a, b) => a - b)[0]; return { agent: toAgent(viewer, agent), - thread: {id: thread.id, taskId: thread.taskId}, + thread: {id: thread.id}, entries: $(entries).filter(isShown).map(toEntry), + subAgents: $(subAgents).map((sub) => ({callId: sub.parentCallId, title: sub.title, taskId: sub.taskId, finishedAt: sub.finishedAt?.toISOString() ?? null})), busy: open.length > 0 && !asleep, sleepingUntil: asleep && first !== undefined ? new Date(first).toISOString() : null, }; diff --git a/packages/api/src/harness/thread-task.ts b/packages/api/src/harness/thread-task.ts index 9bf05536..17bbab9f 100644 --- a/packages/api/src/harness/thread-task.ts +++ b/packages/api/src/harness/thread-task.ts @@ -1,4 +1,4 @@ -import type {Result} from 'ts-results-es'; +import {Err, type Result} from 'ts-results-es'; import {type ApiError, ApiErr, Do, requirePresent} from '@proxy/utils'; import {taskEndedAt} from '@proxy/utils/tasks'; @@ -6,47 +6,41 @@ import {createAgentTask, getAgentTask} from '@proxy/db/task'; import {listEntries, setThreadTask, type ThreadRow} from '@proxy/db/harness'; /** - * The task a thread's calls are made for, as every agent's calls are, so the activity log says - * which conversation each request was for. It is the member's, as the conversation is, so what the - * agent did in it is theirs alone to see. A thread starts with one, and gets a new one when it was - * abandoned, a day after it started, since the gate refuses calls for one that was. + * 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. The + * agent talking with the person has none: it makes no calls but to delegate. */ -const TITLE = 'Chat'; const MAX_GOAL = 2000; -// What the task is for: what the person first said in the thread, as far as a goal holds. +// What the task is for: the sub-agent's brief, as far as a goal holds. function goalOf(entries: Array<{kind: string; text: string}>): string { const first = entries.find((entry) => entry.kind === 'person')?.text.trim() ?? ''; if (first === '') { - return 'A conversation on the Chat page'; + return 'A piece of work from the Chat page'; } return first.length > MAX_GOAL ? `${first.slice(0, MAX_GOAL - 1)}…` : first; } -// A task for the thread's calls; a sub-task of nothing, so always made. -function makeTask(thread: {orgId: string; agentId: string; userId: string}, goal: string): Promise> { - return Do(async ($) => { - const made = $(await createAgentTask({orgId: thread.orgId, agentId: thread.agentId, ownerUserId: thread.userId, parentId: null, title: TITLE, goal, via: 'harness'})); - return $(requirePresent(made, ApiErr.internalError(new Error('A task with no parent was not made')))).id; - }); -} - -/** A thread's first task, before it has a log. */ -export function createThreadTask(thread: {orgId: string; agentId: string; userId: string}): Promise> { - return makeTask(thread, 'A conversation on the Chat page'); -} - -/** The thread's task, still under way: the one it has, or a new one in its place. */ +/** 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 = $(await getAgentTask(thread.orgId, thread.agentId, thread.taskId)); if (task && taskEndedAt({lastStartedAt: task.lastStartedAt, finishedAt: task.finishedAt}, now) === null) { return task.id; } const entries = $(await listEntries(thread.id)); - const made = $(await makeTask(thread, goalOf(entries))); - $(await setThreadTask(thread.id, made)); - return made; + const made = $( + await createAgentTask({orgId: thread.orgId, agentId: thread.agentId, ownerUserId: thread.userId, parentId: null, title: thread.title ?? 'Sub-agent', goal: goalOf(entries), 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 index c4930d61..667c4f6e 100644 --- a/packages/api/src/harness/tool-job.test.ts +++ b/packages/api/src/harness/tool-job.test.ts @@ -9,7 +9,8 @@ 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)); -mock.module('@proxy/db/harness', () => ({getJobThread, getEntry, completeToolJob, failJob, finishJob, getThreadAgent})); +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})); @@ -20,7 +21,20 @@ 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'); -const thread = {id: 'thread-1', orgId: 'org-1', agentId: 'agent-1', userId: 'user-1', taskId: 'task-1', createdAt: now}; +// 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}; @@ -51,7 +65,7 @@ beforeEach(() => { }); describe('runToolJob', () => { - test('runs a gate tool through the MCP dispatch as the built-in agent, for the thread’s task', async () => { + 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); @@ -61,6 +75,47 @@ describe('runToolJob', () => { 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}); diff --git a/packages/api/src/harness/tool-job.ts b/packages/api/src/harness/tool-job.ts index 1277bc60..1b846998 100644 --- a/packages/api/src/harness/tool-job.ts +++ b/packages/api/src/harness/tool-job.ts @@ -1,20 +1,21 @@ -import {Result} from 'ts-results-es'; +import {Err, Result} from 'ts-results-es'; import {type ApiError, ApiErr, Do, requirePresent} from '@proxy/utils'; -import {SLEEP_TOOL, sleepUntil} from '@proxy/utils/harness'; -import {completeToolJob, failJob, finishJob, getEntry, getJobThread, getThreadAgent, type JobRow, type ThreadRow} from '@proxy/db/harness'; +import {DELEGATE_TOOL, delegateArgs, SLEEP_TOOL, sleepUntil} 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. The MCP server's tools go - * through the gate as the thread's agent acting as its member, for the thread's 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. + * 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. @@ -41,7 +42,7 @@ function cut(text: string): string { 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 agent acts in the thread, for the thread's task. +// 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))); @@ -51,19 +52,30 @@ function runGateTool(thread: ThreadRow, name: string, args: Record> { return Do(async ($) => { const args = parseArgs(call.arguments); if (args === null) { return error('The arguments were not a JSON object.'); } - if (call.name === SLEEP_TOOL) { + 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)) { + 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. @@ -74,7 +86,7 @@ function answer(thread: ThreadRow, job: JobRow, call: {name: string; arguments: }); } -/** Runs the job: the call it is for, its answer written to the log, and the model woken once every call is answered. */ +/** 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)); @@ -88,6 +100,13 @@ export function runToolJob(job: JobRow, workerId: string, now = new Date()): Pro $(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 index 5ce9c681..48285283 100644 --- a/packages/api/src/harness/tool-surface.test.ts +++ b/packages/api/src/harness/tool-surface.test.ts @@ -4,21 +4,33 @@ import {describe, expect, mock, test} from 'bun:test'; // 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, MODEL_TOOLS} = await import('./tool-surface'); +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 = MODEL_TOOLS.find((candidate) => candidate.name === name); + const found = SUB_AGENT_TOOLS.find((candidate) => candidate.name === name); if (!found) { - throw new Error(`No model tool ${name}`); + throw new Error(`No sub-agent tool ${name}`); } return {...found, parameters: found.parameters as Params}; } -describe('the built-in agent’s tools', () => { +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(MODEL_TOOLS.map((candidate) => candidate.name)).toEqual(['list_connections', 'list_tools', 'read', 'run', 'send_feedback', 'sleep_until']); + 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', () => { @@ -40,5 +52,6 @@ describe('the built-in agent’s tools', () => { 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 index 9a39afea..004c7b5d 100644 --- a/packages/api/src/harness/tool-surface.ts +++ b/packages/api/src/harness/tool-surface.ts @@ -1,22 +1,41 @@ -import {SLEEP_TOOL} from '@proxy/utils/harness'; +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 MCP server's, as any agent gets them, less the two about - * tasks, since every call in a thread is for the thread's task and the loop fills the task in - * (inference.ts, tool-job.ts); and one of its own, to be woken later. Nothing else: everything it - * does to the person's accounts goes through the same gate as Claude's calls. + * 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 the built-in agent doesn't need: its task is the thread's. +// 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 person asked for at a time they 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.', + '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: { @@ -31,7 +50,7 @@ const SLEEP: ModelTool = { // server's words. const DESCRIPTIONS: Record = { list_connections: - 'Call this before telling the person a service isn’t connected: their 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.', + '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.', @@ -48,8 +67,16 @@ function withoutTask(tool: McpToolDefinition): ModelTool { }; } -/** The tools the model is given, in the order the MCP server lists its. */ -export const MODEL_TOOLS: ModelTool[] = [...MCP_TOOLS.filter((tool) => !TASK_TOOLS.has(tool.name)).map(withoutTask), SLEEP]; +/** 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 { diff --git a/packages/api/src/harness/worker.ts b/packages/api/src/harness/worker.ts index e458caf1..7cd0d670 100644 --- a/packages/api/src/harness/worker.ts +++ b/packages/api/src/harness/worker.ts @@ -11,20 +11,23 @@ import {runInference} from './inference'; import {runToolJob} from './tool-job'; /** - * The worker that runs the built-in agent's jobs, one at a time, in each api instance: it claims - * the next job due, keeps its lease while the job runs, and claims the next as soon as it is done, - * so a turn's calls 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 job it is on, up to a point. + * 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 job under way. +// How long stopping waits for the jobs under way. const STOP_MS = 8_000; export type HarnessWorker = {stop: () => Promise}; @@ -65,10 +68,14 @@ async function runClaimed(job: JobRow, workerId: string): Promise { export function startHarnessWorker(): HarnessWorker { const workerId = workerName(); let stopping = false; - let current: Promise = Promise.resolve(); + 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)); @@ -79,8 +86,8 @@ export function startHarnessWorker(): HarnessWorker { await sleep(POLL_MS); continue; } - current = runClaimed(claimed.value, workerId); - await current; + const job: Promise = runClaimed(claimed.value, workerId).finally(() => running.delete(job)); + running.add(job); } }; void loop(); @@ -89,7 +96,7 @@ export function startHarnessWorker(): HarnessWorker { return { stop: async () => { stopping = true; - await Promise.race([current, sleep(STOP_MS)]); + await Promise.race([Promise.all(running), sleep(STOP_MS)]); }, }; } 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 index 1469e713..70e3a139 100644 --- 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 @@ -1,12 +1,13 @@ -- The built-in agents: agents of the organization with a face, each with a thread per member, the --- member's conversation with it, with its log of entries (what the person said, what the model --- answered with its tool calls, what each call answered, and summaries of the log once it has --- grown long), and the jobs that run it (the model on a log, one tool call), claimed by one api --- instance at a time with a lease. Their requests come in a third way, `harness`, beside the --- agent pages and the MCP server. +-- 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. 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', 'summary'); +CREATE TYPE "AgentEntryKind" AS ENUM ('person', 'assistant', 'tool_result', 'event', 'summary'); -- CreateEnum CREATE TYPE "AgentJobKind" AS ENUM ('inference', 'tool'); @@ -27,7 +28,11 @@ CREATE TABLE "AgentThread" ( "orgId" TEXT NOT NULL, "agentId" TEXT NOT NULL, "userId" TEXT NOT NULL, - "taskId" 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, @@ -72,7 +77,10 @@ CREATE TABLE "AgentJob" ( ); -- CreateIndex -CREATE UNIQUE INDEX "AgentThread_agentId_userId_key" ON "AgentThread"("agentId", "userId"); +CREATE INDEX "AgentThread_agentId_userId_idx" ON "AgentThread"("agentId", "userId"); + +-- CreateIndex +CREATE INDEX "AgentThread_parentThreadId_idx" ON "AgentThread"("parentThreadId"); -- CreateIndex CREATE INDEX "AgentThread_orgId_createdAt_idx" ON "AgentThread"("orgId", "createdAt"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b11a44da..1352efb1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -545,11 +545,23 @@ model AgentThread { // One of the organization's built-in agents, shared with every member of it, an Agent like any // other, with grants of its own. agentId String - // The member whose conversation with the agent it is: the agent acts as them in it, reaching - // what they see, and nobody else sees it. + // 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 - // The task its calls are made for. - taskId 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 @@ -560,17 +572,21 @@ model AgentThread { entries AgentEntry[] jobs AgentJob[] - @@unique([agentId, userId]) + @@index([agentId, userId]) + @@index([parentThreadId]) @@index([orgId, createdAt]) } -// What an entry of a thread's log is: what the person said, what the model answered, with the tool -// calls it made, what one of those calls answered, 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. +// 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 } @@ -586,9 +602,9 @@ model AgentEntry { text String // An assistant entry's tool calls as JSON (`@proxy/utils/harness` ToolCall[]); null otherwise. toolCalls String? - // A tool_result entry's call, by the id the model gave it; null otherwise. + // A tool_result or event entry's call, by the id the model gave it; null otherwise. callId String? - // A tool_result that is an error, in words the model can act on. + // 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? diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index 66ed56f1..2f83ece8 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -2,26 +2,39 @@ import type {AgentEntryKind, AgentJobKind, AgentJobStatus, Prisma} from '@prisma import {Err, Ok, type Result} from 'ts-results-es'; import {ApiErr, type ApiError, wrapDb} from '@proxy/utils'; -import {type LogEntry, pendingToolCalls, type ToolCall} from '@proxy/utils/harness'; +import {type LogEntry, 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 built-in agents, the organization's, each with a thread per member, the member's - * conversation with it, its log and its jobs. Every change to a thread's log or its jobs is made - * with the thread's row locked, so a result landing and the model being run on the log never - * race: either the inference job sees the result, or the result sees no job and queues one. A - * worker's write is fenced by its lease: it goes through only while the job is still its. + * conversation with it, and a thread per sub-agent started from it, each with its log and its + * jobs. Every change to a thread's log or its jobs is made with the thread's row locked, so a + * result landing and the model being run on the log never race: either the inference job sees + * the result, or the result sees no job and queues one. 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; agentId: string; - // The member whose conversation it is. + // The member whose conversation it is, or was started from. userId: string; - taskId: 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; }; @@ -55,12 +68,31 @@ export type JobRow = { attempts: number; }; +/** A sub-agent of a thread, for the page: the call that started it, its title, its task, and when it was done. */ +export type SubAgentRow = { + id: string; + parentCallId: string; + title: string; + taskId: string; + finishedAt: Date | null; +}; + +/** 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, userId: true, + parentThreadId: true, + parentCallId: true, + title: true, taskId: true, + finishedAt: true, createdAt: true, } as const; @@ -127,6 +159,10 @@ function toEntry(entry: EntryRecord): EntryRow { }; } +function cut(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + /** The organization's built-in agents, those of `providerId`, oldest first, with the last thing said in the member's conversation with each. */ export async function listChatAgents( orgId: string, @@ -140,7 +176,7 @@ export async function listChatAgents( select: { ...chatAgentSelect, threads: { - where: {userId}, + where: {userId, parentThreadId: null}, select: { // The agent's turns that only made calls say nothing, so the last with words. entries: { @@ -196,33 +232,62 @@ export async function updateChatAgent( ).map(({count}) => count > 0); } -/** The member's thread with the agent; `Ok(null)` before they have one. */ +/** The member's conversation with the agent; `Ok(null)` before they have one. */ export async function getMemberThread( agentId: string, userId: string, ): Promise> { return wrapDb(() => - db.agentThread.findUnique({where: {agentId_userId: {agentId, userId}}, select: threadSelect}), + db.agentThread.findFirst({ + where: {agentId, userId, parentThreadId: null}, + select: threadSelect, + }), ); } /** - * The member's thread with the agent, made with its calls for `taskId` where they have none yet: - * the one there is otherwise, two first openings at once making one. + * The member's conversation with the agent, made where they have none yet: the one there is + * otherwise, two first openings at once making one, since the agent's row is locked meanwhile. */ export async function ensureMemberThread(data: { orgId: string; agentId: string; userId: string; - taskId: string; }): Promise> { const {agentId, userId} = data; return wrapDb(() => - db.agentThread.upsert({ - where: {agentId_userId: {agentId, userId}}, - create: data, - update: {}, - select: threadSelect, + db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "Agent" WHERE "id" = ${agentId} FOR UPDATE`; + const existing = await tx.agentThread.findFirst({ + where: {agentId, userId, parentThreadId: null}, + select: threadSelect, + }); + if (existing) { + return existing; + } + return tx.agentThread.create({data, select: threadSelect}); + }), + ); +} + +/** The sub-agents started from a thread, oldest first. */ +export async function listSubAgents(threadId: string): Promise> { + return ( + await wrapDb(() => + db.agentThread.findMany({ + where: {parentThreadId: threadId}, + select: {id: true, parentCallId: true, title: true, taskId: true, finishedAt: true}, + orderBy: {createdAt: 'asc'}, + }), + ) + ).map((threads) => + threads.flatMap((thread) => { + if (thread.parentCallId === null || thread.title === null || thread.taskId === null) { + return []; + } + return [ + {...thread, parentCallId: thread.parentCallId, title: thread.title, taskId: thread.taskId}, + ]; }), ); } @@ -262,25 +327,24 @@ export async function listEntries(threadId: string): Promise entries.map(toEntry)); } -/** Whether the agent is busy on the thread: a job of it queued or running, a sleep among them. */ -export async function isThreadBusy(threadId: string): Promise> { - return ( - await wrapDb(() => db.agentJob.count({where: {threadId, status: {in: ['queued', 'running']}}})) - ).map((count) => count > 0); -} - -/** The jobs still to run or running on the thread: what the agent is up to, for the person. */ +/** + * 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: {threadId, status: {in: ['queued', 'running']}}, + where: { + status: {in: ['queued', 'running']}, + OR: [{threadId}, {thread: {parentThreadId: threadId, finishedAt: null}}], + }, select: jobSelect, orderBy: {runAt: 'asc'}, }), ); } -/** A new task for the thread's calls, once the one before was abandoned. */ +/** A new task for a sub-agent's calls, once the one before was abandoned. */ export async function setThreadTask( threadId: string, taskId: string, @@ -290,24 +354,100 @@ export async function setThreadTask( ); } -// Locks the thread's row for the rest of the transaction; false when there is no such thread. -async function lockThread(tx: Tx, threadId: string): Promise { +// 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`; - return rows.length > 0; + 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. With the -// thread locked. -async function queueInference(tx: Tx, threadId: string): Promise { +// 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, kind: 'inference', status: {in: ['queued', 'running']}}, + where: {threadId: thread.id, kind: 'inference', status: {in: ['queued', 'running']}}, }); if (active > 0) { return; } - await tx.agentJob.create({data: {threadId, kind: 'inference'}}); + await tx.agentJob.create({data: {threadId: thread.id, kind: 'inference'}}); +} + +// Queues an inference job where the log is ready for one. With the thread locked. +async function wakeIfReady(tx: Tx, thread: ThreadRow): Promise { + const entries = ( + await tx.agentEntry.findMany({ + where: {threadId: thread.id}, + select: entrySelect, + orderBy: {seq: 'asc'}, + }) + ).map(toEntry); + if (readyForInference(entries)) { + await queueInference(tx, thread); + } +} + +// Answers a call of the thread's log, as its result or, for one answered at once as started, as +// an event, and wakes the agent where the log is then ready. With the thread locked. +async function answerCall( + tx: Tx, + thread: ThreadRow, + kind: 'tool_result' | 'event', + answer: {callId: string; text: string; isError: boolean}, +): Promise { + await tx.agentEntry.create({ + data: { + threadId: thread.id, + kind, + text: answer.text, + callId: answer.callId, + isError: answer.isError, + }, + }); + await wakeIfReady(tx, thread); +} + +// 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. 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}, +): 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}); } /** Appends what the person said and wakes the agent. `Ok(null)` means no such thread. */ @@ -317,14 +457,15 @@ export async function appendPersonMessage( ): Promise> { return wrapDb(() => db.$transaction(async (tx) => { - if (!(await lockThread(tx, threadId))) { + const thread = await lockThread(tx, threadId); + if (!thread) { return null; } const entry = await tx.agentEntry.create({ data: {threadId, kind: 'person' satisfies AgentEntryKind, text}, select: entrySelect, }); - await queueInference(tx, threadId); + await queueInference(tx, thread); return toEntry(entry); }), ); @@ -401,15 +542,6 @@ async function held(tx: Tx, jobId: string, workerId: string): Promise { return count > 0; } -// Gives the job back to the queue, to run at `runAt`; false when it is no longer the worker's. -async function requeue(tx: Tx, jobId: string, workerId: string, runAt: Date): Promise { - const {count} = await tx.agentJob.updateMany({ - where: {id: jobId, claimedBy: workerId, status: 'running'}, - data: {status: 'queued', runAt, claimedBy: null, leaseExpiresAt: null}, - }); - 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, @@ -418,13 +550,44 @@ export async function finishJob( return wrapDb(() => db.$transaction((tx) => fence(tx, jobId, workerId, 'done'))); } -/** Marks the worker's job failed, saying why; `Ok(false)` when it was no longer the worker's. */ +/** + * 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, 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((tx) => fence(tx, jobId, workerId, 'failed', error))); + 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) { + await answerCall(tx, thread, 'tool_result', { + callId: job.callId, + text: `This call could not be run: ${error}`, + isError: true, + }); + return true; + } + if (job.kind === 'inference') { + await endSubAgent(tx, thread, {text: `The sub-agent failed: ${error}`, isError: true}); + } + return true; + }), + ); } /** Puts the worker's job back in the queue for later; `Ok(false)` when it was no longer the worker's. */ @@ -433,7 +596,15 @@ export async function requeueJob( workerId: string, runAt: Date, ): Promise> { - return wrapDb(() => db.$transaction((tx) => requeue(tx, jobId, workerId, runAt))); + return wrapDb(() => + db.$transaction(async (tx) => { + const {count} = await tx.agentJob.updateMany({ + where: {id: jobId, claimedBy: workerId, status: 'running'}, + data: {status: 'queued', runAt, claimedBy: null, leaseExpiresAt: null}, + }); + return count > 0; + }), + ); } /** What the model answered: its text, its tool calls, and what they cost. */ @@ -480,20 +651,22 @@ export async function appendSummary( /** * Finishes an inference job with the model's answer: the assistant entry, and a tool job for each - * of its calls, due at `runAt` where a call says when (a sleep), else now. `Ok(false)` when the - * job was no longer the worker's, and nothing is written. + * 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. `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, - runAt: Record, + plans: Record, ): Promise> { return wrapDb(() => db.$transaction(async (tx) => { - await lockThread(tx, threadId); - if (!(await fence(tx, jobId, workerId, 'done'))) { + const thread = await lockThread(tx, threadId); + if (!thread || !(await fence(tx, jobId, workerId, 'done'))) { return false; } const entry = await tx.agentEntry.create({ @@ -507,16 +680,29 @@ export async function completeInference( }, select: {id: true}, }); - if (turn.toolCalls.length > 0) { - await tx.agentJob.createMany({ - data: turn.toolCalls.map((call) => ({ - threadId, - kind: 'tool' satisfies AgentJobKind, - entryId: entry.id, - callId: call.id, - runAt: runAt[call.id] ?? new Date(), - })), - }); + if (turn.toolCalls.length === 0) { + await endSubAgent(tx, thread, {text: turn.text, isError: false}); + 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}]; + }); + if (started.length > 0) { + await tx.agentEntry.createMany({data: started}); + await wakeIfReady(tx, thread); } return true; }), @@ -524,9 +710,9 @@ export async function completeInference( } /** - * Finishes a tool job with what the call answered, and wakes the agent once every call of the - * answer has its result. `Ok(false)` when the job was no longer the worker's, and nothing is - * written. + * 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, @@ -536,29 +722,65 @@ export async function completeToolJob( ): Promise> { return wrapDb(() => db.$transaction(async (tx) => { - await lockThread(tx, threadId); + const thread = await lockThread(tx, threadId); + if (!thread || !(await fence(tx, jobId, workerId, 'done'))) { + return false; + } + await answerCall(tx, thread, 'tool_result', result); + return true; + }), + ); +} + +/** + * Finishes a delegate call's tool job by starting the sub-agent: its task, 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; } - await tx.agentEntry.create({ + const now = new Date(); + const task = await tx.agentTask.create({ data: { - threadId, - kind: 'tool_result' satisfies AgentEntryKind, - text: result.text, - callId: result.callId, - isError: result.isError, + orgId: parent.orgId, + agentId: parent.agentId, + ownerUserId: parent.userId, + parentId: null, + title: call.title, + goal: cut(call.brief, MAX_TASK_TEXT), + via: 'harness', + createdAt: now, + lastStartedAt: now, }, + select: {id: true}, }); - const entries = ( - await tx.agentEntry.findMany({ - where: {threadId}, - select: entrySelect, - orderBy: {seq: 'asc'}, - }) - ).map(toEntry); - if (pendingToolCalls(entries).length === 0) { - await queueInference(tx, threadId); - } + const thread = await tx.agentThread.create({ + data: { + orgId: parent.orgId, + agentId: parent.agentId, + 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; }), ); diff --git a/packages/docs/content/agents/chat.mdx b/packages/docs/content/agents/chat.mdx index ce1e0351..ee7b1ea8 100644 --- a/packages/docs/content/agents/chat.mdx +++ b/packages/docs/content/agents/chat.mdx @@ -16,6 +16,11 @@ shared with everyone, the ones you connected yourself and your own vault, beside 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 @@ -23,21 +28,24 @@ host that neither keeps prompts nor trains on them, so a self-hosted Personal Ag ## How a conversation runs -Say what you want done. The agent answers, or makes calls first: each shows as a line above its -message, opening to what it asked and, once it has it, what came back. A call it is not allowed is -refused and logged as blocked, as it would be for any agent. It keeps working after you leave the -page, and the page shows the conversation as it is when you come back. +Say what you want done. The agent answers, or starts sub-agents first: each shows as a line above +its message, with its title, opening to the brief it was given and, once it is done, its report, +and a way to its activity. Sub-agents started together run side by side. 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. -It 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 the conversation and nothing else, so it says -what it will do before it sleeps. +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 for this conversation** under the input opens the activity log narrowed to the -conversation's task. +**Activity of this agent** under the input opens the activity log narrowed to the agent, and each +sub-agent's line opens to the activity of its task. Chat is being tried out with some organizations first, so it is only offered to an organization diff --git a/packages/utils/src/harness.test.ts b/packages/utils/src/harness.test.ts index 5b107847..eec37607 100644 --- a/packages/utils/src/harness.test.ts +++ b/packages/utils/src/harness.test.ts @@ -3,6 +3,7 @@ import {describe, expect, test} from 'bun:test'; import { AVATAR_COLORS, AVATAR_SHAPES, + delegateArgs, isAvatarColor, isAvatarShape, type LogEntry, @@ -24,6 +25,12 @@ const result = (callId: string): LogEntry => ({ 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', () => { @@ -55,6 +62,38 @@ describe('readyForInference', () => { 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('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', () => { diff --git a/packages/utils/src/harness.ts b/packages/utils/src/harness.ts index e6a7297c..7282b506 100644 --- a/packages/utils/src/harness.ts +++ b/packages/utils/src/harness.ts @@ -1,18 +1,21 @@ /** * 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, 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. + * 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}; export type LogEntry = { - kind: 'person' | 'assistant' | 'tool_result' | 'summary'; + 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; }; @@ -46,9 +49,36 @@ export function readyForInference(entries: LogEntry[]): boolean { return last.kind !== 'assistant'; } -/** The name of the one tool the built-in agent has beyond its connections: a wake-up later. */ +/** 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; + } + const trimmed = title.trim(); + return { + title: trimmed.length > MAX_TITLE ? `${trimmed.slice(0, MAX_TITLE - 1)}…` : trimmed, + brief: brief.trim(), + }; +} + // How long a sleep may be at most. const MAX_SLEEP_MS = 7 * 24 * 60 * 60 * 1000; diff --git a/packages/web/src/client/harness-client.ts b/packages/web/src/client/harness-client.ts index 38150657..6c23fbba 100644 --- a/packages/web/src/client/harness-client.ts +++ b/packages/web/src/client/harness-client.ts @@ -3,7 +3,8 @@ import {z} from 'zod'; import {apiRequest, apiSend, orgApi} from '@/client/request'; // The Chat page's api: the organization's built-in agents, each named and given a face, which -// every member sees, the person's own conversation with each, its log, and what they say in it. +// every member sees, the person's own conversation with each, its log, the sub-agents started from +// it, and what they say in it. // An agent's persona as the person makes or changes it. const personaSchema = z.object({ @@ -26,22 +27,32 @@ const toolCallSchema = z.object({id: z.string(), name: z.string(), arguments: z. const entrySchema = z.object({ id: z.string(), - kind: z.enum(['person', 'assistant', 'tool_result']), + // An event is a sub-agent's report, which came after its call was answered as started. + kind: z.enum(['person', 'assistant', 'tool_result', 'event']), text: z.string(), // An assistant entry's calls; null otherwise. toolCalls: z.array(toolCallSchema).nullable(), - // A tool result's call; null otherwise. + // A tool result's or an event's call; null otherwise. callId: z.string().nullable(), isError: z.boolean(), at: z.string(), }); +// A sub-agent started from the conversation: by which call, its title, the task its calls are +// made for, which the activity log can be narrowed to, and when it was done. +const subAgentSchema = z.object({ + callId: z.string(), + title: z.string(), + taskId: z.string(), + finishedAt: z.string().nullable(), +}); + const threadLogSchema = z.object({ agent: agentSchema, - // The conversation, with the task every call in it is made for, which the activity log can be narrowed to. - thread: z.object({id: z.string(), taskId: z.string()}), + thread: z.object({id: z.string()}), entries: z.array(entrySchema), - // Whether the agent is at work on the conversation, or asleep until a time. + subAgents: z.array(subAgentSchema), + // Whether the agent is at work on the conversation, sub-agents included, or asleep until a time. busy: z.boolean(), sleepingUntil: z.string().nullable(), }); @@ -50,6 +61,7 @@ export type Persona = z.infer; export type ChatAgent = z.infer; export type ChatEntry = z.infer; export type ChatToolCall = z.infer; +export type ChatSubAgent = z.infer; export type ThreadLog = z.infer; export function listChatAgents(org: string) { diff --git a/packages/web/src/pages/chat-page.tsx b/packages/web/src/pages/chat-page.tsx index 693fac94..b3570a8c 100644 --- a/packages/web/src/pages/chat-page.tsx +++ b/packages/web/src/pages/chat-page.tsx @@ -1,10 +1,11 @@ -import {ArrowUpIcon, ChevronDownIcon, MoonIcon, PlusIcon, WrenchIcon} from 'lucide-react'; +import {ArrowUpIcon, BotIcon, ChevronDownIcon, MoonIcon, PlusIcon, WrenchIcon} from 'lucide-react'; import {type FormEvent, type KeyboardEvent, useEffect, useRef, useState} from 'react'; import {Link, useLoaderData, useNavigate, useRevalidator} from 'react-router'; import {Result} from 'ts-results-es'; import { type ChatAgent, type ChatEntry, + type ChatSubAgent, type ChatToolCall, createChatAgent, deleteChatAgent, @@ -72,11 +73,16 @@ function pretty(json: string): string { type CallState = 'running' | 'done' | 'failed'; // What a call did, in a few words, from its name and arguments, as it runs, once it has, and -// where it failed: "Running entries_list", "Ran entries_list", "Failed to run entries_list". +// where it failed: "Running entries_list", "Ran entries_list", "Failed to run entries_list"; a +// delegation by the sub-agent's title. function callTitle(call: ChatToolCall, state: CallState): string { const args = parseArgs(call.arguments); const said = (verbs: [string, string, string], what: string) => `${verbs[state === 'running' ? 0 : state === 'done' ? 1 : 2]} ${what}`; + if (call.name === 'delegate') { + const title = typeof args.title === 'string' ? args.title : 'a sub-agent'; + return said(['Working on', 'Done with', 'Failed'], title); + } if (call.name === 'list_connections') { return said(['Listing', 'Listed', 'Failed to list'], 'connections'); } @@ -101,33 +107,62 @@ function callTitle(call: ChatToolCall, state: CallState): string { ); } -// One call the agent made: a line that says what it did, opening to what it asked and what came back. -function ToolCallTrace({call, result}: {call: ChatToolCall; result: ChatEntry | undefined}) { +// One call the agent made: a line that says what it did, opening to what it asked and what came +// back. A delegation opens to the sub-agent's brief and its report, with a way to its activity. +function ToolCallTrace({ + call, + result, + subAgent, +}: { + call: ChatToolCall; + result: ChatEntry | undefined; + subAgent: ChatSubAgent | undefined; +}) { + const org = useOrgSlug(); const status = result === undefined ? 'running' : result.isError ? 'error' : undefined; const title = callTitle( call, result === undefined ? 'running' : result.isError ? 'failed' : 'done', ); + const args = parseArgs(call.arguments); + const delegated = call.name === 'delegate'; return ( - {call.name === 'sleep_until' ? : } + + {delegated ? : call.name === 'sleep_until' ? : } + {title} - -
{pretty(call.arguments)}
+ + {delegated ? ( +

+ {typeof args.brief === 'string' ? args.brief : pretty(call.arguments)} +

+ ) : ( +
{pretty(call.arguments)}
+ )} {result && (
-						{pretty(result.text)}
+						{delegated ? result.text : pretty(result.text)}
 					
)} + {subAgent && ( + + Activity of this sub-agent + + )}
); @@ -135,7 +170,15 @@ function ToolCallTrace({call, result}: {call: ChatToolCall; result: ChatEntry | // One turn of the conversation: what the person said in a bubble on the right, or what the agent // said as plain text on the left, with the calls it made as lines above it. -function Turn({entry, results}: {entry: ChatEntry; results: Map}) { +function Turn({ + entry, + results, + subAgents, +}: { + entry: ChatEntry; + results: Map; + subAgents: Map; +}) { if (entry.kind === 'person') { return ( @@ -154,7 +197,12 @@ function Turn({entry, results}: {entry: ChatEntry; results: Map 0 && ( {calls.map((call) => ( - + ))} )} @@ -198,8 +246,10 @@ function preview(last: ChatAgent['lastMessage']): string { // The last thing said in the open conversation, as the page has it, fresher than the list. function lastOf(log: ThreadLog): ChatAgent['lastMessage'] { - const last = log.entries.findLast((entry) => entry.kind !== 'tool_result' && entry.text !== ''); - if (!last || last.kind === 'tool_result') { + const last = log.entries.findLast( + (entry) => (entry.kind === 'person' || entry.kind === 'assistant') && entry.text !== '', + ); + if (!last || (last.kind !== 'person' && last.kind !== 'assistant')) { return null; } return {kind: last.kind, text: last.text, at: last.at}; @@ -282,8 +332,9 @@ export function ChatPage() { } // The person's conversation with one of the organization's agents: its log, read again as the -// agent works, and an input. The agent acts as the person in it, and its calls go through the -// same gate as any agent's, so its activity is in the log by the conversation's task. +// agent works, and an input. The agent hands each piece of work to a sub-agent, which acts as the +// person and whose calls go through the same gate as any agent's, so its activity is in the log +// by the sub-agent's task. function Conversation({loaded}: {loaded: Loaded}) { const org = useOrgSlug(); const navigate = useNavigate(); @@ -371,13 +422,27 @@ function Conversation({loaded}: {loaded: Loaded}) { return null; }; - // Each call's result by the call, folded into the call's line rather than shown as a turn. + // Each call's result by the call, folded into the call's line rather than shown as a turn. A + // delegation's is the sub-agent's report, an event, which comes after the call was answered as + // started: until then the call is under way, so that answer is left out, unless it refused. + const delegations = new Set( + log.entries.flatMap((entry) => + (entry.toolCalls ?? []).filter((call) => call.name === 'delegate').map((call) => call.id), + ), + ); const results = new Map( log.entries - .filter((entry) => entry.kind === 'tool_result') + .filter( + (entry) => + entry.kind === 'event' || + (entry.kind === 'tool_result' && (entry.isError || !delegations.has(entry.callId ?? ''))), + ) .map((entry) => [entry.callId, entry]), ); - const turns = log.entries.filter((entry) => entry.kind !== 'tool_result'); + const subAgents = new Map(log.subAgents.map((sub) => [sub.callId, sub])); + const turns = log.entries.filter( + (entry) => entry.kind === 'person' || entry.kind === 'assistant', + ); return (

What should {current.name} do?

- This conversation is yours alone. In it, {current.name} can use what is shared - with everyone and what you connected yourself, as far as its access allows. - Every call it makes is in the activity log. + 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.

)} @@ -445,7 +510,7 @@ function Conversation({loaded}: {loaded: Loaded}) { messageId={entry.id} scrollAnchor={entry.kind === 'person'} > - + ))} {(log.busy || log.sleepingUntil !== null) && ( @@ -478,10 +543,10 @@ function Conversation({loaded}: {loaded: Loaded}) { /> - Activity for this conversation + Activity of this agent Date: Tue, 27 Oct 2026 01:07:00 +0000 Subject: [PATCH 09/19] show a built-in agent's face wherever an agent's logo is shown The agent row and the agents api carry the face, null for any other agent, and the logo component draws the avatar for one, so the Agents page, the activity log, a task's card and page, the request dialog and the connection page tell personas apart as the Chat page does. --- packages/api/src/agents/agent-response.ts | 3 +++ packages/db/src/agent.ts | 5 +++++ packages/web/src/client/schemas.ts | 3 +++ .../web/src/components/activity-timeline.tsx | 2 +- .../web/src/components/agent-setup.test.tsx | 1 + .../web/src/components/audit-entry-dialog.tsx | 2 +- .../web/src/components/audit-list.test.tsx | 1 + packages/web/src/components/audit-list.tsx | 2 +- packages/web/src/components/brand-logo.tsx | 18 ++++++++++++++++-- .../web/src/components/connected-logos.tsx | 2 +- packages/web/src/pages/activity-page.tsx | 2 +- packages/web/src/pages/agent-page.tsx | 2 +- packages/web/src/pages/agents-page.tsx | 2 +- .../web/src/pages/connect-agent-page.test.tsx | 1 + packages/web/src/pages/connection-page.tsx | 2 +- packages/web/src/pages/home-page.test.tsx | 1 + packages/web/src/pages/task-page.tsx | 2 +- 17 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/api/src/agents/agent-response.ts b/packages/api/src/agents/agent-response.ts index 279a35fc..9beeccde 100644 --- a/packages/api/src/agents/agent-response.ts +++ b/packages/api/src/agents/agent-response.ts @@ -9,6 +9,8 @@ export type AgentResponse = { providerId: string; name: string; username: string; + // A built-in agent's face, shown in place of a logo; null for every other agent. + face: {shape: string; color: string} | null; // Shared with every member of its organization, rather than one member's own. shared: boolean; // Who made it, kept once it is shared; null for one from before it was kept. @@ -29,6 +31,7 @@ function toAgentResponse(row: AgentRow, createdBy: string | null): AgentResponse providerId: row.providerId, name: row.name, username: row.username, + face: row.shape !== null && row.color !== null ? {shape: row.shape, color: row.color} : null, shared: row.ownerUserId === null, createdById: row.createdById, createdBy, diff --git a/packages/db/src/agent.ts b/packages/db/src/agent.ts index adb767ca..ec2bb8dd 100644 --- a/packages/db/src/agent.ts +++ b/packages/db/src/agent.ts @@ -17,6 +17,9 @@ export type AgentRow = { providerId: string; name: string; username: string; + // A built-in agent's face (@proxy/utils/harness avatars); null for every other agent. + shape: string | null; + color: string | null; createdAt: Date; lastActiveAt: Date | null; revokedAt: Date | null; @@ -32,6 +35,8 @@ const agentSelect = { providerId: true, name: true, username: true, + shape: true, + color: true, createdAt: true, lastActiveAt: true, revokedAt: true, diff --git a/packages/web/src/client/schemas.ts b/packages/web/src/client/schemas.ts index 4851ca13..2f8eb104 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, AVATAR_SHAPES} 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. @@ -31,6 +32,8 @@ export const agentSchema = z.object({ providerId: z.string(), name: z.string(), username: z.string(), + // A built-in agent's face, shown in place of a logo; null for every other agent. + face: z.object({shape: z.enum(AVATAR_SHAPES), color: z.enum(AVATAR_COLORS)}).nullable(), // Shared with every member of its organization, rather than one member's own. shared: z.boolean(), // Who made it, kept once it is shared, and their name or address while they are still in its diff --git a/packages/web/src/components/activity-timeline.tsx b/packages/web/src/components/activity-timeline.tsx index d285ce0c..cc9fe0d5 100644 --- a/packages/web/src/components/activity-timeline.tsx +++ b/packages/web/src/components/activity-timeline.tsx @@ -483,7 +483,7 @@ function AgentLabel({ className={cn(className, 'hover:bg-muted')} style={style} > - + {agent.name} {aside} diff --git a/packages/web/src/components/agent-setup.test.tsx b/packages/web/src/components/agent-setup.test.tsx index 21c34875..5b08cb29 100644 --- a/packages/web/src/components/agent-setup.test.tsx +++ b/packages/web/src/components/agent-setup.test.tsx @@ -31,6 +31,7 @@ function agentLogin(id: string, name: string, providerId: AgentProviderId = 'cus providerId, name, username: `${name.toLowerCase().replace(/ /g, '-')}-k7q2`, + face: null, shared: false, createdById: 'user-1', createdBy: null, diff --git a/packages/web/src/components/audit-entry-dialog.tsx b/packages/web/src/components/audit-entry-dialog.tsx index 6c311467..ad5760c0 100644 --- a/packages/web/src/components/audit-entry-dialog.tsx +++ b/packages/web/src/components/audit-entry-dialog.tsx @@ -116,7 +116,7 @@ function AuditEntryContent({ )} {entry.agentId !== null && agent && ( <> - + {agent.name} diff --git a/packages/web/src/components/audit-list.test.tsx b/packages/web/src/components/audit-list.test.tsx index dc63007f..456f4c3b 100644 --- a/packages/web/src/components/audit-list.test.tsx +++ b/packages/web/src/components/audit-list.test.tsx @@ -16,6 +16,7 @@ const agent: AgentLogin = { providerId: 'claude', name: 'Claude', username: 'claude-1', + face: null, shared: false, createdById: 'user-1', createdBy: null, diff --git a/packages/web/src/components/audit-list.tsx b/packages/web/src/components/audit-list.tsx index ce70aae7..b346c031 100644 --- a/packages/web/src/components/audit-list.tsx +++ b/packages/web/src/components/audit-list.tsx @@ -36,7 +36,7 @@ function EntryAgentLogo({entry, agent}: {entry: AuditEntry; agent: AgentLogin | return ( } + logo={agent && } /> ); } diff --git a/packages/web/src/components/brand-logo.tsx b/packages/web/src/components/brand-logo.tsx index 56099691..4c12eedb 100644 --- a/packages/web/src/components/brand-logo.tsx +++ b/packages/web/src/components/brand-logo.tsx @@ -1,7 +1,9 @@ import {findAgentProvider} from '@proxy/integrations'; import {cn} from '@proxy/ui/lib/utils'; import {useState, type ReactNode} from 'react'; +import {Avatar} from '@/components/avatar'; import type {Integration} from '@/lib/integrations'; +import type {AgentLogin} from '@/lib/types'; // Logos sit on a white tile in both themes, so dark marks stay legible. They're decorative: the // text beside one always names it. @@ -19,9 +21,21 @@ function LogoTile({children, className}: {children: ReactNode; className?: strin ); } -// A custom MCP agent is connected through Personal Agent Proxy, so it gets our mark. -export function AgentLogo({providerId, className}: {providerId: string; className?: string}) { +// A built-in agent has a face in place of a logo, the one it has on the Chat page. A custom MCP +// agent is connected through Personal Agent Proxy, so it gets our mark. +export function AgentLogo({ + providerId, + face, + className, +}: { + providerId: string; + face?: AgentLogin['face']; + className?: string; +}) { const provider = findAgentProvider(providerId); + if (face) { + return ; + } if (provider?.custom) { return ( diff --git a/packages/web/src/components/connected-logos.tsx b/packages/web/src/components/connected-logos.tsx index 63be1e42..b750ac0d 100644 --- a/packages/web/src/components/connected-logos.tsx +++ b/packages/web/src/components/connected-logos.tsx @@ -74,7 +74,7 @@ export function ConnectedLogos({ > {active.slice(0, SHOWN).map((agent) => ( - + ))} diff --git a/packages/web/src/pages/activity-page.tsx b/packages/web/src/pages/activity-page.tsx index d0bfcd99..72f8fd70 100644 --- a/packages/web/src/pages/activity-page.tsx +++ b/packages/web/src/pages/activity-page.tsx @@ -140,7 +140,7 @@ function TaskCard({

{agent && ( - + {agent.name} )} diff --git a/packages/web/src/pages/agent-page.tsx b/packages/web/src/pages/agent-page.tsx index 17626ea0..bd56f29c 100644 --- a/packages/web/src/pages/agent-page.tsx +++ b/packages/web/src/pages/agent-page.tsx @@ -105,7 +105,7 @@ function AgentDetail({id}: {id: string}) { } > - + {agent.name} diff --git a/packages/web/src/pages/agents-page.tsx b/packages/web/src/pages/agents-page.tsx index 7224b928..3123c4e2 100644 --- a/packages/web/src/pages/agents-page.tsx +++ b/packages/web/src/pages/agents-page.tsx @@ -79,7 +79,7 @@ export function AgentsPage() { } + icon={} title={ {agent.name} diff --git a/packages/web/src/pages/connect-agent-page.test.tsx b/packages/web/src/pages/connect-agent-page.test.tsx index 50e31935..feda367c 100644 --- a/packages/web/src/pages/connect-agent-page.test.tsx +++ b/packages/web/src/pages/connect-agent-page.test.tsx @@ -16,6 +16,7 @@ const claude: AgentLogin = { providerId: 'claude', name: 'Claude', username: 'claude-k7q2', + face: null, shared: false, createdById: 'user-1', createdBy: null, diff --git a/packages/web/src/pages/connection-page.tsx b/packages/web/src/pages/connection-page.tsx index 0dcf69d1..4af6f754 100644 --- a/packages/web/src/pages/connection-page.tsx +++ b/packages/web/src/pages/connection-page.tsx @@ -214,7 +214,7 @@ export function ConnectionPage() { } + icon={} title={agent.name} cells={ diff --git a/packages/web/src/pages/home-page.test.tsx b/packages/web/src/pages/home-page.test.tsx index 9ed743dc..e4624559 100644 --- a/packages/web/src/pages/home-page.test.tsx +++ b/packages/web/src/pages/home-page.test.tsx @@ -35,6 +35,7 @@ const agent = (id: string, providerId: string, name: string, revokedAt: string | providerId, name, username: id, + face: null, shared: false, createdById: 'user-1', createdBy: null, diff --git a/packages/web/src/pages/task-page.tsx b/packages/web/src/pages/task-page.tsx index ff44e6a5..2dba3a04 100644 --- a/packages/web/src/pages/task-page.tsx +++ b/packages/web/src/pages/task-page.tsx @@ -130,7 +130,7 @@ function TaskRunPage({ } > - + {agent?.name ?? 'Deleted agent'} From 2368393f16113b17dde9cab4c06bed832e78f1e5 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:44:07 +0000 Subject: [PATCH 10/19] draw the built-in agents as heads, the head lab's, with the one being made looking at the pointer A persona is a color and a look now: the lab's six numbers for the nose and the eyes, kept as JSON in place of the shape, shuffled for a new persona, with the lab's first look for one without. The web app draws a head with three.js, loaded on its own the first time a head is drawn, on one renderer for the page: a still head is rendered once per look, color, pose and size and copied from then on, so lists and the activity log stay cheap. In the dialog the head faces the person and turns toward the pointer wherever it is, from the first frame, since the pointer's last place on the page is kept; the colors are round swatches in a grid under the name, with a shuffle and the way back to the look it opened with. In the sidebar the open conversation's head watches the pointer while it is over the sidebar, and settles back to rest, drawn no more, when it leaves. The top bar shows the name alone. --- bun.lock | 20 +- packages/api/src/agents/agent-response.ts | 9 +- packages/api/src/harness/built-in-agent.ts | 2 +- packages/api/src/harness/route.test.ts | 19 +- packages/api/src/harness/route.ts | 15 +- .../migration.sql | 2 +- packages/db/prisma/schema.prisma | 6 +- packages/db/src/agent.ts | 9 +- packages/db/src/harness.ts | 29 +- packages/utils/src/harness.test.ts | 28 +- packages/utils/src/harness.ts | 84 +++++- packages/web/package.json | 2 + packages/web/src/client/harness-client.ts | 6 +- packages/web/src/client/schemas.ts | 6 +- packages/web/src/components/avatar.tsx | 136 --------- packages/web/src/components/brand-logo.tsx | 6 +- packages/web/src/components/head.tsx | 189 +++++++++++++ .../web/src/components/persona-dialog.tsx | 119 ++++++-- packages/web/src/lib/head.ts | 264 ++++++++++++++++++ packages/web/src/loaders.ts | 3 +- packages/web/src/pages/chat-page.tsx | 28 +- 21 files changed, 746 insertions(+), 236 deletions(-) delete mode 100644 packages/web/src/components/avatar.tsx create mode 100644 packages/web/src/components/head.tsx create mode 100644 packages/web/src/lib/head.ts diff --git a/bun.lock b/bun.lock index 022c4380..963e7cc7 100644 --- a/bun.lock +++ b/bun.lock @@ -176,6 +176,7 @@ "react-plaid-link": "^5.0.0", "react-router": "^8.3.1", "tailwindcss": "^4.3.3", + "three": "0.170.0", "ts-results-es": "^7.1.0", "zod": "^4.5.4", }, @@ -185,6 +186,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", @@ -662,7 +664,7 @@ "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], - "@types/three": ["@types/three@0.186.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.3", "meshoptimizer": "~1.1.1" } }, "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ=="], + "@types/three": ["@types/three@0.170.0", "", { "dependencies": { "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.18.1" } }, "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -682,6 +684,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.152.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw=="], + "@webgpu/types": ["@webgpu/types@0.1.74", "", {}, "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA=="], + "@xyflow/react": ["@xyflow/react@12.12.0", "", { "dependencies": { "@xyflow/system": "0.0.83", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ=="], "@xyflow/system": ["@xyflow/system@0.0.83", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw=="], @@ -1266,7 +1270,7 @@ "meshline": ["meshline@3.3.1", "", { "peerDependencies": { "three": ">=0.137" } }, "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ=="], - "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], + "meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="], "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], @@ -1666,7 +1670,7 @@ "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], - "three": ["three@0.186.1", "", {}, "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA=="], + "three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="], "three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], @@ -1928,12 +1932,16 @@ "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="], + "stats-gl/@types/three": ["@types/three@0.186.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.3", "meshoptimizer": "~1.1.1" } }, "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ=="], "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], "three-stdlib/fflate": ["fflate@0.6.11", "", {}, "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg=="], + "website/@types/three": ["@types/three@0.186.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.3", "meshoptimizer": "~1.1.1" } }, "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ=="], + + "website/three": ["three@0.186.1", "", {}, "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA=="], + "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "@babel/core/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -2014,6 +2022,10 @@ "router/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "stats-gl/@types/three/meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], + + "website/@types/three/meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], + "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], "@modelcontextprotocol/sdk/express/body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], diff --git a/packages/api/src/agents/agent-response.ts b/packages/api/src/agents/agent-response.ts index 9beeccde..017fb84e 100644 --- a/packages/api/src/agents/agent-response.ts +++ b/packages/api/src/agents/agent-response.ts @@ -1,16 +1,19 @@ import type {Result} from 'ts-results-es'; import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils'; +import {type Look, parseLook} from '@proxy/utils/harness'; import type {AgentRow} from '@proxy/db/agent'; import {listMemberNames} from '@proxy/db/organization'; +import {BUILT_IN_AGENT_PROVIDER} from '@proxy/integrations'; export type AgentResponse = { id: string; providerId: string; name: string; username: string; - // A built-in agent's face, shown in place of a logo; null for every other agent. - face: {shape: string; color: string} | null; + // A built-in agent's head, shown in place of a logo, the lab's first where it was never given + // one; null for every other agent. + face: {look: Look; color: string} | null; // Shared with every member of its organization, rather than one member's own. shared: boolean; // Who made it, kept once it is shared; null for one from before it was kept. @@ -31,7 +34,7 @@ function toAgentResponse(row: AgentRow, createdBy: string | null): AgentResponse providerId: row.providerId, name: row.name, username: row.username, - face: row.shape !== null && row.color !== null ? {shape: row.shape, color: row.color} : null, + face: row.providerId === BUILT_IN_AGENT_PROVIDER.id ? {look: parseLook(row.look), color: row.color ?? 'blue'} : null, shared: row.ownerUserId === null, createdById: row.createdById, createdBy, diff --git a/packages/api/src/harness/built-in-agent.ts b/packages/api/src/harness/built-in-agent.ts index faca0f96..8a4ba9f8 100644 --- a/packages/api/src/harness/built-in-agent.ts +++ b/packages/api/src/harness/built-in-agent.ts @@ -21,7 +21,7 @@ export async function createBuiltInAgent(viewer: MemberViewer, persona: Persona) createdById: viewer.userId, providerId: BUILT_IN_AGENT_PROVIDER.id, name: persona.name, - shape: persona.shape, + look: JSON.stringify(persona.look), color: persona.color, username: `proxy-${randomBytes(6).toString('hex')}`, passwordHash: await Bun.password.hash(randomBytes(32).toString('base64url')), diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index eeaa05a1..67ddf0ce 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -23,8 +23,9 @@ const createBuiltInAgent = mock(async () => Ok({id: 'agent-2', createdById: 'use mock.module('./built-in-agent', () => ({createBuiltInAgent})); const now = new Date('2026-10-21T10:00:00Z'); -const agent = {id: 'agent-1', name: 'Finance', shape: 'circle', color: 'blue', createdById: 'user-1'}; -const agentResponse = {id: 'agent-1', name: 'Finance', shape: 'circle', color: 'blue', mayChange: true, lastMessage: null}; +const look = {nose: 0.48, noseOut: 0.35, eye: 27, eyeTheta: 55, eyeLat: 8, eyes: 2 as const}; +const agent = {id: 'agent-1', name: 'Finance', look, color: 'blue', createdById: 'user-1'}; +const agentResponse = {id: 'agent-1', name: 'Finance', look, color: 'blue', mayChange: true, lastMessage: null}; 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}; function request(params: Record = {}, body: unknown = undefined) { @@ -98,24 +99,24 @@ describe('the chat routes', () => { test('make an agent as the persona given, for everyone in the organization', async () => { const {handleCreateChatAgentRoute} = await import('./route'); - const made = await handleCreateChatAgentRoute(request({}, {name: ' Finance ', shape: 'circle', color: 'blue'})); + const made = await handleCreateChatAgentRoute(request({}, {name: ' Finance ', look, color: 'blue'})); - expect(createBuiltInAgent).toHaveBeenCalledWith(viewer, {name: 'Finance', shape: 'circle', color: 'blue'}); + expect(createBuiltInAgent).toHaveBeenCalledWith(viewer, {name: 'Finance', look, color: 'blue'}); expect(made.unwrap().agent).toEqual({...agentResponse, id: 'agent-2'}); }); - test('refuse a persona without a name, or with a shape or color outside the catalog', async () => { + test('refuse a persona without a name, with a look outside the lab’s range, or a color outside the catalog', async () => { const {handleCreateChatAgentRoute} = await import('./route'); - expect((await handleCreateChatAgentRoute(request({}, {name: ' ', shape: 'circle', color: 'blue'}))).unwrapErr().kind).toBe('parse_error'); - expect((await handleCreateChatAgentRoute(request({}, {name: 'Finance', shape: 'star', color: 'blue'}))).unwrapErr().kind).toBe('parse_error'); - expect((await handleCreateChatAgentRoute(request({}, {name: 'Finance', shape: 'circle', color: 'brown'}))).unwrapErr().kind).toBe('parse_error'); + expect((await handleCreateChatAgentRoute(request({}, {name: ' ', look, color: 'blue'}))).unwrapErr().kind).toBe('parse_error'); + expect((await handleCreateChatAgentRoute(request({}, {name: 'Finance', look: {...look, nose: 2}, color: 'blue'}))).unwrapErr().kind).toBe('parse_error'); + expect((await handleCreateChatAgentRoute(request({}, {name: 'Finance', look, color: 'brown'}))).unwrapErr().kind).toBe('parse_error'); expect(createBuiltInAgent).not.toHaveBeenCalled(); }); test('rename an agent or change its face, and delete one with every conversation, for whoever made it or owns the organization', async () => { const {handleDeleteChatAgentRoute, handleUpdateChatAgentRoute} = await import('./route'); - const persona = {name: 'Money', shape: 'diamond', color: 'green'}; + const persona = {name: 'Money', look: {...look, eyes: 1 as const}, color: 'green'}; const updated = await handleUpdateChatAgentRoute(request({agentId: 'agent-1'}, persona)); expect(updateChatAgent).toHaveBeenCalledWith('org-1', 'agent-1', persona); diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts index 00401d0e..41e2413c 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -2,7 +2,7 @@ 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, AVATAR_SHAPES, type ToolCall} from '@proxy/utils/harness'; +import {AVATAR_COLORS, type Look, lookSchema, type ToolCall} from '@proxy/utils/harness'; import {deleteAgent} from '@proxy/db/agent'; import { appendPersonMessage, @@ -37,7 +37,7 @@ import {createBuiltInAgent} from './built-in-agent'; const MAX_MESSAGE = 20_000; /** A built-in agent as the page lists it: its persona, 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 ChatAgentResponse = {id: string; name: string; shape: string; color: string; mayChange: boolean; lastMessage: {kind: 'person' | 'assistant'; text: string; at: string} | null}; +export type ChatAgentResponse = {id: string; name: string; look: Look; color: string; mayChange: boolean; lastMessage: {kind: 'person' | 'assistant'; text: string; at: string} | null}; export type EntryResponse = {id: string; kind: 'person' | 'assistant' | 'tool_result' | 'event'; text: string; toolCalls: ToolCall[] | null; callId: string | null; isError: boolean; at: string}; @@ -60,17 +60,18 @@ function toAgent(viewer: OrgViewer, agent: ChatAgentRow & {lastMessage?: LastMes return { id: agent.id, name: agent.name, - shape: agent.shape, + look: agent.look, color: agent.color, mayChange: managesAgent(viewer, agent), lastMessage: last && {kind: last.kind, text: last.text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_CHARS), at: last.createdAt.toISOString()}, }; } -// An agent's persona as the person makes or changes it: a name, and a face from the catalog. +// An agent's 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)), - shape: z.enum(AVATAR_SHAPES), + look: lookSchema, color: z.enum(AVATAR_COLORS), }); @@ -132,7 +133,7 @@ export function handleListChatAgentsRoute(request: AuthenticatedRequest): Promis }); } -/** `POST /api/orgs/:org/chat/agents` with `{name, shape, color}`: a new built-in agent, every member's. */ +/** `POST /api/orgs/:org/chat/agents` with `{name, look, color}`: a new built-in agent, every member's. */ export function handleCreateChatAgentRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { const viewer = $(await requireChat(request)); @@ -142,7 +143,7 @@ export function handleCreateChatAgentRoute(request: AuthenticatedRequest): Promi }); } -/** `PUT /api/orgs/:org/chat/agents/:agentId` with `{name, shape, color}`: the agent renamed, or with another face. */ +/** `PUT /api/orgs/:org/chat/agents/:agentId` with `{name, look, color}`: the agent renamed, or with another head. */ export function handleUpdateChatAgentRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { const viewer = $(await requireChat(request)); 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 index 70e3a139..987052fc 100644 --- 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 @@ -20,7 +20,7 @@ ALTER TYPE "AuditVia" ADD VALUE IF NOT EXISTS 'harness'; -- AlterTable ALTER TABLE "Agent" ADD COLUMN "color" TEXT, -ADD COLUMN "shape" TEXT; +ADD COLUMN "look" TEXT; -- CreateTable CREATE TABLE "AgentThread" ( diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 1352efb1..a8bef046 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -280,9 +280,9 @@ model Agent { // Who made it, kept once it is shared: who may change it beside the organization's owner. A plain // id rather than a relation, so a shared agent stays when they are gone. createdById String? - // A built-in agent's face on the Chat page: a shape and a color (`@proxy/utils/harness`); none - // for an agent that connects from outside. - shape String? + // A built-in agent's head on the Chat page: its look, as JSON (`@proxy/utils/harness` Look), and + // its color; none for an agent that connects from outside. + look String? color String? createdAt DateTime @default(now()) diff --git a/packages/db/src/agent.ts b/packages/db/src/agent.ts index ec2bb8dd..07498c48 100644 --- a/packages/db/src/agent.ts +++ b/packages/db/src/agent.ts @@ -17,8 +17,9 @@ export type AgentRow = { providerId: string; name: string; username: string; - // A built-in agent's face (@proxy/utils/harness avatars); null for every other agent. - shape: string | null; + // A built-in agent's head: its look as JSON (@proxy/utils/harness Look) and its color; null for + // every other agent. + look: string | null; color: string | null; createdAt: Date; lastActiveAt: Date | null; @@ -35,7 +36,7 @@ const agentSelect = { providerId: true, name: true, username: true, - shape: true, + look: true, color: true, createdAt: true, lastActiveAt: true, @@ -85,7 +86,7 @@ export async function createAgent(data: { username: string; passwordHash: string; // A built-in agent's face. - shape?: string; + look?: string; color?: string; }): Promise> { return wrapDb(() => db.agent.create({data, select: agentSelect})); diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index 2f83ece8..738a84a6 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -2,7 +2,13 @@ import type {AgentEntryKind, AgentJobKind, AgentJobStatus, Prisma} from '@prisma import {Err, Ok, type Result} from 'ts-results-es'; import {ApiErr, type ApiError, wrapDb} from '@proxy/utils'; -import {type LogEntry, readyForInference, type ToolCall} from '@proxy/utils/harness'; +import { + type Look, + type LogEntry, + parseLook, + readyForInference, + type ToolCall, +} from '@proxy/utils/harness'; import {TASK_ABANDONED_AFTER_MS} from '@proxy/utils/tasks'; import {db} from '.'; @@ -38,8 +44,8 @@ export type ThreadRow = { createdAt: Date; }; -/** What a built-in agent is shown as: its name and its face, a shape and a color. */ -export type Persona = {name: string; shape: string; color: string}; +/** What a built-in agent is shown as: its name and its head, a look and a color. */ +export type Persona = {name: string; look: Look; color: string}; /** A built-in agent as the Chat page lists it, with who made it, who may change it beside the organization's owner. */ export type ChatAgentRow = Persona & {id: string; createdById: string | null}; @@ -99,23 +105,23 @@ const threadSelect = { const chatAgentSelect = { id: true, name: true, - shape: true, + look: true, color: true, createdById: true, } as const; -// A built-in agent's face, as it was given one; a missing one, which shouldn't be, as plain as can be. +// A built-in agent's head, as it was given one; a missing one, which shouldn't be, the lab's first. function toChatAgent(agent: { id: string; name: string; - shape: string | null; + look: string | null; color: string | null; createdById: string | null; }): ChatAgentRow { return { id: agent.id, name: agent.name, - shape: agent.shape ?? 'circle', + look: parseLook(agent.look), color: agent.color ?? 'blue', createdById: agent.createdById, }; @@ -221,14 +227,19 @@ export async function getChatAgent( ).map((agent) => (agent ? toChatAgent(agent) : null)); } -/** Renames a built-in agent or changes its face; `Ok(false)` means no such agent in the organization. */ +/** Renames a built-in agent or changes its head; `Ok(false)` means no such agent in the organization. */ export async function updateChatAgent( orgId: string, agentId: string, persona: Persona, ): Promise> { return ( - await wrapDb(() => db.agent.updateMany({where: {id: agentId, orgId}, data: persona})) + await wrapDb(() => + db.agent.updateMany({ + where: {id: agentId, orgId}, + data: {name: persona.name, look: JSON.stringify(persona.look), color: persona.color}, + }), + ) ).map(({count}) => count > 0); } diff --git a/packages/utils/src/harness.test.ts b/packages/utils/src/harness.test.ts index eec37607..6e53729c 100644 --- a/packages/utils/src/harness.test.ts +++ b/packages/utils/src/harness.test.ts @@ -2,13 +2,14 @@ import {describe, expect, test} from 'bun:test'; import { AVATAR_COLORS, - AVATAR_SHAPES, + DEFAULT_LOOK, delegateArgs, isAvatarColor, - isAvatarShape, type LogEntry, + parseLook, pendingToolCalls, readyForInference, + shuffleLook, sleepUntil, } from './harness'; @@ -115,13 +116,26 @@ describe('sleepUntil', () => { }); }); -describe('avatars', () => { - test('are four shapes and eight colors, told from anything else', () => { - expect(AVATAR_SHAPES).toHaveLength(4); +describe('heads', () => { + test('are one of eight colors, told from anything else', () => { expect(AVATAR_COLORS).toHaveLength(8); - expect(isAvatarShape('circle')).toBe(true); - expect(isAvatarShape('star')).toBe(false); 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 index 7282b506..610348f7 100644 --- a/packages/utils/src/harness.ts +++ b/packages/utils/src/harness.ts @@ -1,3 +1,6 @@ +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 @@ -105,10 +108,11 @@ export function sleepUntil(args: unknown, now: Date): Date | null { } /** - * A built-in agent's avatar, one of four shapes in one of eight colors, which is all the person - * picks beside its name, such as Finance or Outreach. + * 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_SHAPES = ['circle', 'square', 'triangle', 'diamond'] as const; export const AVATAR_COLORS = [ 'red', 'orange', @@ -120,13 +124,77 @@ export const AVATAR_COLORS = [ 'pink', ] as const; -export type AvatarShape = (typeof AVATAR_SHAPES)[number]; export type AvatarColor = (typeof AVATAR_COLORS)[number]; -export function isAvatarShape(value: string): value is AvatarShape { - return (AVATAR_SHAPES as readonly string[]).includes(value); -} - 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, + }; +} diff --git a/packages/web/package.json b/packages/web/package.json index 168e3a92..17218a63 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -23,6 +23,7 @@ "react-plaid-link": "^5.0.0", "react-router": "^8.3.1", "tailwindcss": "^4.3.3", + "three": "0.170.0", "ts-results-es": "^7.1.0", "zod": "^4.5.4" }, @@ -32,6 +33,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 index 6c23fbba..04ae2e34 100644 --- a/packages/web/src/client/harness-client.ts +++ b/packages/web/src/client/harness-client.ts @@ -1,4 +1,4 @@ -import {AVATAR_COLORS, AVATAR_SHAPES} from '@proxy/utils/harness'; +import {AVATAR_COLORS, lookSchema} from '@proxy/utils/harness'; import {z} from 'zod'; import {apiRequest, apiSend, orgApi} from '@/client/request'; @@ -6,10 +6,10 @@ import {apiRequest, apiSend, orgApi} from '@/client/request'; // every member sees, the person's own conversation with each, its log, the sub-agents started from // it, and what they say in it. -// An agent's persona as the person makes or changes it. +// An agent's 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(), - shape: z.enum(AVATAR_SHAPES), + look: lookSchema, color: z.enum(AVATAR_COLORS), }); diff --git a/packages/web/src/client/schemas.ts b/packages/web/src/client/schemas.ts index 2f8eb104..75a7eac5 100644 --- a/packages/web/src/client/schemas.ts +++ b/packages/web/src/client/schemas.ts @@ -1,5 +1,5 @@ import {mcpToolSchema} from '@proxy/integrations'; -import {AVATAR_COLORS, AVATAR_SHAPES} from '@proxy/utils/harness'; +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. @@ -32,8 +32,8 @@ export const agentSchema = z.object({ providerId: z.string(), name: z.string(), username: z.string(), - // A built-in agent's face, shown in place of a logo; null for every other agent. - face: z.object({shape: z.enum(AVATAR_SHAPES), color: z.enum(AVATAR_COLORS)}).nullable(), + // A built-in agent's head, shown in place of a logo; null for every other agent. + face: z.object({look: lookSchema, color: z.enum(AVATAR_COLORS)}).nullable(), // Shared with every member of its organization, rather than one member's own. shared: z.boolean(), // Who made it, kept once it is shared, and their name or address while they are still in its diff --git a/packages/web/src/components/avatar.tsx b/packages/web/src/components/avatar.tsx deleted file mode 100644 index 42f95a4e..00000000 --- a/packages/web/src/components/avatar.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { - AVATAR_COLORS, - AVATAR_SHAPES, - type AvatarColor, - type AvatarShape, -} from '@proxy/utils/harness'; -import {cn} from '@proxy/ui/lib/utils'; - -// A persona's face: one of four shapes in one of eight colors, as the person picked them. The -// shapes are drawn to fill the same box, so a triangle sits as heavy as a circle beside it. - -const FILL: Record = { - red: 'fill-avatar-red', - orange: 'fill-avatar-orange', - yellow: 'fill-avatar-yellow', - green: 'fill-avatar-green', - teal: 'fill-avatar-teal', - blue: 'fill-avatar-blue', - purple: 'fill-avatar-purple', - pink: 'fill-avatar-pink', -}; - -const BACKGROUND: Record = { - red: 'bg-avatar-red', - orange: 'bg-avatar-orange', - yellow: 'bg-avatar-yellow', - green: 'bg-avatar-green', - teal: 'bg-avatar-teal', - blue: 'bg-avatar-blue', - purple: 'bg-avatar-purple', - pink: 'bg-avatar-pink', -}; - -const SIZES = {sm: 'size-5', md: 'size-7', lg: 'size-16'} as const; - -// Each shape in a 24-unit box, with a little air around it. -function Shape({shape}: {shape: AvatarShape}) { - if (shape === 'circle') { - return ; - } - if (shape === 'square') { - return ; - } - if (shape === 'triangle') { - return ; - } - return ; -} - -export function Avatar({ - shape, - color, - size = 'sm', - className, -}: { - shape: AvatarShape; - color: AvatarColor; - size?: keyof typeof SIZES; - className?: string; -}) { - return ( - - - - ); -} - -// Where the person picks a face: the four shapes in a row, in gray, so the shape is read on its -// own, then the eight colors in a row of the same width, with what is picked lit. -export function AvatarPicker({ - shape, - color, - onChange, -}: { - shape: AvatarShape; - color: AvatarColor; - onChange: (next: {shape: AvatarShape; color: AvatarColor}) => void; -}) { - const option = - 'flex items-center justify-center rounded-lg border transition-colors outline-none hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50'; - return ( -

-
- {AVATAR_SHAPES.map((candidate) => ( - - ))} -
-
- {AVATAR_COLORS.map((candidate) => ( - - ))} -
-
- ); -} diff --git a/packages/web/src/components/brand-logo.tsx b/packages/web/src/components/brand-logo.tsx index 4c12eedb..c609e54a 100644 --- a/packages/web/src/components/brand-logo.tsx +++ b/packages/web/src/components/brand-logo.tsx @@ -1,7 +1,7 @@ import {findAgentProvider} from '@proxy/integrations'; import {cn} from '@proxy/ui/lib/utils'; import {useState, type ReactNode} from 'react'; -import {Avatar} from '@/components/avatar'; +import {Head} from '@/components/head'; import type {Integration} from '@/lib/integrations'; import type {AgentLogin} from '@/lib/types'; @@ -21,7 +21,7 @@ function LogoTile({children, className}: {children: ReactNode; className?: strin ); } -// A built-in agent has a face in place of a logo, the one it has on the Chat page. A custom MCP +// A built-in agent has its head in place of a logo, the one it has on the Chat page. A custom MCP // agent is connected through Personal Agent Proxy, so it gets our mark. export function AgentLogo({ providerId, @@ -34,7 +34,7 @@ export function AgentLogo({ }) { const provider = findAgentProvider(providerId); if (face) { - return ; + return ; } if (provider?.custom) { return ( diff --git a/packages/web/src/components/head.tsx b/packages/web/src/components/head.tsx new file mode 100644 index 00000000..87fc28d9 --- /dev/null +++ b/packages/web/src/components/head.tsx @@ -0,0 +1,189 @@ +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. +let pointer: {x: number; y: number} | null = null; +if (typeof window !== 'undefined') { + const note = (event: PointerEvent) => { + pointer = {x: event.clientX, y: event.clientY}; + }; + 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 and stays, drawn no more + * until there is something to follow again. + */ +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. + 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 = + !watch.current && + 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; + tick(); + }; + wake.current(); + }); + return () => { + current = false; + wake.current = null; + cancelAnimationFrame(frame); + }; + }, [size]); + return ( + + ); +} diff --git a/packages/web/src/components/persona-dialog.tsx b/packages/web/src/components/persona-dialog.tsx index f8e12543..d952eaa0 100644 --- a/packages/web/src/components/persona-dialog.tsx +++ b/packages/web/src/components/persona-dialog.tsx @@ -1,7 +1,9 @@ +import {RotateCcwIcon, ShuffleIcon} from 'lucide-react'; import {useState} from 'react'; import type {Persona} from '@/client/harness-client'; import {AGENT_NAME_MAX} from '@proxy/integrations'; -import {Avatar, AvatarPicker} from '@/components/avatar'; +import {AVATAR_COLORS, type AvatarColor, shuffleLook} from '@proxy/utils/harness'; +import {LiveHead} from '@/components/head'; import {Button} from '@proxy/ui/components/button'; import { Dialog, @@ -13,6 +15,7 @@ import { } from '@proxy/ui/components/dialog'; import {Input} from '@proxy/ui/components/input'; import {Label} from '@proxy/ui/components/label'; +import {cn} from '@proxy/ui/lib/utils'; type Props = { open: boolean; @@ -25,12 +28,37 @@ type Props = { onDelete?: () => Promise; }; -const DEFAULT: Persona = {name: '', shape: 'circle', color: 'blue'}; +// The colors as the stylesheet has them, for the swatches. +const BACKGROUND: Record = { + red: 'bg-avatar-red', + orange: 'bg-avatar-orange', + yellow: 'bg-avatar-yellow', + green: 'bg-avatar-green', + teal: 'bg-avatar-teal', + blue: 'bg-avatar-blue', + purple: 'bg-avatar-purple', + pink: 'bg-avatar-pink', +}; + +// A new persona starts with a look of its own, and a color drawn from the eight. +function fresh(): Persona { + return { + name: '', + look: shuffleLook(), + color: AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] ?? 'blue', + }; +} + +// A round button the size of a swatch, for the colors and the two about the look. +const ROUND = + 'flex size-9 shrink-0 items-center justify-center rounded-full transition-[box-shadow,background-color] outline-none focus-visible:ring-3 focus-visible:ring-ring/50'; -// Making a persona, or changing one: its name, and its face from the shapes and colors, shown -// as it will be beside the name. Mount it keyed by what is edited, so the fields start from it. +// Making a persona, or changing one: its name, and its head, which looks at the cursor as the +// person works, in one of the colors, with another look a click away and the way back to the one +// it opened with. Mount it keyed by what is edited, so the fields start from it. export function PersonaDialog({open, initial, onClose, onSave, onDelete}: Props) { - const [persona, setPersona] = useState(initial ?? DEFAULT); + const [opened] = useState(() => initial ?? fresh()); + const [persona, setPersona] = useState(opened); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const ready = persona.name.trim() !== ''; @@ -69,30 +97,73 @@ export function PersonaDialog({open, initial, onClose, onSave, onDelete}: Props) {initial ? 'Edit agent' : 'New agent'} {initial - ? 'Its name and its face. The conversation stays as it is.' + ? 'Its name and its head. The conversation stays as it is.' : 'An agent for one kind of thing, such as Finance or Outreach, with a conversation that keeps going.'} -
- -
- - setPersona({...persona, name: event.target.value})} - /> +
+ +
+
+ + setPersona({...persona, name: event.target.value})} + /> +
+
+
+ {AVATAR_COLORS.map((candidate) => ( +
+ + +
- setPersona({...persona, ...face})} - /> {error &&

{error}

} {onDelete && ( diff --git a/packages/web/src/lib/head.ts b/packages/web/src/lib/head.ts new file mode 100644 index 00000000..519181a5 --- /dev/null +++ b/packages/web/src/lib/head.ts @@ -0,0 +1,264 @@ +import {type AvatarColor, isAvatarColor, type Look} from '@proxy/utils/harness'; + +/** + * A persona's head, drawn as the head lab draws it (design/head-lab): a sphere in the persona's + * color with the eyes painted on, and a darker sphere for the nose, unlit, under a perspective + * camera. One three.js renderer serves every head on the page, loaded the first time a head is + * drawn, as its own chunk: a head is rendered into the renderer's canvas and copied into its own. + * A still head is rendered once per look, color, pose and size, and copied from then on; a live + * one is rendered every frame. + */ + +type Three = typeof import('three'); + +/** How a head is turned: a nod about x, a turn about y and a tilt about z, in degrees, as the lab has them. */ +export type Pose = {rotX: number; rotY: number; rotZ: number}; + +/** The pose of the lab's first look: turned three-quarters toward you. Every still head takes it. */ +export const REST: Pose = {rotX: -3, rotY: -65, rotZ: -12}; + +/** Looking straight at you. */ +export const FACING: Pose = {rotX: 0, rotY: -90, rotZ: 0}; + +const DEG = Math.PI / 180; +// How much darker than the head the nose is, in OKLCH lightness, and how the head fills its box. +const NOSE_DARK = 0.22; +const ZOOM = 1.25; +// The lab's palette, as OKLCH, the same hues as the stylesheet's avatar colors. +const HUES: Record = { + red: [0.64, 0.21, 25], + orange: [0.72, 0.17, 55], + yellow: [0.8, 0.16, 90], + green: [0.68, 0.17, 150], + teal: [0.7, 0.13, 195], + blue: [0.62, 0.19, 255], + purple: [0.62, 0.2, 300], + pink: [0.7, 0.19, 350], +}; +// How many rendered heads are kept. +const CACHE_MAX = 300; + +function oklchToHex(L: number, C: number, h: number): string { + const a = C * Math.cos(h * DEG); + const b = C * Math.sin(h * DEG); + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.291485548 * b; + const l = l_ ** 3; + const m = m_ ** 3; + const s = s_ ** 3; + const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + const bb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; + const gamma = (x: number) => { + const clamped = Math.max(0, Math.min(1, x)); + return clamped <= 0.0031308 ? 12.92 * clamped : 1.055 * clamped ** (1 / 2.4) - 0.055; + }; + return `#${[r, g, bb] + .map((x) => + Math.round(gamma(x) * 255) + .toString(16) + .padStart(2, '0'), + ) + .join('')}`; +} + +function hues(color: string): [number, number, number] { + return HUES[isAvatarColor(color) ? color : 'blue']; +} + +/** The head's color as a hex, for anything drawn beside it. */ +export function hexOf(color: string): string { + return oklchToHex(...hues(color)); +} + +function noseOf(color: string): string { + const [L, C, h] = hues(color); + return oklchToHex(L - NOSE_DARK, C * 0.9, h); +} + +/** What makes one head: its look and its color. */ +export type HeadSpec = {look: Look; color: string}; + +type Stage = { + three: Three; + renderer: import('three').WebGLRenderer; + scene: import('three').Scene; + camera: import('three').PerspectiveCamera; + head: import('three').Mesh; + nose: import('three').Mesh; + group: import('three').Group; + outer: import('three').Group; + textures: Map; + rendered: Map; +}; + +let loading: Promise | null = null; + +// Builds the stage once: the renderer, the two spheres and the camera, as the lab has them. Null +// where there is no WebGL, as in tests, so a head is left blank rather than thrown on. +async function build(): Promise { + if (typeof window === 'undefined' || typeof WebGLRenderingContext === 'undefined') { + return null; + } + const three = await import('three'); + try { + const renderer = new three.WebGLRenderer({ + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }); + renderer.setClearColor(0, 0); + const sphere = new three.SphereGeometry(1, 96, 64); + const camera = new three.PerspectiveCamera(35, 1, 0.1, 100); + camera.position.z = 7; + const scene = new three.Scene(); + const head = new three.Mesh(sphere, new three.MeshBasicMaterial()); + const nose = new three.Mesh(sphere, new three.MeshBasicMaterial()); + const group = new three.Group(); + group.add(head); + group.add(nose); + const outer = new three.Group(); + outer.add(group); + scene.add(outer); + return { + three, + renderer, + scene, + camera, + head, + nose, + group, + outer, + textures: new Map(), + rendered: new Map(), + }; + } catch { + return null; + } +} + +/** The stage every head is drawn on, loaded the first time; null where heads can't be drawn. */ +export function loadStage(): Promise { + if (loading === null) { + loading = build(); + } + return loading; +} + +// The head's skin: its color, with the eyes painted where the look puts them, as the lab paints it. +function skin(stage: Stage, spec: HeadSpec): import('three').CanvasTexture { + const key = JSON.stringify([ + spec.color, + spec.look.eye, + spec.look.eyeTheta, + spec.look.eyeLat, + spec.look.eyes, + ]); + const cached = stage.textures.get(key); + if (cached) { + return cached; + } + const width = 2048; + const height = 1024; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('No 2d context for a head'); + } + context.fillStyle = hexOf(spec.color); + context.fillRect(0, 0, width, height); + const v = 0.5 + (spec.look.eyeLat * DEG) / Math.PI; + const radius = (height / Math.PI) * spec.look.eye * DEG; + const sides = spec.look.eyes === 1 ? [-1] : [-1, 1]; + for (const side of sides) { + const phi = Math.PI + side * spec.look.eyeTheta * DEG; + const u = phi / (2 * Math.PI); + context.fillStyle = '#ffffff'; + context.beginPath(); + context.ellipse( + u * width, + (1 - v) * height, + radius / Math.cos(spec.look.eyeLat * DEG), + radius, + 0, + 0, + Math.PI * 2, + ); + context.fill(); + } + const texture = new stage.three.CanvasTexture(canvas); + texture.colorSpace = stage.three.SRGBColorSpace; + texture.anisotropy = 8; + if (stage.textures.size > 50) { + for (const old of stage.textures.values()) { + old.dispose(); + } + stage.textures.clear(); + } + stage.textures.set(key, texture); + return texture; +} + +// Renders the head into the renderer's canvas, `px` wide and high. +function render(stage: Stage, spec: HeadSpec, pose: Pose, px: number): HTMLCanvasElement { + stage.renderer.setSize(px, px, false); + stage.head.material.map = skin(stage, spec); + stage.head.material.needsUpdate = true; + stage.nose.material.color.set(noseOf(spec.color)); + stage.nose.scale.setScalar(spec.look.nose); + stage.nose.position.set(1 + spec.look.noseOut * spec.look.nose, 0, 0); + stage.group.rotation.set(pose.rotX * DEG, pose.rotY * DEG, pose.rotZ * DEG); + stage.outer.scale.setScalar(ZOOM); + stage.renderer.render(stage.scene, stage.camera); + return stage.renderer.domElement; +} + +// The head rendered once, kept as a canvas of its own for every other time it is drawn. +function still(stage: Stage, spec: HeadSpec, pose: Pose, px: number): HTMLCanvasElement { + const key = JSON.stringify([spec, pose, px]); + const cached = stage.rendered.get(key); + if (cached) { + return cached; + } + const source = render(stage, spec, pose, px); + const copy = document.createElement('canvas'); + copy.width = px; + copy.height = px; + copy.getContext('2d')?.drawImage(source, 0, 0); + if (stage.rendered.size >= CACHE_MAX) { + stage.rendered.clear(); + } + stage.rendered.set(key, copy); + return copy; +} + +/** + * Draws the head on `target`, `px` wide and high: a still one from the cache, a live one rendered + * now. + */ +export function paintHead( + stage: Stage, + target: HTMLCanvasElement, + spec: HeadSpec, + pose: Pose, + px: number, + live: boolean, +): void { + const source = live ? render(stage, spec, pose, px) : still(stage, spec, pose, px); + if (target.width !== px || target.height !== px) { + target.width = px; + target.height = px; + } + const context = target.getContext('2d'); + context?.clearRect(0, 0, px, px); + context?.drawImage(source, 0, 0); +} + +/** How many pixels a head `size` CSS pixels wide is drawn at, for the screen it is on. */ +export function pixels(size: number): number { + const ratio = typeof window === 'undefined' ? 1 : Math.min(window.devicePixelRatio || 1, 2); + return Math.round(size * ratio); +} diff --git a/packages/web/src/loaders.ts b/packages/web/src/loaders.ts index 522349e0..fd2ac42f 100644 --- a/packages/web/src/loaders.ts +++ b/packages/web/src/loaders.ts @@ -1,3 +1,4 @@ +import {DEFAULT_LOOK} from '@proxy/utils/harness'; import {getSignInMethods} from '@/client/auth-client'; import { type ActivityFilter, @@ -120,7 +121,7 @@ export async function chatIndexLoader(args: OrgParams) { throw redirect(`/${org}/chat/${first.id}`); } const {agent} = unwrapLoaderResult( - await createChatAgent(org, {name: 'General', shape: 'circle', color: 'blue'}), + await createChatAgent(org, {name: 'General', look: DEFAULT_LOOK, color: 'blue'}), ); throw redirect(`/${org}/chat/${agent.id}`); } diff --git a/packages/web/src/pages/chat-page.tsx b/packages/web/src/pages/chat-page.tsx index b3570a8c..86e2df97 100644 --- a/packages/web/src/pages/chat-page.tsx +++ b/packages/web/src/pages/chat-page.tsx @@ -16,7 +16,7 @@ import { updateChatAgent, } from '@/client/harness-client'; import {AppShell} from '@/components/app-shell'; -import {Avatar} from '@/components/avatar'; +import {Head, LiveHead} from '@/components/head'; import {PersonaDialog} from '@/components/persona-dialog'; import {Bubble, BubbleContent} from '@proxy/ui/components/bubble'; import { @@ -255,8 +255,9 @@ function lastOf(log: ThreadLog): ChatAgent['lastMessage'] { return {kind: last.kind, text: last.text, at: last.at}; } -// The organization's agents in the second sidebar, each with its face, its name and the last -// thing said between it and the person, the open one lit, and a row at the bottom to make a new one. +// The organization's agents in the second sidebar, each with its head, its name and the last +// thing said between it and the person, the open one lit, its head watching the pointer while it +// is over the sidebar, and a row at the bottom to make a new one. function AgentList({ agents, current, @@ -269,8 +270,13 @@ function AgentList({ onNew: () => void; }) { const org = useOrgSlug(); + const [watching, setWatching] = useState(false); return ( -
+
setWatching(true)} + onPointerLeave={() => setWatching(false)} + >
{agents.length === 0 && ( No agents yet. @@ -288,7 +294,11 @@ function AgentList({ agent.id === current && 'bg-sidebar-accent text-sidebar-accent-foreground', )} > - + {agent.id === current ? ( + + ) : ( + + )} @@ -453,13 +463,11 @@ function Conversation({loaded}: {loaded: Loaded}) { onClick={() => setEditing('current')} className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50" > - {current.name} ) : ( - - + {current.name} ) @@ -482,7 +490,7 @@ function Conversation({loaded}: {loaded: Loaded}) { initial={ editing === 'new' ? null - : {name: current.name, shape: current.shape, color: current.color} + : {name: current.name, look: current.look, color: current.color} } onClose={() => setEditing(null)} onSave={savePersona} @@ -495,7 +503,7 @@ function Conversation({loaded}: {loaded: Loaded}) { {turns.length === 0 && (
- +

What should {current.name} do?

This conversation is yours alone. {current.name} hands each piece of work to a From d6799bed14d6876bbc28354aa77005aca039f8b5 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:26:42 +0000 Subject: [PATCH 11/19] make the personas of the Chat page one app on the Agent permissions page, and say which persona a task was A persona is a row of its own now, named and given a head, and every persona acts through the organization's one built-in agent login, Chat, made the first time Chat is used and shared with everyone: its permissions are every persona's, as an outside app's are every assistant's inside it, since a login is the identity we can verify and a persona is not. The login is neither revoked nor deleted, and nothing signs in with it, so its row says what it is instead of a username. The Agents tab is Agent permissions. Which persona did a piece of work is on the task: a sub-agent's task records the persona it ran as, and the activity log, a task's card and its page show the persona's head and name for it, the app's logo for any other task. --- packages/api/src/activity/route.test.ts | 7 + packages/api/src/activity/route.ts | 31 +++- packages/api/src/agent-side/tasks.test.ts | 1 + packages/api/src/agents/agent-response.ts | 6 - packages/api/src/agents/route.test.ts | 10 ++ packages/api/src/agents/route.ts | 14 +- packages/api/src/harness/built-in-agent.ts | 45 +++--- packages/api/src/harness/route.test.ts | 153 ++++++++++-------- packages/api/src/harness/route.ts | 137 ++++++++-------- packages/api/src/harness/thread-task.ts | 11 +- packages/api/src/server/create-api-router.ts | 14 +- .../migration.sql | 41 +++-- packages/db/prisma/schema.prisma | 41 ++++- packages/db/src/agent.ts | 26 +-- packages/db/src/harness.ts | 144 +++++++++++------ packages/db/src/task.ts | 5 + packages/docs/content/agents/chat.mdx | 9 +- packages/docs/content/organizations.mdx | 2 +- packages/docs/src/nav.ts | 2 +- packages/web/src/client/harness-client.ts | 49 +++--- packages/web/src/client/schemas.ts | 6 +- .../web/src/components/activity-timeline.tsx | 2 +- .../web/src/components/agent-setup.test.tsx | 1 - .../web/src/components/audit-entry-dialog.tsx | 2 +- .../web/src/components/audit-list.test.tsx | 2 +- packages/web/src/components/audit-list.tsx | 26 ++- packages/web/src/components/brand-logo.tsx | 18 +-- .../web/src/components/connected-logos.tsx | 2 +- packages/web/src/components/nav.tsx | 2 +- .../web/src/lib/activity-timeline.test.ts | 1 + packages/web/src/lib/run-timeline.test.ts | 7 + packages/web/src/lib/timeline-window.test.ts | 3 + packages/web/src/loaders.ts | 20 +-- packages/web/src/pages/activity-page.tsx | 13 +- packages/web/src/pages/agent-page.tsx | 20 ++- packages/web/src/pages/agents-page.tsx | 18 ++- packages/web/src/pages/chat-page.tsx | 99 ++++++------ .../web/src/pages/connect-agent-page.test.tsx | 1 - packages/web/src/pages/connection-page.tsx | 2 +- packages/web/src/pages/home-page.test.tsx | 3 +- packages/web/src/pages/task-page.tsx | 18 ++- 41 files changed, 634 insertions(+), 380 deletions(-) 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 44eb5bce..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'; @@ -46,6 +48,9 @@ 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; @@ -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 { 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', async () => { + const {handleDeleteAgentRoute, 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(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..e1dc41eb 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'; @@ -111,12 +112,22 @@ 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 +140,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 index 8a4ba9f8..03f0f2d6 100644 --- a/packages/api/src/harness/built-in-agent.ts +++ b/packages/api/src/harness/built-in-agent.ts @@ -2,28 +2,35 @@ import {randomBytes} from 'node:crypto'; import type {Result} from 'ts-results-es'; -import type {ApiError} from '@proxy/utils'; -import {type AgentRow, createAgent} from '@proxy/db/agent'; -import type {Persona} from '@proxy/db/harness'; -import type {MemberViewer} from '@proxy/db/viewer'; +import {type ApiError, Do} from '@proxy/utils'; +import {type AgentRow, createAgent, getSharedAgentByProvider} from '@proxy/db/agent'; import {BUILT_IN_AGENT_PROVIDER} from '@proxy/integrations'; /** - * A new built-in agent of the organization, shared with every member of it, made by the viewer: - * an Agent like any other, with grants and an activity log of its own, so what it may do on the - * shared connections is set where it is set for Claude. 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. + * 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, 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 async function createBuiltInAgent(viewer: MemberViewer, persona: Persona): Promise> { - return createAgent({ - orgId: viewer.orgId, - ownerUserId: null, - createdById: viewer.userId, - providerId: BUILT_IN_AGENT_PROVIDER.id, - name: persona.name, - look: JSON.stringify(persona.look), - color: persona.color, - username: `proxy-${randomBytes(6).toString('hex')}`, - passwordHash: await Bun.password.hash(randomBytes(32).toString('base64url')), +export function ensureBuiltInAgent(orgId: string): Promise> { + return Do(async ($) => { + const existing = $(await getSharedAgentByProvider(orgId, BUILT_IN_AGENT_PROVIDER.id)); + if (existing) { + return existing; + } + return $( + await createAgent({ + orgId, + ownerUserId: null, + createdById: null, + providerId: BUILT_IN_AGENT_PROVIDER.id, + name: 'Chat', + username: `proxy-${randomBytes(6).toString('hex')}`, + passwordHash: await Bun.password.hash(randomBytes(32).toString('base64url')), + }), + ); }); } diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index 67ddf0ce..335b69a1 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -1,32 +1,55 @@ import {beforeEach, describe, expect, mock, test} from 'bun:test'; import {Ok} from 'ts-results-es'; -const listChatAgents = mock(); -const getChatAgent = mock(); -const updateChatAgent = mock(); +const listPersonas = mock(); +const getPersona = mock(); +const createPersona = mock(); +const updatePersona = mock(); +const deletePersona = mock(async () => Ok(true)); const getMemberThread = mock(); const ensureMemberThread = mock(); const listEntries = mock(); const listOpenJobs = mock(); const listSubAgents = mock(); const appendPersonMessage = mock(); -mock.module('@proxy/db/harness', () => ({listChatAgents, getChatAgent, updateChatAgent, getMemberThread, ensureMemberThread, listEntries, listOpenJobs, listSubAgents, appendPersonMessage})); - -const deleteAgent = mock(async () => Ok(true)); -mock.module('@proxy/db/agent', () => ({deleteAgent})); +mock.module('@proxy/db/harness', () => ({ + listPersonas, + getPersona, + createPersona, + updatePersona, + deletePersona, + getMemberThread, + ensureMemberThread, + listEntries, + listOpenJobs, + listSubAgents, + 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 createBuiltInAgent = mock(async () => Ok({id: 'agent-2', createdById: 'user-1'})); -mock.module('./built-in-agent', () => ({createBuiltInAgent})); +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 agent = {id: 'agent-1', name: 'Finance', look, color: 'blue', createdById: 'user-1'}; -const agentResponse = {id: 'agent-1', name: 'Finance', look, color: 'blue', mayChange: true, lastMessage: null}; -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 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; @@ -35,9 +58,10 @@ function request(params: Record = {}, body: unknown = undefined) beforeEach(() => { mock.clearAllMocks(); requireViewer.mockResolvedValue(Ok(viewer)); - listChatAgents.mockResolvedValue(Ok([{...agent, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}}])); - getChatAgent.mockResolvedValue(Ok(agent)); - updateChatAgent.mockResolvedValue(Ok(true)); + 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)); listEntries.mockResolvedValue( @@ -68,106 +92,109 @@ beforeEach(() => { describe('the chat routes', () => { test('are refused without the experiment', async () => { - const {handleListChatAgentsRoute, handlePostMessageRoute} = await import('./route'); + const {handleListPersonasRoute, handlePostMessageRoute} = await import('./route'); requireViewer.mockResolvedValue(Ok({...viewer, experiments: []})); - expect((await handleListChatAgentsRoute(request())).unwrapErr().kind).toBe('forbidden'); - expect((await handlePostMessageRoute(request({agentId: 'agent-1'}, {text: 'Hi'}))).unwrapErr().kind).toBe('forbidden'); + 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 agents, with their faces, whether the viewer may change each, and the last thing said with each, on one line', async () => { - const {handleListChatAgentsRoute} = await import('./route'); - listChatAgents.mockResolvedValue( + 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([ - {...agent, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}}, - {...agent, id: 'agent-3', createdById: 'user-2', lastMessage: null}, + {...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}}, + {...persona, id: 'persona-3', createdById: 'user-2', lastMessage: null}, ]), ); - const listed = (await handleListChatAgentsRoute(request())).unwrap(); + const listed = (await handleListPersonasRoute(request())).unwrap(); - expect(listChatAgents).toHaveBeenCalledWith('org-1', 'user-1', 'proxy'); + expect(listPersonas).toHaveBeenCalledWith('org-1', 'user-1'); expect(listed).toEqual({ - agents: [ - {...agentResponse, lastMessage: {kind: 'assistant', text: 'Your vault has one entry.', at: '2026-10-21T10:00:00.000Z'}}, - {...agentResponse, id: 'agent-3', mayChange: false}, + personas: [ + {...personaResponse, lastMessage: {kind: 'assistant', text: 'Your vault has one entry.', at: '2026-10-21T10:00:00.000Z'}}, + {...personaResponse, id: 'persona-3', mayChange: false}, ], }); }); - test('make an agent as the persona given, for everyone in the organization', async () => { - const {handleCreateChatAgentRoute} = await import('./route'); + 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 handleCreateChatAgentRoute(request({}, {name: ' Finance ', look, color: 'blue'})); + const made = await handleCreatePersonaRoute(request({}, {name: ' Finance ', look, color: 'blue'})); - expect(createBuiltInAgent).toHaveBeenCalledWith(viewer, {name: 'Finance', look, color: 'blue'}); - expect(made.unwrap().agent).toEqual({...agentResponse, id: 'agent-2'}); + 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 {handleCreateChatAgentRoute} = await import('./route'); + const {handleCreatePersonaRoute} = await import('./route'); - expect((await handleCreateChatAgentRoute(request({}, {name: ' ', look, color: 'blue'}))).unwrapErr().kind).toBe('parse_error'); - expect((await handleCreateChatAgentRoute(request({}, {name: 'Finance', look: {...look, nose: 2}, color: 'blue'}))).unwrapErr().kind).toBe('parse_error'); - expect((await handleCreateChatAgentRoute(request({}, {name: 'Finance', look, color: 'brown'}))).unwrapErr().kind).toBe('parse_error'); - expect(createBuiltInAgent).not.toHaveBeenCalled(); + 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 an agent or change its face, and delete one with every conversation, for whoever made it or owns the organization', async () => { - const {handleDeleteChatAgentRoute, handleUpdateChatAgentRoute} = await import('./route'); - const persona = {name: 'Money', look: {...look, eyes: 1 as const}, color: 'green'}; + 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 handleUpdateChatAgentRoute(request({agentId: 'agent-1'}, persona)); - expect(updateChatAgent).toHaveBeenCalledWith('org-1', 'agent-1', persona); - expect(updated.unwrap().agent).toEqual({...agentResponse, ...persona}); + const updated = await handleUpdatePersonaRoute(request({personaId: 'persona-1'}, changed)); + expect(updatePersona).toHaveBeenCalledWith('org-1', 'persona-1', changed); + expect(updated.unwrap().persona).toEqual({...personaResponse, ...changed}); - getChatAgent.mockResolvedValue(Ok({...agent, createdById: 'user-2'})); - expect((await handleUpdateChatAgentRoute(request({agentId: 'agent-1'}, persona))).unwrapErr().kind).toBe('forbidden'); - expect((await handleDeleteChatAgentRoute(request({agentId: 'agent-1'}))).unwrapErr().kind).toBe('forbidden'); - expect(deleteAgent).not.toHaveBeenCalled(); + 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 handleDeleteChatAgentRoute(request({agentId: 'agent-1'}))).isOk()).toBe(true); - expect(deleteAgent).toHaveBeenCalledWith('org-1', 'agent-1'); + expect((await handleDeletePersonaRoute(request({personaId: 'persona-1'}))).isOk()).toBe(true); + expect(deletePersona).toHaveBeenCalledWith('org-1', 'persona-1'); - getChatAgent.mockResolvedValue(Ok(null)); - expect((await handleDeleteChatAgentRoute(request({agentId: 'nope'}))).unwrapErr()).toMatchObject({kind: 'not_found'}); + getPersona.mockResolvedValue(Ok(null)); + expect((await handleDeletePersonaRoute(request({personaId: 'nope'}))).unwrapErr()).toMatchObject({kind: 'not_found'}); }); - test('give the viewer’s conversation with the agent, begun where there was none, without its summaries, with its sub-agents and whether the agent is busy or asleep', async () => { + test('give the viewer’s conversation with the persona, begun through the built-in login where there was none, without its summaries, with its sub-agents and 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({agentId: 'agent-1'}))).unwrap(); - expect(idle.agent).toEqual(agentResponse); + const idle = (await handleGetThreadRoute(request({personaId: 'persona-1'}))).unwrap(); + expect(idle.persona).toEqual(personaResponse); expect(idle.thread).toEqual({id: 'thread-1'}); expect(idle.entries).toEqual([{id: 'e1', kind: 'person', text: 'Hi', toolCalls: null, callId: null, isError: false, at: '2026-10-21T10:00:00.000Z'}]); expect(idle.subAgents).toEqual([{callId: 'c1', title: 'Find the invoices', taskId: 'task-2', finishedAt: '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({agentId: 'agent-1'}))).unwrap()).toMatchObject({busy: true, sleepingUntil: null}); - expect(ensureMemberThread).toHaveBeenCalledWith({orgId: 'org-1', agentId: 'agent-1', userId: 'user-1'}); + 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({agentId: 'agent-1'}))).unwrap()).toMatchObject({busy: false, sleepingUntil: soon.toISOString()}); + expect((await handleGetThreadRoute(request({personaId: 'persona-1'}))).unwrap()).toMatchObject({busy: false, sleepingUntil: soon.toISOString()}); - getChatAgent.mockResolvedValue(Ok(null)); - expect((await handleGetThreadRoute(request({agentId: 'nope'}))).unwrapErr()).toMatchObject({kind: 'not_found', resource: 'agent'}); + 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({agentId: 'agent-1'}, {text: ' Hello \n'})); + 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({agentId: 'agent-1'}, {text: ' '}))).unwrapErr().kind).toBe('parse_error'); - expect((await handlePostMessageRoute(request({agentId: 'agent-1'}, {text: 'x'.repeat(20_001)}))).unwrapErr().kind).toBe('parse_error'); + 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 index 41e2413c..6a8e6e9c 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -3,72 +3,72 @@ import {z} from 'zod'; import {ApiErr, type ApiError, Do, hasExperiment, parseSchema, requirePresent} from '@proxy/utils'; import {AVATAR_COLORS, type Look, lookSchema, type ToolCall} from '@proxy/utils/harness'; -import {deleteAgent} from '@proxy/db/agent'; import { appendPersonMessage, - type ChatAgentRow, + createPersona, + deletePersona, ensureMemberThread, type EntryRow, - getChatAgent, getMemberThread, + getPersona, type LastMessage, - listChatAgents, listEntries, listOpenJobs, + listPersonas, listSubAgents, + type PersonaRow, type ThreadRow, - updateChatAgent, + updatePersona, } from '@proxy/db/harness'; -import {AGENT_NAME_MAX, BUILT_IN_AGENT_PROVIDER} from '@proxy/integrations'; +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 {createBuiltInAgent} from './built-in-agent'; +import {ensureBuiltInAgent} from './built-in-agent'; /** - * The Chat page's routes, under the organization: its built-in agents, which every member sees, - * each named and given a face, 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. + * 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 built-in agent as the page lists it: its persona, 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 ChatAgentResponse = {id: string; name: string; look: Look; color: string; mayChange: boolean; lastMessage: {kind: 'person' | 'assistant'; text: string; at: string} | null}; +/** 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}; export type EntryResponse = {id: string; kind: 'person' | 'assistant' | 'tool_result' | 'event'; text: string; toolCalls: ToolCall[] | null; callId: string | null; isError: boolean; at: string}; /** A sub-agent started from the conversation: the call that started it, its title, the task its calls are made for, which the activity log can be narrowed to, and when it was done. */ export type SubAgentResponse = {callId: string; title: string; taskId: string; finishedAt: string | null}; -/** The member's conversation with an agent: the agent, the thread, its log, the sub-agents started from it, and what the agent is up to: busy on it, asleep until a time, or waiting for the person. */ -export type ThreadLogResponse = {agent: ChatAgentResponse; thread: {id: string}; entries: EntryResponse[]; subAgents: SubAgentResponse[]; busy: boolean; sleepingUntil: string | null}; +/** The member's conversation with a persona: the persona, the thread, its log, the sub-agents started from it, and what the persona is up to: busy on it, asleep until a time, or waiting for the person. */ +export type ThreadLogResponse = {persona: PersonaResponse; thread: {id: string}; entries: EntryResponse[]; subAgents: SubAgentResponse[]; busy: boolean; sleepingUntil: string | null}; // How much of the last message the list shows. const PREVIEW_CHARS = 120; -// Whether the viewer may rename, reface or delete the agent: whoever made it, or the organization's owner (utils/manages.ts). -function managesAgent(viewer: OrgViewer, agent: ChatAgentRow): boolean { - return manages(viewer, {ownerUserId: null, byId: agent.createdById}); +// 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 toAgent(viewer: OrgViewer, agent: ChatAgentRow & {lastMessage?: LastMessage | null}): ChatAgentResponse { - const last = agent.lastMessage ?? null; +function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null}): PersonaResponse { + const last = persona.lastMessage ?? null; return { - id: agent.id, - name: agent.name, - look: agent.look, - color: agent.color, - mayChange: managesAgent(viewer, agent), + id: persona.id, + name: persona.name, + look: persona.look, + color: persona.color, + mayChange: managesPersona(viewer, persona), lastMessage: last && {kind: last.kind, text: last.text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_CHARS), at: last.createdAt.toISOString()}, }; } -// An agent's persona as the person makes or changes it: a name, and a head, a look in one of the -// colors. +// 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, @@ -94,85 +94,88 @@ function requireChat(request: AuthenticatedRequest): Promise> { +// The persona in the address, one of the organization's. +function requirePersona(viewer: OrgViewer, request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const agentId = request.params.agentId ?? ''; - return $(requirePresent($(await getChatAgent(viewer.orgId, agentId, BUILT_IN_AGENT_PROVIDER.id)), ApiErr.notFound('agent', agentId))); + const personaId = request.params.personaId ?? ''; + return $(requirePresent($(await getPersona(viewer.orgId, personaId)), ApiErr.notFound('persona', personaId))); }); } -// The agent in the address, which the viewer may change; forbidden for one they only talk to. -function requireManagedAgent(viewer: OrgViewer, request: AuthenticatedRequest): Promise> { +// 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 agent = $(await requireAgent(viewer, request)); - if (!managesAgent(viewer, agent)) { + const persona = $(await requirePersona(viewer, request)); + if (!managesPersona(viewer, persona)) { return $(Err(ApiErr.forbidden())); } - return agent; + return persona; }); } -// The viewer's conversation with the agent, begun the first time. -function requireThread(viewer: OrgViewer, agent: ChatAgentRow): Promise> { +// 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(agent.id, viewer.userId)); + const existing = $(await getMemberThread(persona.id, viewer.userId)); if (existing) { return existing; } - return $(await ensureMemberThread({orgId: viewer.orgId, agentId: agent.id, userId: viewer.userId})); + 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/agents`: the organization's built-in agents, in the order they were made, with the viewer's conversations. */ -export function handleListChatAgentsRoute(request: AuthenticatedRequest): Promise> { +/** `GET /api/orgs/:org/chat/personas`: the organization's personas, in the order they were made, with the viewer's conversations. */ +export function handleListPersonasRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { const viewer = $(await requireChat(request)); - const agents = $(await listChatAgents(viewer.orgId, viewer.userId, BUILT_IN_AGENT_PROVIDER.id)); - return {agents: agents.map((agent) => toAgent(viewer, agent))}; + const personas = $(await listPersonas(viewer.orgId, viewer.userId)); + return {personas: personas.map((persona) => toPersona(viewer, persona))}; }); } -/** `POST /api/orgs/:org/chat/agents` with `{name, look, color}`: a new built-in agent, every member's. */ -export function handleCreateChatAgentRoute(request: AuthenticatedRequest): Promise> { +/** `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)); - const made = $(await createBuiltInAgent(viewer, persona)); - return {agent: toAgent(viewer, {...persona, id: made.id, createdById: made.createdById})}; + $(await ensureBuiltInAgent(viewer.orgId)); + const made = $(await createPersona(viewer.orgId, viewer.userId, persona)); + return {persona: toPersona(viewer, made)}; }); } -/** `PUT /api/orgs/:org/chat/agents/:agentId` with `{name, look, color}`: the agent renamed, or with another head. */ -export function handleUpdateChatAgentRoute(request: AuthenticatedRequest): Promise> { +/** `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 agent = $(await requireManagedAgent(viewer, request)); - if (!$(await updateChatAgent(viewer.orgId, agent.id, persona))) { - return $(Err(ApiErr.notFound('agent', agent.id))); + const existing = $(await requireManagedPersona(viewer, request)); + if (!$(await updatePersona(viewer.orgId, existing.id, persona))) { + return $(Err(ApiErr.notFound('persona', existing.id))); } - return {agent: toAgent(viewer, {...agent, ...persona})}; + return {persona: toPersona(viewer, {...existing, ...persona})}; }); } -/** `DELETE /api/orgs/:org/chat/agents/:agentId`: the agent, with every member's conversation with it. */ -export function handleDeleteChatAgentRoute(request: AuthenticatedRequest): Promise> { +/** `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 agent = $(await requireManagedAgent(viewer, request)); - if (!$(await deleteAgent(viewer.orgId, agent.id))) { - return $(Err(ApiErr.notFound('agent', agent.id))); + 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/agents/:agentId/thread`: the viewer's conversation with the agent, begun where there was none, its sub-agents, and whether the agent is busy on it, sub-agents included. */ +/** `GET /api/orgs/:org/chat/personas/:personaId/thread`: the viewer's conversation with the persona, begun where there was none, its sub-agents, and whether the persona is busy on it, sub-agents included. */ export function handleGetThreadRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { const viewer = $(await requireChat(request)); - const agent = $(await requireAgent(viewer, request)); - const thread = $(await requireThread(viewer, agent)); + const persona = $(await requirePersona(viewer, request)); + const thread = $(await requireThread(viewer, persona)); const [entries, jobs, subAgents] = await Promise.all([listEntries(thread.id), listOpenJobs(thread.id), listSubAgents(thread.id)]); const open = $(jobs); const now = Date.now(); @@ -181,7 +184,7 @@ export function handleGetThreadRoute(request: AuthenticatedRequest): Promise 0 && later.length === open.length; const first = later.map((job) => job.runAt.getTime()).sort((a, b) => a - b)[0]; return { - agent: toAgent(viewer, agent), + persona: toPersona(viewer, persona), thread: {id: thread.id}, entries: $(entries).filter(isShown).map(toEntry), subAgents: $(subAgents).map((sub) => ({callId: sub.parentCallId, title: sub.title, taskId: sub.taskId, finishedAt: sub.finishedAt?.toISOString() ?? null})), @@ -193,13 +196,13 @@ export function handleGetThreadRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { const viewer = $(await requireChat(request)); const {text} = $(parseSchema(messageBodySchema, request.body)); - const agent = $(await requireAgent(viewer, request)); - const thread = $(await requireThread(viewer, agent)); + 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 index 17bbab9f..4392e79c 100644 --- a/packages/api/src/harness/thread-task.ts +++ b/packages/api/src/harness/thread-task.ts @@ -37,7 +37,16 @@ export function ensureThreadTask(thread: ThreadRow, now: Date): Promise db.agent.findFirst({where: {id: agentId, orgId}, select: agentSelect})); } +/** The organization's shared agent of a provider, such as its one built-in login; `Ok(null)` for none. */ +export async function getSharedAgentByProvider( + orgId: string, + providerId: string, +): Promise> { + return wrapDb(() => + db.agent.findFirst({ + where: {orgId, providerId, ownerUserId: null}, + select: agentSelect, + orderBy: {createdAt: 'asc'}, + }), + ); +} + /** `Ok(null)` means no such agent the viewer sees. */ export async function getAgent( viewer: MemberViewer, @@ -80,14 +88,12 @@ 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; passwordHash: string; - // A built-in agent's face. - look?: string; - color?: string; }): Promise> { return wrapDb(() => db.agent.create({data, select: agentSelect})); } diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index 738a84a6..e3150d8a 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -15,9 +15,10 @@ import {db} from '.'; import {signedInAgentSelect, type SignedInAgent, toSignedInAgent} from './signed-in-agent'; /** - * The built-in agents, the organization's, each with a thread per member, the member's + * 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 change to a thread's log or its jobs is made with the thread's row locked, so a + * 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, so a * result landing and the model being run on the log never race: either the inference job sees * the result, or the result sees no job and queues one. 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, @@ -29,7 +30,10 @@ import {signedInAgentSelect, type SignedInAgent, toSignedInAgent} from './signed 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. @@ -44,11 +48,11 @@ export type ThreadRow = { createdAt: Date; }; -/** What a built-in agent is shown as: its name and its head, a look and a color. */ +/** What a persona is: its name and its head, a look and a color. */ export type Persona = {name: string; look: Look; color: string}; -/** A built-in agent as the Chat page lists it, with who made it, who may change it beside the organization's owner. */ -export type ChatAgentRow = Persona & {id: string; createdById: string | null}; +/** 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; @@ -93,6 +97,7 @@ const threadSelect = { id: true, orgId: true, agentId: true, + personaId: true, userId: true, parentThreadId: true, parentCallId: true, @@ -102,7 +107,7 @@ const threadSelect = { createdAt: true, } as const; -const chatAgentSelect = { +const personaSelect = { id: true, name: true, look: true, @@ -110,21 +115,14 @@ const chatAgentSelect = { createdById: true, } as const; -// A built-in agent's head, as it was given one; a missing one, which shouldn't be, the lab's first. -function toChatAgent(agent: { +function toPersona(persona: { id: string; name: string; - look: string | null; - color: string | null; + look: string; + color: string; createdById: string | null; -}): ChatAgentRow { - return { - id: agent.id, - name: agent.name, - look: parseLook(agent.look), - color: agent.color ?? 'blue', - createdById: agent.createdById, - }; +}): PersonaRow { + return {...persona, look: parseLook(persona.look)}; } const entrySelect = { @@ -169,18 +167,17 @@ function cut(text: string, max: number): string { return text.length > max ? `${text.slice(0, max - 1)}…` : text; } -/** The organization's built-in agents, those of `providerId`, oldest first, with the last thing said in the member's conversation with each. */ -export async function listChatAgents( +/** 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, - providerId: string, -): Promise, ApiError>> { +): Promise, ApiError>> { return ( await wrapDb(() => - db.agent.findMany({ - where: {orgId, providerId, revokedAt: null}, + db.persona.findMany({ + where: {orgId}, select: { - ...chatAgentSelect, + ...personaSelect, threads: { where: {userId, parentThreadId: null}, select: { @@ -197,11 +194,11 @@ export async function listChatAgents( orderBy: {createdAt: 'asc'}, }), ) - ).map((agents) => - agents.map(({threads, ...agent}) => { + ).map((personas) => + personas.map(({threads, ...persona}) => { const last = threads[0]?.entries[0]; return { - ...toChatAgent(agent), + ...toPersona(persona), lastMessage: last && (last.kind === 'person' || last.kind === 'assistant') ? {kind: last.kind, text: last.text, createdAt: last.createdAt} @@ -211,66 +208,107 @@ export async function listChatAgents( ); } -/** One of the organization's built-in agents, those of `providerId`; `Ok(null)` for none. */ -export async function getChatAgent( +/** 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, - agentId: string, - providerId: string, -): Promise> { + 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.agent.findFirst({ - where: {id: agentId, orgId, providerId, revokedAt: null}, - select: chatAgentSelect, + db.persona.create({ + data: { + orgId, + createdById, + name: persona.name, + look: JSON.stringify(persona.look), + color: persona.color, + }, + select: personaSelect, }), ) - ).map((agent) => (agent ? toChatAgent(agent) : null)); + ).map(toPersona); } -/** Renames a built-in agent or changes its head; `Ok(false)` means no such agent in the organization. */ -export async function updateChatAgent( +/** Renames a persona or changes its head; `Ok(false)` means no such persona in the organization. */ +export async function updatePersona( orgId: string, - agentId: string, + personaId: string, persona: Persona, ): Promise> { return ( await wrapDb(() => - db.agent.updateMany({ - where: {id: agentId, orgId}, + db.persona.updateMany({ + where: {id: personaId, orgId}, data: {name: persona.name, look: JSON.stringify(persona.look), color: persona.color}, }), ) ).map(({count}) => count > 0); } -/** The member's conversation with the agent; `Ok(null)` before they have one. */ +/** 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( - agentId: string, + personaId: string, userId: string, ): Promise> { return wrapDb(() => db.agentThread.findFirst({ - where: {agentId, userId, parentThreadId: null}, + where: {personaId, userId, parentThreadId: null}, select: threadSelect, }), ); } /** - * The member's conversation with the agent, made where they have none yet: the one there is - * otherwise, two first openings at once making one, since the agent's row is locked meanwhile. + * 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 {agentId, userId} = data; + const {personaId, userId} = data; return wrapDb(() => db.$transaction(async (tx) => { - await tx.$queryRaw`SELECT "id" FROM "Agent" WHERE "id" = ${agentId} FOR UPDATE`; + await tx.$queryRaw`SELECT "id" FROM "Persona" WHERE "id" = ${personaId} FOR UPDATE`; const existing = await tx.agentThread.findFirst({ - where: {agentId, userId, parentThreadId: null}, + where: {personaId, userId, parentThreadId: null}, select: threadSelect, }); if (existing) { @@ -744,8 +782,8 @@ export async function completeToolJob( } /** - * Finishes a delegate call's tool job by starting the sub-agent: its task, its thread with the - * brief as the first thing said in it, and its first turn queued. The call was answered as the + * 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. */ @@ -767,6 +805,7 @@ export async function startSubAgent( orgId: parent.orgId, agentId: parent.agentId, ownerUserId: parent.userId, + personaId: parent.personaId, parentId: null, title: call.title, goal: cut(call.brief, MAX_TASK_TEXT), @@ -780,6 +819,7 @@ export async function startSubAgent( data: { orgId: parent.orgId, agentId: parent.agentId, + personaId: parent.personaId, userId: parent.userId, parentThreadId: parent.id, parentCallId: call.id, 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 index ee7b1ea8..c6f389c1 100644 --- a/packages/docs/content/agents/chat.mdx +++ b/packages/docs/content/agents/chat.mdx @@ -5,10 +5,11 @@ description: Talk to an agent Personal Agent Proxy runs itself, with the same ac **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 face, and whoever made it, or the organization's owner, renames or deletes it. Each is an -agent login like any other on the Agents page, shared with everyone: what it may do with each -shared connection is set there, as for Claude or ChatGPT, and every call it makes is in the activity -log, by the conversation it was made for. +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 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 31995683..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', 'agents/chat']}, + {title: 'Agent permissions', pages: ['agents/mcp', 'agents/chat']}, { title: 'Self-hosting', pages: [ diff --git a/packages/web/src/client/harness-client.ts b/packages/web/src/client/harness-client.ts index 04ae2e34..86a826db 100644 --- a/packages/web/src/client/harness-client.ts +++ b/packages/web/src/client/harness-client.ts @@ -2,18 +2,18 @@ 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 built-in agents, each named and given a face, which -// every member sees, the person's own conversation with each, its log, the sub-agents started from -// it, and what they say in it. +// 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 log, the sub-agents started from it, +// and what they say in it. -// An agent's persona as the person makes or changes it: its name and its head, a look in a color. +// 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 agentSchema = personaSchema.extend({ +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(), @@ -48,7 +48,7 @@ const subAgentSchema = z.object({ }); const threadLogSchema = z.object({ - agent: agentSchema, + persona: chatPersonaSchema, thread: z.object({id: z.string()}), entries: z.array(entrySchema), subAgents: z.array(subAgentSchema), @@ -58,41 +58,50 @@ const threadLogSchema = z.object({ }); export type Persona = z.infer; -export type ChatAgent = z.infer; +export type ChatPersona = z.infer; export type ChatEntry = z.infer; export type ChatToolCall = z.infer; export type ChatSubAgent = z.infer; export type ThreadLog = z.infer; -export function listChatAgents(org: string) { - return apiRequest('GET', `${orgApi(org)}/chat/agents`, z.object({agents: z.array(agentSchema)})); +export function listPersonas(org: string) { + return apiRequest( + 'GET', + `${orgApi(org)}/chat/personas`, + z.object({personas: z.array(chatPersonaSchema)}), + ); } -export function createChatAgent(org: string, persona: Persona) { - return apiRequest('POST', `${orgApi(org)}/chat/agents`, z.object({agent: agentSchema}), persona); +export function createPersona(org: string, persona: Persona) { + return apiRequest( + 'POST', + `${orgApi(org)}/chat/personas`, + z.object({persona: chatPersonaSchema}), + persona, + ); } -export function updateChatAgent(org: string, agentId: string, persona: Persona) { +export function updatePersona(org: string, personaId: string, persona: Persona) { return apiRequest( 'PUT', - `${orgApi(org)}/chat/agents/${agentId}`, - z.object({agent: agentSchema}), + `${orgApi(org)}/chat/personas/${personaId}`, + z.object({persona: chatPersonaSchema}), persona, ); } -export function deleteChatAgent(org: string, agentId: string) { - return apiSend('DELETE', `${orgApi(org)}/chat/agents/${agentId}`); +export function deletePersona(org: string, personaId: string) { + return apiSend('DELETE', `${orgApi(org)}/chat/personas/${personaId}`); } -export function getThread(org: string, agentId: string) { - return apiRequest('GET', `${orgApi(org)}/chat/agents/${agentId}/thread`, threadLogSchema); +export function getThread(org: string, personaId: string) { + return apiRequest('GET', `${orgApi(org)}/chat/personas/${personaId}/thread`, threadLogSchema); } -export function postMessage(org: string, agentId: string, text: string) { +export function postMessage(org: string, personaId: string, text: string) { return apiRequest( 'POST', - `${orgApi(org)}/chat/agents/${agentId}/messages`, + `${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 75a7eac5..c7085b2b 100644 --- a/packages/web/src/client/schemas.ts +++ b/packages/web/src/client/schemas.ts @@ -32,8 +32,6 @@ export const agentSchema = z.object({ providerId: z.string(), name: z.string(), username: z.string(), - // A built-in agent's head, shown in place of a logo; null for every other agent. - face: z.object({look: lookSchema, color: z.enum(AVATAR_COLORS)}).nullable(), // Shared with every member of its organization, rather than one member's own. shared: z.boolean(), // Who made it, kept once it is shared, and their name or address while they are still in its @@ -89,6 +87,10 @@ 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(), diff --git a/packages/web/src/components/activity-timeline.tsx b/packages/web/src/components/activity-timeline.tsx index cc9fe0d5..d285ce0c 100644 --- a/packages/web/src/components/activity-timeline.tsx +++ b/packages/web/src/components/activity-timeline.tsx @@ -483,7 +483,7 @@ function AgentLabel({ className={cn(className, 'hover:bg-muted')} style={style} > - + {agent.name} {aside} diff --git a/packages/web/src/components/agent-setup.test.tsx b/packages/web/src/components/agent-setup.test.tsx index 5b08cb29..21c34875 100644 --- a/packages/web/src/components/agent-setup.test.tsx +++ b/packages/web/src/components/agent-setup.test.tsx @@ -31,7 +31,6 @@ function agentLogin(id: string, name: string, providerId: AgentProviderId = 'cus providerId, name, username: `${name.toLowerCase().replace(/ /g, '-')}-k7q2`, - face: null, shared: false, createdById: 'user-1', createdBy: null, diff --git a/packages/web/src/components/audit-entry-dialog.tsx b/packages/web/src/components/audit-entry-dialog.tsx index ad5760c0..6c311467 100644 --- a/packages/web/src/components/audit-entry-dialog.tsx +++ b/packages/web/src/components/audit-entry-dialog.tsx @@ -116,7 +116,7 @@ function AuditEntryContent({ )} {entry.agentId !== null && agent && ( <> - + {agent.name} diff --git a/packages/web/src/components/audit-list.test.tsx b/packages/web/src/components/audit-list.test.tsx index 456f4c3b..1e56ee05 100644 --- a/packages/web/src/components/audit-list.test.tsx +++ b/packages/web/src/components/audit-list.test.tsx @@ -16,7 +16,6 @@ const agent: AgentLogin = { providerId: 'claude', name: 'Claude', username: 'claude-1', - face: null, shared: false, createdById: 'user-1', createdBy: null, @@ -49,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 b346c031..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,16 +28,33 @@ 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 ( } + logo={agent && } /> ); } @@ -101,7 +119,7 @@ export function AuditList({ className="flex h-10 w-full items-center gap-3 px-(--bleed) text-left text-sm hover:bg-muted/50 active:bg-muted/50" > {formatDateTime(entry.at)} - {showAgent && } + {showAgent && } diff --git a/packages/web/src/components/brand-logo.tsx b/packages/web/src/components/brand-logo.tsx index c609e54a..56099691 100644 --- a/packages/web/src/components/brand-logo.tsx +++ b/packages/web/src/components/brand-logo.tsx @@ -1,9 +1,7 @@ import {findAgentProvider} from '@proxy/integrations'; import {cn} from '@proxy/ui/lib/utils'; import {useState, type ReactNode} from 'react'; -import {Head} from '@/components/head'; import type {Integration} from '@/lib/integrations'; -import type {AgentLogin} from '@/lib/types'; // Logos sit on a white tile in both themes, so dark marks stay legible. They're decorative: the // text beside one always names it. @@ -21,21 +19,9 @@ function LogoTile({children, className}: {children: ReactNode; className?: strin ); } -// A built-in agent has its head in place of a logo, the one it has on the Chat page. A custom MCP -// agent is connected through Personal Agent Proxy, so it gets our mark. -export function AgentLogo({ - providerId, - face, - className, -}: { - providerId: string; - face?: AgentLogin['face']; - className?: string; -}) { +// A custom MCP agent is connected through Personal Agent Proxy, so it gets our mark. +export function AgentLogo({providerId, className}: {providerId: string; className?: string}) { const provider = findAgentProvider(providerId); - if (face) { - return ; - } if (provider?.custom) { return ( diff --git a/packages/web/src/components/connected-logos.tsx b/packages/web/src/components/connected-logos.tsx index b750ac0d..63be1e42 100644 --- a/packages/web/src/components/connected-logos.tsx +++ b/packages/web/src/components/connected-logos.tsx @@ -74,7 +74,7 @@ export function ConnectedLogos({ > {active.slice(0, SHOWN).map((agent) => ( - + ))} diff --git a/packages/web/src/components/nav.tsx b/packages/web/src/components/nav.tsx index ef2880f8..fc85e81b 100644 --- a/packages/web/src/components/nav.tsx +++ b/packages/web/src/components/nav.tsx @@ -38,7 +38,7 @@ const DESTINATIONS: Destination[] = [ {to: '', label: 'Home', icon: HomeIcon}, {to: '/chat', label: 'Chat', icon: MessageSquareIcon, experiment: 'harness'}, {to: '/connections', label: 'Connections', icon: PlugIcon}, - {to: '/agents', label: 'Agents', icon: BotIcon}, + {to: '/agents', label: 'Agent permissions', icon: BotIcon}, {to: '/vault', label: 'Vault', icon: LibraryIcon}, {to: '/activity', label: 'Activity', icon: ScrollTextIcon}, {to: '/members', label: 'Members', icon: UsersIcon}, diff --git a/packages/web/src/lib/activity-timeline.test.ts b/packages/web/src/lib/activity-timeline.test.ts index d2375220..3c924962 100644 --- a/packages/web/src/lib/activity-timeline.test.ts +++ b/packages/web/src/lib/activity-timeline.test.ts @@ -18,6 +18,7 @@ function task(overrides: Partial): ActivityTask { return { id: 't1', agentId: 'a1', + persona: null, parentId: null, title: 'Triage inbox', goal: 'Archive the newsletters', diff --git a/packages/web/src/lib/run-timeline.test.ts b/packages/web/src/lib/run-timeline.test.ts index e361ac76..fda78643 100644 --- a/packages/web/src/lib/run-timeline.test.ts +++ b/packages/web/src/lib/run-timeline.test.ts @@ -17,6 +17,7 @@ function task(overrides: Partial): ActivityTask { return { id: 'plan', agentId: 'a1', + persona: null, parentId: null, title: 'Plan the week', goal: 'List the week’s events and what to do first', @@ -154,6 +155,7 @@ describe('buildRunTimeline', () => { const plan = task({finishedAt: '2026-10-16T09:10:00Z', outcome: 'Tuesday is the day to start'}); const calendar = task({ id: 'calendar', + persona: null, parentId: 'plan', title: 'Check the calendar', startedAt: '2026-10-16T09:01:00Z', @@ -162,6 +164,7 @@ describe('buildRunTimeline', () => { }); const issues = task({ id: 'issues', + persona: null, parentId: 'plan', title: 'Find the open issues', startedAt: '2026-10-16T09:03:00Z', @@ -170,6 +173,7 @@ describe('buildRunTimeline', () => { }); const notes = task({ id: 'notes', + persona: null, parentId: 'calendar', title: 'Pull the meeting notes', startedAt: '2026-10-16T09:02:00Z', @@ -233,6 +237,7 @@ describe('buildRunTimeline', () => { test('a task finished by its agent is handed back; one finished along with its parent, saying nothing, is not', () => { const quiet = task({ id: 'quiet', + persona: null, parentId: 'plan', startedAt: '2026-10-16T09:05:00Z', finishedAt: '2026-10-16T09:10:00Z', @@ -250,6 +255,7 @@ describe('buildRunTimeline', () => { const burst = ['a', 'b', 'c'].map((id, index) => task({ id, + persona: null, parentId: 'parent', startedAt: new Date(Date.parse('2026-10-16T09:01:00Z') + index * 15 * SECOND).toISOString(), finishedAt: new Date( @@ -300,6 +306,7 @@ describe('buildRunTimeline', () => { const burst = ['a', 'b'].map((id, index) => task({ id, + persona: null, parentId: 'parent', startedAt: new Date(Date.parse('2026-10-14T09:01:00Z') + index * 3 * SECOND).toISOString(), }), diff --git a/packages/web/src/lib/timeline-window.test.ts b/packages/web/src/lib/timeline-window.test.ts index 076b5266..d580a397 100644 --- a/packages/web/src/lib/timeline-window.test.ts +++ b/packages/web/src/lib/timeline-window.test.ts @@ -23,6 +23,7 @@ function task(overrides: Partial): ActivityTask { via: 'mcp', startedAt: '2026-10-16T11:30:00Z', finishedAt: null, + persona: null, parentId: null, outcome: null, // No sub-task started after it, unless a test gives one. @@ -87,12 +88,14 @@ describe('endsOf', () => { }); const compare = task({ id: 'compare', + persona: null, parentId: 'groceries', startedAt: '2026-10-15T08:00:00Z', finishedAt: '2026-10-15T08:45:00Z', }); const slot = task({ id: 'slot', + persona: null, parentId: 'groceries', startedAt: '2026-10-15T09:00:00Z', lastStartedAt: '2026-10-15T10:00:00Z', diff --git a/packages/web/src/loaders.ts b/packages/web/src/loaders.ts index fd2ac42f..b936bd55 100644 --- a/packages/web/src/loaders.ts +++ b/packages/web/src/loaders.ts @@ -10,7 +10,7 @@ import { listMcpClients, } from '@/client/agents-client'; import {getConnection, listConnections} from '@/client/connections-client'; -import {createChatAgent, getThread, listChatAgents} from '@/client/harness-client'; +import {createPersona, getThread, listPersonas} from '@/client/harness-client'; import {getMe} from '@/client/me-client'; import {getAuthorizationRequest} from '@/client/oauth-client'; import {getInvitation, getOrg, listMembers, listOrgs} from '@/client/orgs-client'; @@ -112,28 +112,28 @@ export async function connectionLoader(args: {params: {org?: string; id?: string }; } -// Chat opens on the organization's first agent, a General one made where it has none yet. +// Chat opens on the organization's first persona, a General one made where it has none yet. export async function chatIndexLoader(args: OrgParams) { const org = orgOf(args); - const {agents} = unwrapLoaderResult(await listChatAgents(org)); - const first = agents[0]; + const {personas} = unwrapLoaderResult(await listPersonas(org)); + const first = personas[0]; if (first) { throw redirect(`/${org}/chat/${first.id}`); } - const {agent} = unwrapLoaderResult( - await createChatAgent(org, {name: 'General', look: DEFAULT_LOOK, color: 'blue'}), + const {persona} = unwrapLoaderResult( + await createPersona(org, {name: 'General', look: DEFAULT_LOOK, color: 'blue'}), ); - throw redirect(`/${org}/chat/${agent.id}`); + throw redirect(`/${org}/chat/${persona.id}`); } // The person's conversation with an agent, and the agents to switch between. export async function chatLoader(args: OrgParams & {params: {id?: string}}) { const org = orgOf(args); - const [log, agents] = await Promise.all([ + const [log, personas] = await Promise.all([ getThread(org, args.params.id ?? ''), - listChatAgents(org), + listPersonas(org), ]); - return {log: unwrapLoaderResult(log), agents: unwrapLoaderResult(agents).agents}; + return {log: unwrapLoaderResult(log), personas: unwrapLoaderResult(personas).personas}; } // The vault is the one built-in connection, which every organization has twice: the person's own diff --git a/packages/web/src/pages/activity-page.tsx b/packages/web/src/pages/activity-page.tsx index 72f8fd70..5b35cb4e 100644 --- a/packages/web/src/pages/activity-page.tsx +++ b/packages/web/src/pages/activity-page.tsx @@ -13,6 +13,7 @@ import { } from '@proxy/ui/components/select'; import {Button} from '@proxy/ui/components/button'; import {AgentLogo} from '@/components/brand-logo'; +import {Head} from '@/components/head'; import {Badge} from '@proxy/ui/components/badge'; import {useOrgSlug} from '@/hooks/use-org'; import {connectionLabel} from '@/lib/access'; @@ -138,13 +139,19 @@ function TaskCard({

{task.goal}

- {agent && ( + {task.persona && ( - + + {task.persona.name} + + )} + {!task.persona && agent && ( + + {agent.name} )} - {!agent && Deleted agent} + {!task.persona && !agent && Deleted agent} · Started {formatDateTime(task.startedAt)} {task.finishedAt && ( diff --git a/packages/web/src/pages/agent-page.tsx b/packages/web/src/pages/agent-page.tsx index bd56f29c..b43f2247 100644 --- a/packages/web/src/pages/agent-page.tsx +++ b/packages/web/src/pages/agent-page.tsx @@ -99,13 +99,15 @@ function AgentDetail({id}: {id: string}) { - + {agent.name} @@ -122,7 +124,7 @@ function AgentDetail({id}: {id: string}) { )} - {revoked ? ( + {builtIn ? null : revoked ? ( void apply(setAgentRevoked(org, agent.id, false))} @@ -134,9 +136,11 @@ function AgentDetail({id}: {id: string}) { )} - setConfirming('delete')}> - - + {!builtIn && ( + setConfirming('delete')}> + + + )} ) } diff --git a/packages/web/src/pages/agents-page.tsx b/packages/web/src/pages/agents-page.tsx index 3123c4e2..2ea911bc 100644 --- a/packages/web/src/pages/agents-page.tsx +++ b/packages/web/src/pages/agents-page.tsx @@ -2,6 +2,7 @@ import {PlusIcon} from 'lucide-react'; import {Link, useLoaderData} from 'react-router'; import {findAgentProvider} from '@proxy/integrations'; import {AgentLogo} from '@/components/brand-logo'; +import {Badge} from '@proxy/ui/components/badge'; import {AppShell, PageTitle} from '@/components/app-shell'; import {Row, RowHeader, RowList} from '@/components/row-list'; import {SideEmpty, SideToggle} from '@/components/share-toggle'; @@ -39,7 +40,7 @@ export function AgentsPage() { return ( Agents} + title={Agent permissions} actions={

From 99d91bcc4ec204be519d519881eb9113d282c726 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:38:45 +0000 Subject: [PATCH 12/19] show a conversation as a conversation: the turns in bubbles on both sides, and nothing of the sub-agents The Chat page shows what the person said and what the persona answered, each in a bubble, the person's on the right and the persona's outlined on the left, turns in a row from one side close together. The sub-agents the persona started, their briefs and their reports are in the activity log, not on the page: the api gives the page the turns alone, and the sub-agent list, the trace lines and the trace pieces that only served them are gone. The Chat app's badge on the Agent permissions page sits in a row rather than with a margin. --- packages/api/src/harness/route.test.ts | 8 +- packages/api/src/harness/route.ts | 25 ++- packages/db/src/harness.ts | 31 --- packages/docs/content/agents/chat.mdx | 19 +- packages/ui/src/components/trace.tsx | 94 +-------- packages/web/src/client/harness-client.ts | 29 +-- packages/web/src/pages/agents-page.tsx | 17 +- packages/web/src/pages/chat-page.tsx | 220 +++------------------- 8 files changed, 71 insertions(+), 372 deletions(-) diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index 335b69a1..ef7d8ef0 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -10,7 +10,6 @@ const getMemberThread = mock(); const ensureMemberThread = mock(); const listEntries = mock(); const listOpenJobs = mock(); -const listSubAgents = mock(); const appendPersonMessage = mock(); mock.module('@proxy/db/harness', () => ({ listPersonas, @@ -22,7 +21,6 @@ mock.module('@proxy/db/harness', () => ({ ensureMemberThread, listEntries, listOpenJobs, - listSubAgents, appendPersonMessage, })); @@ -84,7 +82,6 @@ beforeEach(() => { ]), ); listOpenJobs.mockResolvedValue(Ok([])); - listSubAgents.mockResolvedValue(Ok([{id: 'thread-2', parentCallId: 'c1', title: 'Find the invoices', taskId: 'task-2', finishedAt: now}])); 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, createdAt: now}), ); @@ -160,7 +157,7 @@ describe('the chat routes', () => { 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, without its summaries, with its sub-agents and whether the persona is busy or asleep', async () => { + 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}; @@ -168,8 +165,7 @@ describe('the chat routes', () => { const idle = (await handleGetThreadRoute(request({personaId: 'persona-1'}))).unwrap(); expect(idle.persona).toEqual(personaResponse); expect(idle.thread).toEqual({id: 'thread-1'}); - expect(idle.entries).toEqual([{id: 'e1', kind: 'person', text: 'Hi', toolCalls: null, callId: null, isError: false, at: '2026-10-21T10:00:00.000Z'}]); - expect(idle.subAgents).toEqual([{callId: 'c1', title: 'Find the invoices', taskId: 'task-2', finishedAt: '2026-10-21T10:00:00.000Z'}]); + expect(idle.entries).toEqual([{id: 'e1', kind: 'person', text: 'Hi', at: '2026-10-21T10:00:00.000Z'}]); expect(idle).toMatchObject({busy: false, sleepingUntil: null}); expect(ensureMemberThread).not.toHaveBeenCalled(); expect(ensureBuiltInAgent).not.toHaveBeenCalled(); diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts index 6a8e6e9c..e02cb107 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -2,7 +2,7 @@ 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, type ToolCall} from '@proxy/utils/harness'; +import {AVATAR_COLORS, type Look, lookSchema} from '@proxy/utils/harness'; import { appendPersonMessage, createPersona, @@ -15,7 +15,6 @@ import { listEntries, listOpenJobs, listPersonas, - listSubAgents, type PersonaRow, type ThreadRow, updatePersona, @@ -40,13 +39,11 @@ 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}; -export type EntryResponse = {id: string; kind: 'person' | 'assistant' | 'tool_result' | 'event'; text: string; toolCalls: ToolCall[] | null; callId: string | null; isError: boolean; at: string}; +/** A turn of the conversation as the page shows it: what the person said, or what the persona answered. What the persona did in between is in the activity log, not here. */ +export type EntryResponse = {id: string; kind: 'person' | 'assistant'; text: string; at: string}; -/** A sub-agent started from the conversation: the call that started it, its title, the task its calls are made for, which the activity log can be narrowed to, and when it was done. */ -export type SubAgentResponse = {callId: string; title: string; taskId: string; finishedAt: string | null}; - -/** The member's conversation with a persona: the persona, the thread, its log, the sub-agents started from it, and what the persona is up to: busy on it, asleep until a time, or waiting for the person. */ -export type ThreadLogResponse = {persona: PersonaResponse; thread: {id: string}; entries: EntryResponse[]; subAgents: SubAgentResponse[]; busy: boolean; sleepingUntil: string | null}; +/** The member's conversation with a persona: the persona, the thread, 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; thread: {id: string}; entries: EntryResponse[]; busy: boolean; sleepingUntil: string | null}; // How much of the last message the list shows. const PREVIEW_CHARS = 120; @@ -75,13 +72,14 @@ const personaSchema = z.object({ color: z.enum(AVATAR_COLORS), }); -// The entries the person sees: the conversation, not the summaries the model reads it as. +// The entries the person sees: the conversation, what they said and what the persona answered, +// not the calls, their results, the reports or the summaries the model reads. function isShown(entry: EntryRow): entry is EntryRow & {kind: EntryResponse['kind']} { - return entry.kind !== 'summary'; + 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, toolCalls: entry.toolCalls, callId: entry.callId, isError: entry.isError, at: entry.createdAt.toISOString()}; + return {id: entry.id, kind: entry.kind, text: entry.text, at: entry.createdAt.toISOString()}; } function requireChat(request: AuthenticatedRequest): Promise> { @@ -170,13 +168,13 @@ export function handleDeletePersonaRoute(request: AuthenticatedRequest): Promise }); } -/** `GET /api/orgs/:org/chat/personas/:personaId/thread`: the viewer's conversation with the persona, begun where there was none, its sub-agents, and whether the persona is busy on it, sub-agents included. */ +/** `GET /api/orgs/:org/chat/personas/:personaId/thread`: the viewer's conversation with the persona, begun where there was none, 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, subAgents] = await Promise.all([listEntries(thread.id), listOpenJobs(thread.id), listSubAgents(thread.id)]); + const [entries, jobs] = await Promise.all([listEntries(thread.id), listOpenJobs(thread.id)]); const open = $(jobs); const now = Date.now(); // Asleep when every open job is a tool job due later; the first to be due is when it wakes. @@ -187,7 +185,6 @@ export function handleGetThreadRoute(request: AuthenticatedRequest): Promise ({callId: sub.parentCallId, title: sub.title, taskId: sub.taskId, finishedAt: sub.finishedAt?.toISOString() ?? null})), busy: open.length > 0 && !asleep, sleepingUntil: asleep && first !== undefined ? new Date(first).toISOString() : null, }; diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index e3150d8a..59d4b237 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -78,15 +78,6 @@ export type JobRow = { attempts: number; }; -/** A sub-agent of a thread, for the page: the call that started it, its title, its task, and when it was done. */ -export type SubAgentRow = { - id: string; - parentCallId: string; - title: string; - taskId: string; - finishedAt: Date | null; -}; - /** 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}; @@ -319,28 +310,6 @@ export async function ensureMemberThread(data: { ); } -/** The sub-agents started from a thread, oldest first. */ -export async function listSubAgents(threadId: string): Promise> { - return ( - await wrapDb(() => - db.agentThread.findMany({ - where: {parentThreadId: threadId}, - select: {id: true, parentCallId: true, title: true, taskId: true, finishedAt: true}, - orderBy: {createdAt: 'asc'}, - }), - ) - ).map((threads) => - threads.flatMap((thread) => { - if (thread.parentCallId === null || thread.title === null || thread.taskId === null) { - return []; - } - return [ - {...thread, parentCallId: thread.parentCallId, title: thread.title, taskId: thread.taskId}, - ]; - }), - ); -} - /** The last thing said in a thread, by the person or the agent. */ export type LastMessage = {kind: 'person' | 'assistant'; text: string; createdAt: Date}; diff --git a/packages/docs/content/agents/chat.mdx b/packages/docs/content/agents/chat.mdx index c6f389c1..f8deeb5e 100644 --- a/packages/docs/content/agents/chat.mdx +++ b/packages/docs/content/agents/chat.mdx @@ -29,13 +29,14 @@ host that neither keeps prompts nor trains on them, so a self-hosted Personal Ag ## How a conversation runs -Say what you want done. The agent answers, or starts sub-agents first: each shows as a line above -its message, with its title, opening to the brief it was given and, once it is done, its report, -and a way to its activity. Sub-agents started together run side by side. 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. +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 @@ -45,8 +46,8 @@ A conversation can go on for as long as you like. Once it has grown long, the ag 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 of this agent** under the input opens the activity log narrowed to the agent, and each -sub-agent's line opens to the activity of its task. +**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 diff --git a/packages/ui/src/components/trace.tsx b/packages/ui/src/components/trace.tsx index a1cb449f..1df59c8d 100644 --- a/packages/ui/src/components/trace.tsx +++ b/packages/ui/src/components/trace.tsx @@ -1,13 +1,6 @@ import * as React from 'react'; import {cn} from 'cn'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@proxy/ui/components/collapsible'; -import {ChevronRightIcon} from 'lucide-react'; - function Trace({className, ...props}: React.ComponentProps<'div'>) { return (

) { ); } -function TraceDescription({className, ...props}: React.ComponentProps<'span'>) { - return ( - - ); -} - -function TraceCollapsible({ - className, - ...props -}: Omit, 'open'> & - ( - | {open?: undefined} - | { - open: boolean; - onOpenChange: NonNullable['onOpenChange']>; - } - )) { - return ( - - ); -} - -function TraceCollapsibleTrigger({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - {children} - - - - - ); -} - -function TraceCollapsibleContent({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - -
- {children} -
-
- ); -} - -export { - Trace, - TraceLine, - TraceIcon, - TraceTitle, - TraceDescription, - TraceCollapsible, - TraceCollapsibleTrigger, - TraceCollapsibleContent, -}; +export {Trace, TraceLine, TraceIcon, TraceTitle}; diff --git a/packages/web/src/client/harness-client.ts b/packages/web/src/client/harness-client.ts index 86a826db..f5d4b283 100644 --- a/packages/web/src/client/harness-client.ts +++ b/packages/web/src/client/harness-client.ts @@ -3,8 +3,7 @@ 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 log, the sub-agents started from it, -// and what they say in it. +// 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({ @@ -23,36 +22,20 @@ const chatPersonaSchema = personaSchema.extend({ .nullable(), }); -const toolCallSchema = z.object({id: z.string(), name: z.string(), arguments: z.string()}); - +// A turn of the conversation: what the person said, or what the persona answered. What the +// persona did in between is in the activity log, not here. const entrySchema = z.object({ id: z.string(), - // An event is a sub-agent's report, which came after its call was answered as started. - kind: z.enum(['person', 'assistant', 'tool_result', 'event']), + kind: z.enum(['person', 'assistant']), text: z.string(), - // An assistant entry's calls; null otherwise. - toolCalls: z.array(toolCallSchema).nullable(), - // A tool result's or an event's call; null otherwise. - callId: z.string().nullable(), - isError: z.boolean(), at: z.string(), }); -// A sub-agent started from the conversation: by which call, its title, the task its calls are -// made for, which the activity log can be narrowed to, and when it was done. -const subAgentSchema = z.object({ - callId: z.string(), - title: z.string(), - taskId: z.string(), - finishedAt: z.string().nullable(), -}); - const threadLogSchema = z.object({ persona: chatPersonaSchema, thread: z.object({id: z.string()}), entries: z.array(entrySchema), - subAgents: z.array(subAgentSchema), - // Whether the agent is at work on the conversation, sub-agents included, or asleep until a time. + // Whether the persona is at work on the conversation, sub-agents included, or asleep until a time. busy: z.boolean(), sleepingUntil: z.string().nullable(), }); @@ -60,8 +43,6 @@ const threadLogSchema = z.object({ export type Persona = z.infer; export type ChatPersona = z.infer; export type ChatEntry = z.infer; -export type ChatToolCall = z.infer; -export type ChatSubAgent = z.infer; export type ThreadLog = z.infer; export function listPersonas(org: string) { diff --git a/packages/web/src/pages/agents-page.tsx b/packages/web/src/pages/agents-page.tsx index 2ea911bc..c590cf87 100644 --- a/packages/web/src/pages/agents-page.tsx +++ b/packages/web/src/pages/agents-page.tsx @@ -82,14 +82,19 @@ export function AgentsPage() { to={`/${org}/agents/${agent.id}`} icon={} title={ - - {agent.name} + + + {agent.name} + {revoked && ' · Revoked'} + {findAgentProvider(agent.providerId)?.connects === 'built-in' && ( - - Built in - + Built in )} - {revoked && ' · Revoked'} } cells={ diff --git a/packages/web/src/pages/chat-page.tsx b/packages/web/src/pages/chat-page.tsx index 05a498f2..0f1a61b3 100644 --- a/packages/web/src/pages/chat-page.tsx +++ b/packages/web/src/pages/chat-page.tsx @@ -1,12 +1,9 @@ -import {ArrowUpIcon, BotIcon, ChevronDownIcon, MoonIcon, PlusIcon, WrenchIcon} from 'lucide-react'; +import {ArrowUpIcon, ChevronDownIcon, MoonIcon, PlusIcon} from 'lucide-react'; import {type FormEvent, type KeyboardEvent, useEffect, useRef, useState} from 'react'; import {Link, useLoaderData, useNavigate, useRevalidator} from 'react-router'; -import {Result} from 'ts-results-es'; import { type ChatEntry, type ChatPersona, - type ChatSubAgent, - type ChatToolCall, createPersona, deletePersona, getThread, @@ -34,15 +31,7 @@ import { MessageScrollerProvider, MessageScrollerViewport, } from '@proxy/ui/components/message-scroller'; -import { - Trace, - TraceCollapsible, - TraceCollapsibleContent, - TraceCollapsibleTrigger, - TraceIcon, - TraceLine, - TraceTitle, -} from '@proxy/ui/components/trace'; +import {Trace, TraceIcon, TraceLine, TraceTitle} from '@proxy/ui/components/trace'; import {cn} from '@proxy/ui/lib/utils'; import {useOrgSlug} from '@/hooks/use-org'; import {formatAgo, formatDateTime} from '@/lib/format'; @@ -54,131 +43,10 @@ import type {chatLoader} from '@/loaders'; const BUSY_POLL_MS = 1000; const IDLE_POLL_MS = 4000; -// A call's arguments as the model wrote them; none where they aren't JSON. -function parseArgs(json: string): Record { - const parsed = Result.wrap((): unknown => JSON.parse(json)); - if (parsed.isErr() || typeof parsed.value !== 'object' || parsed.value === null) { - return {}; - } - return parsed.value as Record; -} - -// JSON as it is shown: laid out where it parses, as written where it doesn't. -function pretty(json: string): string { - return Result.wrap((): unknown => JSON.parse(json)) - .map((value) => JSON.stringify(value, null, 2)) - .unwrapOr(json); -} - -type CallState = 'running' | 'done' | 'failed'; - -// What a call did, in a few words, from its name and arguments, as it runs, once it has, and -// where it failed: "Running entries_list", "Ran entries_list", "Failed to run entries_list"; a -// delegation by the sub-agent's title. -function callTitle(call: ChatToolCall, state: CallState): string { - const args = parseArgs(call.arguments); - const said = (verbs: [string, string, string], what: string) => - `${verbs[state === 'running' ? 0 : state === 'done' ? 1 : 2]} ${what}`; - if (call.name === 'delegate') { - const title = typeof args.title === 'string' ? args.title : 'a sub-agent'; - return said(['Working on', 'Done with', 'Failed'], title); - } - if (call.name === 'list_connections') { - return said(['Listing', 'Listed', 'Failed to list'], 'connections'); - } - if (call.name === 'list_tools') { - return said(['Listing', 'Listed', 'Failed to list'], 'tools'); - } - if (call.name === 'sleep_until') { - const until = - typeof args.until === 'string' - ? formatDateTime(args.until) - : typeof args.seconds === 'number' - ? `${args.seconds} seconds from now` - : 'later'; - return said(['Sleeping until', 'Slept until', 'Failed to sleep until'], until); - } - if (call.name === 'send_feedback') { - return said(['Sending', 'Sent', 'Failed to send'], 'feedback'); - } - return said( - ['Running', 'Ran', 'Failed to run'], - typeof args.tool === 'string' ? args.tool : call.name, - ); -} - -// One call the agent made: a line that says what it did, opening to what it asked and what came -// back. A delegation opens to the sub-agent's brief and its report, with a way to its activity. -function ToolCallTrace({ - call, - result, - subAgent, -}: { - call: ChatToolCall; - result: ChatEntry | undefined; - subAgent: ChatSubAgent | undefined; -}) { - const org = useOrgSlug(); - const status = result === undefined ? 'running' : result.isError ? 'error' : undefined; - const title = callTitle( - call, - result === undefined ? 'running' : result.isError ? 'failed' : 'done', - ); - const args = parseArgs(call.arguments); - const delegated = call.name === 'delegate'; - return ( - - - - - {delegated ? : call.name === 'sleep_until' ? : } - - {title} - - - - {delegated ? ( -

- {typeof args.brief === 'string' ? args.brief : pretty(call.arguments)} -

- ) : ( -
{pretty(call.arguments)}
- )} - {result && ( -
-						{delegated ? result.text : pretty(result.text)}
-					
- )} - {subAgent && ( - - Activity of this sub-agent - - )} -
-
- ); -} - -// One turn of the conversation: what the person said in a bubble on the right, or what the agent -// said as plain text on the left, with the calls it made as lines above it. -function Turn({ - entry, - results, - subAgents, -}: { - entry: ChatEntry; - results: Map; - subAgents: Map; -}) { +// One turn of the conversation: what the person said in a bubble on the right, or what the +// persona answered in an outlined one on the left. What it did in between, the sub-agents it +// started and what they found, is in the activity log, not here: this is a conversation. +function Turn({entry}: {entry: ChatEntry}) { if (entry.kind === 'person') { return ( @@ -190,25 +58,12 @@ function Turn({ ); } - const calls = entry.toolCalls ?? []; return ( - - {calls.length > 0 && ( - - {calls.map((call) => ( - - ))} - - )} - {entry.text !== '' && ( -
{entry.text}
- )} + + + {entry.text} +
); @@ -246,13 +101,8 @@ function preview(last: ChatPersona['lastMessage']): string { // The last thing said in the open conversation, as the page has it, fresher than the list. function lastOf(log: ThreadLog): ChatPersona['lastMessage'] { - const last = log.entries.findLast( - (entry) => (entry.kind === 'person' || entry.kind === 'assistant') && entry.text !== '', - ); - if (!last || (last.kind !== 'person' && last.kind !== 'assistant')) { - return null; - } - return {kind: last.kind, text: last.text, at: last.at}; + const last = log.entries.at(-1); + return last ? {kind: last.kind, text: last.text, at: last.at} : null; } // The organization's personas in the second sidebar, each with its head, its name and the last @@ -437,27 +287,15 @@ function Conversation({loaded}: {loaded: Loaded}) { return null; }; - // Each call's result by the call, folded into the call's line rather than shown as a turn. A - // delegation's is the sub-agent's report, an event, which comes after the call was answered as - // started: until then the call is under way, so that answer is left out, unless it refused. - const delegations = new Set( - log.entries.flatMap((entry) => - (entry.toolCalls ?? []).filter((call) => call.name === 'delegate').map((call) => call.id), - ), - ); - const results = new Map( - log.entries - .filter( - (entry) => - entry.kind === 'event' || - (entry.kind === 'tool_result' && (entry.isError || !delegations.has(entry.callId ?? ''))), - ) - .map((entry) => [entry.callId, entry]), - ); - const subAgents = new Map(log.subAgents.map((sub) => [sub.callId, sub])); - const turns = log.entries.filter( - (entry) => entry.kind === 'person' || entry.kind === 'assistant', - ); + // Turns in a row from the same side sit closer together than the sides do. + const runs = log.entries.reduce((grouped, entry) => { + const last = grouped.at(-1); + if (last && last[0]?.kind === entry.kind) { + last.push(entry); + return grouped; + } + return [...grouped, [entry]]; + }, []); return ( - {turns.length === 0 && ( + {runs.length === 0 && (

What should {current.name} do?

@@ -517,13 +355,17 @@ function Conversation({loaded}: {loaded: Loaded}) {

)} - {turns.map((entry) => ( + {runs.map((run) => ( - +
+ {run.map((entry) => ( + + ))} +
))} {(log.busy || log.sleepingUntil !== null) && ( From 6e8d652470fd2d01054b180f82c80de9cf5ed1b3 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:01:12 +0000 Subject: [PATCH 13/19] render a persona's answers as Markdown, and lift the conversation's bottom fade at its end everywhere What a persona answers is shown as Markdown: paragraphs, lists, tables, code and links, styled to sit in its bubble, headings as bold lines, since a chat reply has none. The model is told so, and to lay a reply out as a list or a table where that reads better. The sidebar's preview of the last message drops the Markdown marks. The message scroller's viewport fades its bottom while there is more below. A browser that can drive the fade from the scroll position lifts it at the end by itself; one that can't got shadcn's fallback, a fade that never lifts. The viewport now says whether there is more below, from the scroller's own state, and the stylesheet lifts the fade where there isn't. --- bun.lock | 6 ++ packages/api/src/harness/inference.ts | 2 +- packages/api/src/harness/route.test.ts | 6 ++ packages/api/src/harness/route.ts | 15 +++- .../ui/src/components/message-scroller.tsx | 5 ++ packages/ui/src/styles.css | 7 ++ packages/web/package.json | 2 + packages/web/src/components/markdown.tsx | 70 +++++++++++++++++++ packages/web/src/pages/chat-page.tsx | 9 ++- 9 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/components/markdown.tsx diff --git a/bun.lock b/bun.lock index 963e7cc7..c02e0f27 100644 --- a/bun.lock +++ b/bun.lock @@ -173,8 +173,10 @@ "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", @@ -1078,6 +1080,8 @@ "html-to-text": ["html-to-text@10.0.1", "", { "dependencies": { "@selderee/plugin-htmlparser2": "~0.12.0", "deepmerge-ts": "^8.0.1", "dom-serializer": "^2.0.0", "htmlparser2": "^10.1.0", "selderee": "~0.12.0" } }, "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], @@ -1508,6 +1512,8 @@ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-plaid-link": ["react-plaid-link@5.0.0", "", { "dependencies": { "prop-types": "^15.7.2" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19", "react-dom": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-DZ/6C7hf+xhEIG9ElCqjk1lty/z+y+wQuV/A29EUx4fxiPLPe7CXN811C3MrCzI7grV45LD7NRS0Do0ma8KIBg=="], "react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="], diff --git a/packages/api/src/harness/inference.ts b/packages/api/src/harness/inference.ts index f0a1fa6b..7a8e74e0 100644 --- a/packages/api/src/harness/inference.ts +++ b/packages/api/src/harness/inference.ts @@ -37,7 +37,7 @@ import {toolsFor} from './tool-surface'; // 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 it is, so write plain text, short, and answer what they asked. 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.'; + '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 = diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index ef7d8ef0..9fb38df2 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -103,6 +103,11 @@ describe('the chat routes', () => { 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}, + }, ]), ); @@ -113,6 +118,7 @@ describe('the chat routes', () => { 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'}}, ], }); }); diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts index e02cb107..f50b7164 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -48,6 +48,19 @@ export type ThreadLogResponse = {persona: PersonaResponse; thread: {id: string}; // How much of the last message the list shows. const PREVIEW_CHARS = 120; +// The last message as a line of words: its Markdown marks dropped, its whitespace folded. +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); +} + // 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}); @@ -61,7 +74,7 @@ function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastM look: persona.look, color: persona.color, mayChange: managesPersona(viewer, persona), - lastMessage: last && {kind: last.kind, text: last.text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_CHARS), at: last.createdAt.toISOString()}, + lastMessage: last && {kind: last.kind, text: preview(last.text), at: last.createdAt.toISOString()}, }; } diff --git a/packages/ui/src/components/message-scroller.tsx b/packages/ui/src/components/message-scroller.tsx index cb265463..dcd01e1f 100644 --- a/packages/ui/src/components/message-scroller.tsx +++ b/packages/ui/src/components/message-scroller.tsx @@ -34,13 +34,18 @@ function MessageScroller({ ); } +// The viewport fades out at its bottom while there is more below. A browser that can drive the +// fade from the scroll position does (shadcn's scroll-fade); for one that can't, the viewport +// says whether it is scrolled to the end, and the stylesheet lifts the fade there. function MessageScrollerViewport({ className, ...props }: React.ComponentProps) { + const scrollable = useMessageScrollerScrollable(); return (

, + a: ({className, ...props}) => ( + + ), + ul: ({className, ...props}) => ( +