From c4c9261a2309991ff0e2a5d9bab09455deae468f Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:23:14 +0000 Subject: [PATCH 1/3] ship chat to every organization, run its agents on members' own claude accounts, and keep openrouter behind an experiment The `harness` experiment is gone: Chat, Alerts and the command bar's agent widgets are on everywhere. The OpenRouter harness is offered only to organizations with the new `openrouter` experiment, in the Chat routes and in the worker alike, and Claude comes first, so new agents run on it. A migration moves agents on OpenRouter elsewhere to Claude Sonnet. The reply telling a member to connect Claude links the Account page, links into the app open in place, and the Claude card links the desktop app's downloads. --- packages/api/src/backends/harness.test.ts | 13 +++- packages/api/src/backends/harness.ts | 41 +++++++--- packages/api/src/harness/alerts-route.ts | 9 +-- packages/api/src/harness/inference.test.ts | 32 ++++++-- packages/api/src/harness/inference.ts | 26 ++++--- packages/api/src/harness/route.test.ts | 33 +++++---- packages/api/src/harness/route.ts | 74 ++++++++----------- packages/api/src/intent/classify.test.ts | 43 ++++------- packages/api/src/intent/classify.ts | 19 ++--- packages/api/src/intent/route.test.ts | 23 +----- packages/api/src/intent/route.ts | 19 ++--- packages/api/src/orgs/route.test.ts | 4 +- packages/api/src/utils/env-schema.ts | 4 +- packages/contracts/src/intent.ts | 4 +- .../v059_agents_on_claude/migration.sql | 16 ++++ packages/db/prisma/schema.prisma | 8 +- packages/docs/content/agents/chat.mdx | 31 ++++---- .../docs/content/self-hosting/environment.mdx | 7 +- packages/utils/src/experiments.ts | 6 +- packages/web/src/components/claude-card.tsx | 22 +++++- packages/web/src/components/command-bar.tsx | 15 +--- .../src/components/command-palette.test.tsx | 19 +---- .../web/src/components/command-palette.tsx | 56 ++++++-------- packages/web/src/components/markdown.test.tsx | 32 +++++++- packages/web/src/components/markdown.tsx | 38 +++++++++- packages/web/src/components/nav.test.tsx | 22 ++---- packages/web/src/components/nav.tsx | 19 +---- .../src/components/unread-provider.test.tsx | 15 +--- .../web/src/components/unread-provider.tsx | 4 +- packages/web/src/lib/command-bar.test.ts | 14 ---- packages/web/src/lib/command-bar.ts | 11 +-- packages/web/src/pages/account-page.test.tsx | 10 ++- packages/web/src/pages/account-page.tsx | 3 +- packages/web/src/pages/chat-page.test.tsx | 2 +- packages/web/src/pages/org-start.test.tsx | 14 +--- packages/web/src/pages/org-start.tsx | 6 +- 36 files changed, 358 insertions(+), 356 deletions(-) create mode 100644 packages/db/prisma/migrations/v059_agents_on_claude/migration.sql diff --git a/packages/api/src/backends/harness.test.ts b/packages/api/src/backends/harness.test.ts index a5518b29f..7e6984c7b 100644 --- a/packages/api/src/backends/harness.test.ts +++ b/packages/api/src/backends/harness.test.ts @@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'; import {fakeHarness} from '@proxy/backends/harness/fake'; -import {authFor, cappedHarness, chosenFor, CONNECT_CLAUDE, harnessesOf, harnessFor, needsCredential, NOT_SET_UP} from './harness'; +import {authFor, cappedHarness, chosenFor, CONNECT_CLAUDE, harnessesOf, harnessFor, needsCredential, NOT_SET_UP, offeredTo} from './harness'; const proxy = fakeHarness('proxy'); const sdk = fakeHarness('claude-agent-sdk'); @@ -22,7 +22,16 @@ describe('which harness runs a thread', () => { expect(chosenFor(proxyOnly, {harness: 'claude-agent-sdk', chatModel: 'other'})).toEqual({harness: proxy, model: 'other'}); expect(chosenFor({proxy: null, claudeAgentSdk: sdk}, {harness: 'proxy', chatModel: 'claude'})).toEqual({harness: sdk, model: 'claude'}); expect(chosenFor({proxy: null, claudeAgentSdk: null}, {harness: 'proxy', chatModel: 'claude'})).toBeNull(); - expect(harnessesOf(both)).toEqual([proxy, sdk]); + expect(harnessesOf(both)).toEqual([sdk, proxy]); + expect(chosenFor(both, {harness: 'codex', chatModel: 'other'})).toEqual({harness: sdk, model: 'other'}); + }); + + test('OpenRouter is offered an organization only with the openrouter experiment on, and a persona on it without is read as on Claude', () => { + expect(offeredTo(both, {experiments: ['openrouter']})).toEqual(both); + expect(offeredTo(both, {experiments: []})).toEqual({proxy: null, claudeAgentSdk: sdk}); + expect(offeredTo(both, {experiments: ['sandboxes']})).toEqual({proxy: null, claudeAgentSdk: sdk}); + expect(chosenFor(offeredTo(both, {experiments: []}), {harness: 'proxy', chatModel: 'claude'})).toEqual({harness: sdk, model: 'claude'}); + expect(harnessFor(offeredTo(proxyOnly, {experiments: []}), {harness: 'proxy', chatModel: 'claude'}, null)).toEqual({harness: null, auth: null, why: NOT_SET_UP}); }); test('a turn on the proxy harness takes no credential, whatever the member connected; one on the SDK harness takes the member’s, and a member without one is told to connect it', () => { diff --git a/packages/api/src/backends/harness.ts b/packages/api/src/backends/harness.ts index 55ce8c754..fc48c1f2d 100644 --- a/packages/api/src/backends/harness.ts +++ b/packages/api/src/backends/harness.ts @@ -1,22 +1,24 @@ import type {Result} from 'ts-results-es'; import type {HarnessAuth, HarnessBackend} from '@proxy/backends/harness/backend'; -import type {ApiError} from '@proxy/utils'; +import {type ApiError, type Experiment, hasExperiment} from '@proxy/utils'; import type {ClaudeUsage} from '@proxy/db/claude-account'; import type {PersonaModel} from '@proxy/db/harness'; import type {ClaudeCredential} from '../me/claude-credential'; /** - * Which harness runs a thread's turns (@proxy/backends/harness), of those this server has: the - * api's own loop over OpenRouter, where there is a key for it, and the Claude Agent SDK's, which - * runs as the member, on the Claude credential they connected on the Account page. The choice is + * Which harness runs a thread's turns (@proxy/backends/harness), of those this server has and + * offers the organization (`offeredTo`): the Claude Agent SDK's, which runs as the member, on the + * Claude credential they connected on the Account page, and the api's own loop over OpenRouter, on + * the server's key, where there is one and the organization has the `openrouter` experiment. The choice is * the persona's, made where it is made or changed, with the model it answers with, one of those * the harness lists; its sub-agents' turns go the same way. It is read anew for each turn, so a * persona switched to another harness moves every member's conversation with it from the next * turn on: the log is the record on either, and a harness whose session the thread hasn't got is - * given the log in its place. A harness the server hasn't got, as the SDK harness on a server - * that turned it off, is read as the first it has, and a model the harness hasn't got as its + * given the log in its place. A harness the organization isn't offered, as the SDK harness on a + * server that turned it off, or OpenRouter without the experiment, is read as the first it is, + * and a model the harness hasn't got as its * first. A persona on the SDK harness needs each member's own credential: a member without one * is told to connect it, and one whose credential ran out or was refused to connect it again * (inference.ts), never quietly moved to the server's key, which would bill the organization for @@ -44,7 +46,7 @@ export type Harnesses = {registry: HarnessRegistry; credentials: ClaudeCredentia /** The harness for a turn with the model to answer with and the credential to give it, or none, with what the person is told instead. */ export type HarnessChoice = {harness: HarnessBackend; model: string; auth: HarnessAuth | null} | {harness: null; auth: null; why: string}; -export const NOT_SET_UP = 'Chat is not set up on this server: it needs an OpenRouter key (OPENROUTER_KEY). Everything else works as before.'; +export const NOT_SET_UP = 'Chat is not set up on this server: it has no harness to run agents on. Everything else works as before.'; /** The credential as the harness is handed it: a token goes as a sign-in's access token would, with no scopes, which is how the SDK takes `CLAUDE_CODE_OAUTH_TOKEN`. */ export function authFor(credential: ClaudeCredential): HarnessAuth { @@ -54,18 +56,33 @@ export function authFor(credential: ClaudeCredential): HarnessAuth { return {kind: 'api_key', key: credential.key}; } +// The Account page, where a member connects their Claude account, as a link in what the +// conversation tells them, which the Chat page opens in place. +const ACCOUNT_PAGE = '[Account page](/account)'; + /** What the person is told where the persona runs on their own Claude account and they connected none. */ -export const CONNECT_CLAUDE = 'This agent runs on your own Claude account, and you haven’t connected one. Connect it under Claude on the Account page.'; +export const CONNECT_CLAUDE = `This agent runs on your own Claude account, and you haven’t connected one. Connect it under Claude on the ${ACCOUNT_PAGE}.`; + +/** What the person is told where their Claude credential ran out or was refused. */ +export const RECONNECT_CLAUDE = `Your Claude sign-in has run out or was refused. Connect it again on the ${ACCOUNT_PAGE}.`; + +/** The harnesses of the server's the organization is offered: OpenRouter, on the server's key, only with the `openrouter` experiment on. */ +export function offeredTo(registry: HarnessRegistry, org: {experiments: Experiment[]}): HarnessRegistry { + if (hasExperiment(org, 'openrouter')) { + return registry; + } + return {...registry, proxy: null}; +} -/** The harnesses this server has, in the order a choice among them is offered: the first is what a persona gets that chose none, or one the server hasn't got. */ +/** The harnesses in the registry, in the order a choice among them is offered: the first is what a persona gets that chose none, or one it isn't offered. */ export function harnessesOf(registry: HarnessRegistry): HarnessBackend[] { - return [registry.proxy, registry.claudeAgentSdk].flatMap((harness) => (harness === null ? [] : [harness])); + return [registry.claudeAgentSdk, registry.proxy].flatMap((harness) => (harness === null ? [] : [harness])); } /** - * The persona's choice as this server has it: the harness it chose, or the first the server has + * The persona's choice as the registry has it: the harness it chose, or the first the registry has * where it hasn't got that one, with the model it chose of that harness's, or the harness's first. - * Null on a server with no harness at all. + * Null where the registry has no harness at all. */ export function chosenFor(registry: HarnessRegistry, persona: PersonaModel): {harness: HarnessBackend; model: string} | null { const harnesses = harnessesOf(registry); diff --git a/packages/api/src/harness/alerts-route.ts b/packages/api/src/harness/alerts-route.ts index 5e6d07898..fc2feadab 100644 --- a/packages/api/src/harness/alerts-route.ts +++ b/packages/api/src/harness/alerts-route.ts @@ -9,15 +9,14 @@ import type {PersonaRow} from '@proxy/db/harness'; import type {Alert, alertListSchema} from '@proxy/contracts/alerts'; import type {AuthenticatedRequest} from '../server/middleware/require-auth'; +import {requireViewer} from '../utils/user-org'; import {collectionName, connectionName} from './alerts'; -import {requireChat} from './route'; import type {HarnessStore} from './store'; /** * The Alerts page's routes, under the organization: the member's own alerts, those the agents they * talk with on the Chat page set in their conversations, each with the connections it watches, and - * deleting one. Behind the `harness` experiment, as the Chat page is. Nothing here sets an alert: - * the agent does, asked to in its conversation (tool-job.ts). + * deleting one. Nothing here sets an alert: the agent does, asked to in its conversation (tool-job.ts). */ /** What the Alerts page's routes need of the store. */ @@ -44,7 +43,7 @@ function toAlert(alert: AlertRow & {persona: PersonaRow}, connections: Map, ApiError>> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const alerts = $(await store.listMemberAlerts(viewer.orgId, viewer.userId)); const ids = [...new Set(alerts.flatMap((alert) => alert.connectionIds))]; const connections = new Map($(await store.listConnectionsByIds(viewer.orgId, ids)).map((connection) => [connection.id, connection])); @@ -55,7 +54,7 @@ function handleListAlertsRoute(store: AlertsStore, request: AuthenticatedRequest /** `DELETE /api/orgs/:org/alerts/:alertId`: one of the viewer's alerts; nothing more comes of it. */ function handleDeleteAlertRoute(store: AlertsStore, request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const alertId = request.params.alertId ?? ''; if (!$(await store.deleteAlert(alertId, {orgId: viewer.orgId, userId: viewer.userId}))) { return $(Err(ApiErr.notFound('alert', alertId))); diff --git a/packages/api/src/harness/inference.test.ts b/packages/api/src/harness/inference.test.ts index e7333fda6..4b13d67a6 100644 --- a/packages/api/src/harness/inference.test.ts +++ b/packages/api/src/harness/inference.test.ts @@ -109,7 +109,7 @@ beforeEach(() => { listEntries.mockResolvedValue(Ok([entry('person', 'Hi')])); listSubAgents.mockResolvedValue(Ok([])); store.listThreadAlerts.mockResolvedValue(Ok([])); - getThreadAgent.mockResolvedValue(Ok({agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'proxy', name: 'Finance', orgSlug: 'acme', experiments: []})); + getThreadAgent.mockResolvedValue(Ok({agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'proxy', name: 'Finance', orgSlug: 'acme', experiments: ['openrouter']})); getPersonaModel.mockResolvedValue(Ok({harness: 'proxy', chatModel: 'claude'})); for (const write of [finishJob, completeInference, requeueJob, appendSummary, appendTurnEntries, store.failJob]) { write.mockResolvedValue(Ok(true)); @@ -342,13 +342,13 @@ describe('runInference', () => { expect(askForTurn).not.toHaveBeenCalled(); }); - test('without a harness, which a server without a key has none of, the person is told the server is not set up', async () => { + test('without a harness, as on a server with the SDK harness off and no key, the person is told the server is not set up', async () => { const {runInference} = await import('./inference'); await runInference(store, on(null), job, 'w1', false, clock); expect(askForTurn).not.toHaveBeenCalled(); - expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({text: expect.stringContaining('OPENROUTER_KEY'), toolCalls: []}), {plans: {}}); + expect(completeInference).toHaveBeenCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({text: expect.stringContaining('Chat is not set up'), toolCalls: []}), {plans: {}}); }); test('a model that can’t be reached is tried again later, and the person told after the last try', async () => { @@ -717,7 +717,7 @@ describe('runInference', () => { 'job-1', 'w1', 'thread-1', - expect.objectContaining({text: 'Your Claude sign-in has run out or was refused. Connect it again on the Account page.'}), + expect.objectContaining({text: 'Your Claude sign-in has run out or was refused. Connect it again on the [Account page](/account).'}), { plans: {}, }, @@ -774,7 +774,7 @@ describe('runInference', () => { 'job-1', 'w1', 'thread-1', - expect.objectContaining({text: 'This agent runs on your own Claude account, and you haven’t connected one. Connect it under Claude on the Account page.'}), + expect.objectContaining({text: 'This agent runs on your own Claude account, and you haven’t connected one. Connect it under Claude on the [Account page](/account).'}), {plans: {}}, ); expect(proxy.turns).toHaveLength(0); @@ -848,10 +848,30 @@ describe('runInference', () => { const {runInference} = await import('./inference'); const proxy = fakeHarness('proxy'); getPersonaModel.mockResolvedValue(Ok(null)); + fake.script([ended(session)]); await runInference(store, on(proxy, fake), job, 'w1', false, clock); - expect(proxy.turns.at(-1)).toMatchObject({model: 'claude'}); + expect(fake.turns.at(-1)).toMatchObject({model: 'claude'}); + expect(proxy.turns).toHaveLength(0); + }); + + test('a persona on the proxy harness, in an organization without the openrouter experiment, runs on the member’s Claude account, and one who connected none is told to connect it', async () => { + const {runInference} = await import('./inference'); + const proxy = fakeHarness('proxy'); + getThreadAgent.mockResolvedValue(Ok({agentId: 'agent-1', orgId: 'org-1', ownerUserId: 'user-1', providerId: 'proxy', name: 'Finance', orgSlug: 'acme', experiments: []})); + getPersonaModel.mockResolvedValue(Ok({harness: 'proxy', chatModel: 'other'})); + fake.script([ended(session)]); + + await runInference(store, on(proxy, fake), job, 'w1', false, clock); + + expect(proxy.turns).toHaveLength(0); + expect(fake.turns.at(-1)).toMatchObject({model: 'other', auth: {kind: 'oauth', accessToken: 'tok', scopes: []}}); + + claudeCredentialFor.mockResolvedValue(Ok(null)); + await runInference(store, on(proxy, fake), job, 'w1', false, clock); + expect(proxy.turns).toHaveLength(0); + expect(completeInference).toHaveBeenLastCalledWith('job-1', 'w1', 'thread-1', expect.objectContaining({text: expect.stringContaining('Connect it under Claude')}), {plans: {}}); }); test('a harness with a cap on its turns at once runs that many, the rest waiting their turn', async () => { diff --git a/packages/api/src/harness/inference.ts b/packages/api/src/harness/inference.ts index 5b24756db..65d7a6586 100644 --- a/packages/api/src/harness/inference.ts +++ b/packages/api/src/harness/inference.ts @@ -5,9 +5,8 @@ import {ApiErr, type ApiError, Do, requirePresent, semaphore, type Semaphore} fr import {CALL_INTERRUPTED, type HarnessSession, OUTCOME_UNKNOWN, pendingToolCalls, type ToolCall} from '@proxy/utils/harness'; import type {AssistantTurn, EntryRow, JobRow, NewTurnEntry, RanOn, SubAgentRow, ThreadRow, ThreadSession} from '@proxy/db/harness'; -import {chosenFor, type ClaudeCredentials, type HarnessChoice, type Harnesses, harnessFor, needsCredential} from '../backends/harness'; +import {chosenFor, type ClaudeCredentials, type HarnessChoice, type Harnesses, harnessFor, needsCredential, offeredTo, RECONNECT_CLAUDE} from '../backends/harness'; import {agentInstructions, SCRIPT_TOOL} from '../mcp/tools'; -import {SIGN_IN_EXPIRED} from '../me/claude-token'; import {log, serializeError} from '../observability/log'; import {alertsParagraph} from './alerts'; import {afterAnswer, failureNote, jobLost, planCalls} from './conversation'; @@ -462,24 +461,27 @@ function readyLog(store: InferenceStore, job: JobRow, workerId: string, thread: // What a thread's persona runs on where it is gone, the thread about to go with it: the database's // defaults for a persona. -const NO_PERSONA = {harness: 'proxy', chatModel: 'claude'}; +const NO_PERSONA = {harness: 'claude-agent-sdk', chatModel: 'sonnet'}; -// The harness for a turn on the thread, and the model: its persona's choice as the server has it, -// with the member's credential where the harness runs on it (backends/harness.ts). A credential -// that ran out or was refused is what the person is told; one that couldn't be read for another +// The harness for a turn on the thread, and the model: its persona's choice as the server offers +// the thread's organization, as its login says what that has on, with the member's credential +// where the harness runs on it (backends/harness.ts). A credential that ran out or was refused is +// what the person is told, with where to connect it again; one that couldn't be read for another // reason fails the turn with it. function chooseHarness(store: InferenceStore, harnesses: Harnesses, thread: ThreadRow): Promise> { return Do(async ($) => { + const signedIn = $(requirePresent($(await store.getThreadAgent(thread)), ApiErr.notFound('agent', thread.agentId))); + const registry = offeredTo(harnesses.registry, signedIn); const persona = $(await store.getPersonaModel(thread)) ?? NO_PERSONA; - const chosen = chosenFor(harnesses.registry, persona); - if (chosen === null || !needsCredential(harnesses.registry, chosen.harness)) { - return harnessFor(harnesses.registry, persona, null); + const chosen = chosenFor(registry, persona); + if (chosen === null || !needsCredential(registry, chosen.harness)) { + return harnessFor(registry, persona, null); } const credential = await harnesses.credentials.claudeCredentialFor(thread.userId); if (credential.isErr() && credential.error.kind === 'unavailable') { - return {harness: null, auth: null, why: credential.error.message}; + return {harness: null, auth: null, why: RECONNECT_CLAUDE}; } - return harnessFor(harnesses.registry, persona, $(credential)); + return harnessFor(registry, persona, $(credential)); }); } @@ -513,7 +515,7 @@ export function runInference(store: InferenceStore, harnesses: Harnesses, job: J if (marked.isErr()) { log.warn('The model turned the member’s Claude credential down, which could not be noted on their account', {userId: thread.userId, ...serializeError(marked.error)}); } - $(await answerFailure(store, job, workerId, thread, SIGN_IN_EXPIRED)); + $(await answerFailure(store, job, workerId, thread, RECONNECT_CLAUDE)); return; } if (outcome.kind === 'failed' && outcome.limitReached) { diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index fd2f58400..52c9cf1de 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -8,7 +8,7 @@ import type {EntryRow, JobRow, SubAgentRow, ThreadRow} from '@proxy/db/harness'; import type {HarnessRegistry} from '../backends/harness'; import {type FakeStore, fakeStore} from './fake-store'; -const viewer = {orgId: 'org-1', userId: 'user-1', slug: 'acme', name: 'Acme', role: 'member' as 'member' | 'owner', onboarded: true, experiments: ['harness']}; +const viewer = {orgId: 'org-1', userId: 'user-1', slug: 'acme', name: 'Acme', role: 'member' as 'member' | 'owner', onboarded: true, experiments: ['openrouter']}; const requireViewer = mock(async () => Ok(viewer)); mock.module('../utils/user-org', () => ({requireViewer})); @@ -33,7 +33,8 @@ const personaResponse = { }; // The harnesses the server has, unless a test says otherwise: the loop over OpenRouter, with its -// two models, and the Claude Agent SDK's, with its three. +// two models, which the viewer's organization is offered with the `openrouter` experiment, and +// the Claude Agent SDK's, with its three. const proxy: HarnessBackend = { ...fakeHarness('proxy'), label: 'OpenRouter', @@ -53,8 +54,8 @@ const sdk: HarnessBackend = { }; let registry: HarnessRegistry = {proxy, claudeAgentSdk: sdk}; const offered = [ - {id: 'proxy', label: 'OpenRouter', models: proxy.models}, {id: 'claude-agent-sdk', label: 'Claude', models: sdk.models}, + {id: 'proxy', label: 'OpenRouter', models: proxy.models}, ]; const thread: ThreadRow = { id: 'thread-1', @@ -148,14 +149,23 @@ beforeEach(async () => { }); describe('the chat routes', () => { - test('are refused without the experiment', async () => { - const {handleListPersonasRoute, handleListAnswersRoute, handlePostMessageRoute} = routes; + test('offer OpenRouter only with the openrouter experiment: without it, a persona on it is shown on Claude, and none is made or switched to it', async () => { + const {handleCreatePersonaRoute, handleListPersonasRoute, handleUpdatePersonaRoute} = routes; requireViewer.mockResolvedValue(Ok({...viewer, experiments: []})); + store.listPersonas.mockResolvedValue(Ok([{...persona, ownerUserId: null, lastMessage: null, draft: null}])); - expect((await handleListPersonasRoute(request())).unwrapErr().kind).toBe('forbidden'); - expect((await handleListAnswersRoute(request())).unwrapErr().kind).toBe('forbidden'); - expect((await handlePostMessageRoute(request({personaId: 'persona-1'}, {text: 'Hi'}))).unwrapErr().kind).toBe('forbidden'); - expect(store.appendPersonMessage).not.toHaveBeenCalled(); + const listed = (await handleListPersonasRoute(request())).unwrap(); + expect(listed.harnesses.map((harness) => harness.id)).toEqual(['claude-agent-sdk']); + expect(listed.personas.map(({harness, chatModel}) => [harness, chatModel])).toEqual([['claude-agent-sdk', 'sonnet']]); + + const body = {name: 'Finance', look, color: 'blue', harness: 'proxy'}; + expect((await handleCreatePersonaRoute(request({}, body))).unwrapErr()).toMatchObject({kind: 'validation_error', message: 'This server has no harness proxy.'}); + expect((await handleUpdatePersonaRoute(request({personaId: 'persona-1'}, body))).unwrapErr().kind).toBe('validation_error'); + expect(store.createPersona).not.toHaveBeenCalled(); + expect(store.updatePersona).not.toHaveBeenCalled(); + + await handleCreatePersonaRoute(request({}, {name: 'Finance', look, color: 'blue'})); + expect(store.createPersona).toHaveBeenCalledWith('org-1', 'user-1', {name: 'Finance', look, color: 'blue', harness: 'claude-agent-sdk', chatModel: 'sonnet'}); }); test('list the personas the viewer sees, their own and every member’s, with their heads, what runs each, whether the viewer may change each, and the last thing said with each and what the viewer typed and hasn’t sent, on one line, and the harnesses the server has with their models', async () => { @@ -227,7 +237,7 @@ describe('the chat routes', () => { const made = await handleCreatePersonaRoute(request({}, {name: ' Finance ', look, color: 'blue'})); expect(ensureBuiltInAgent).toHaveBeenCalledWith('org-1'); - expect(store.createPersona).toHaveBeenCalledWith('org-1', 'user-1', {name: 'Finance', look, color: 'blue', harness: 'proxy', chatModel: 'claude'}); + expect(store.createPersona).toHaveBeenCalledWith('org-1', 'user-1', {name: 'Finance', look, color: 'blue', harness: 'claude-agent-sdk', chatModel: 'sonnet'}); expect(made.unwrap().persona).toEqual({...personaResponse, id: 'persona-2'}); }); @@ -497,9 +507,6 @@ describe('the chat routes', () => { store.getMemberThread.mockResolvedValue(Ok(null)); expect((await handlePostTaskMessageRoute(request({personaId: 'persona-1', taskId: 'sub-1'}, {text: 'Hi'}))).unwrapErr().kind).toBe('not_found'); expect(store.messageSubAgentDirectly).toHaveBeenCalledTimes(2); - - requireViewer.mockResolvedValue(Ok({...viewer, experiments: []})); - expect((await handlePostTaskMessageRoute(request({personaId: 'persona-1', taskId: 'sub-1'}, {text: 'Hi'}))).unwrapErr().kind).toBe('forbidden'); }); test('give what the viewer typed in the conversation and hasn’t sent, whole', async () => { diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts index 55a7ebc43..f63f39e55 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -1,7 +1,7 @@ import {Err, Ok, type Result} from 'ts-results-es'; import type {z} from 'zod'; -import {ApiErr, type ApiError, Do, hasExperiment, parseSchema, requirePresent} from '@proxy/utils'; +import {ApiErr, type ApiError, Do, parseSchema, requirePresent} from '@proxy/utils'; import { callArguments, CREATE_SUBAGENT_TOOL, @@ -35,7 +35,7 @@ import { type ThreadLog, } from '@proxy/contracts/harness'; -import {chosenFor, harnessesOf, type HarnessRegistry} from '../backends/harness'; +import {chosenFor, harnessesOf, type HarnessRegistry, offeredTo} from '../backends/harness'; import type {AuthenticatedRequest} from '../server/middleware/require-auth'; import {manages} from '../utils/manages'; import {type OrgViewer, requireViewer} from '../utils/user-org'; @@ -50,8 +50,9 @@ import type {HarnessStore} from './store'; * and what it and its sub-agents are up to. Each sub-agent is a task to the member, shown where it * was started, with a chat of its own: what it and the persona said to each other, and what the * member writes to it there. A persona - * says which of the server's harnesses runs it and which of that harness's models it answers with - * (backends/harness.ts), chosen from the conversation's input by whoever may change it. Behind the `harness` experiment. Nothing here runs the model: a message queues a job, and + * says which of the harnesses the server offers the organization runs it and which of that + * harness's models it answers with (backends/harness.ts), chosen from the conversation's input by + * whoever may change it. 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. */ @@ -90,9 +91,9 @@ function managesPersona(viewer: OrgViewer, persona: PersonaRow): boolean { function toPersona(harnesses: Harnesses, viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null; draft?: string | null}): ChatPersona { const last = persona.lastMessage ?? null; - // What runs it as this server has it, which is what a turn of its gets; as kept on a server - // with no harness at all, where nothing runs. - const chosen = chosenFor(harnesses(), persona); + // What runs it as this server offers the organization, which is what a turn of its gets; as + // kept where there is no harness at all, where nothing runs. + const chosen = chosenFor(offeredTo(harnesses(), viewer), persona); return { id: persona.id, name: persona.name, @@ -108,17 +109,17 @@ function toPersona(harnesses: Harnesses, viewer: OrgViewer, persona: PersonaRow }; } -// The harnesses a persona can run on here, as the dialog offers them. -function toHarnesses(registry: HarnessRegistry): ChatHarness[] { - return harnessesOf(registry).map(({id, label, models}) => ({id, label, models})); +// The harnesses a persona of the viewer's organization can run on here, as the dialog offers them. +function toHarnesses(registry: HarnessRegistry, viewer: OrgViewer): ChatHarness[] { + return harnessesOf(offeredTo(registry, viewer)).map(({id, label, models}) => ({id, label, models})); } // The persona as the person made or changed it. What runs it is as they chose, where the server -// has it: a harness named has to be one of the server's, and a model named one of the harness's; -// the model left out is the harness's first, or the persona's own where the harness stays. With -// neither named, one changed (`existing`) keeps what it has, and a new one gets the server's -// first harness with its first model. -function readPersona(harnesses: Harnesses, request: AuthenticatedRequest, existing: PersonaModel | null): Result { +// offers it the organization: a harness named has to be one of those, and a model named one of +// the harness's; the model left out is the harness's first, or the persona's own where the harness +// stays. With neither named, one changed (`existing`) keeps what it has, and a new one gets the +// first harness offered with its first model. +function readPersona(harnesses: Harnesses, viewer: OrgViewer, request: AuthenticatedRequest, existing: PersonaModel | null): Result { const parsed = parseSchema(personaBodySchema, request.body); if (parsed.isErr()) { return Err(parsed.error); @@ -128,7 +129,7 @@ function readPersona(harnesses: Harnesses, request: AuthenticatedRequest, existi return Ok(persona); } const sameHarness = harness === undefined || harness === existing?.harness; - const chosen = chosenFor(harnesses(), {harness: harness ?? existing?.harness ?? '', chatModel: chatModel ?? (sameHarness ? (existing?.chatModel ?? '') : '')}); + const chosen = chosenFor(offeredTo(harnesses(), viewer), {harness: harness ?? existing?.harness ?? '', chatModel: chatModel ?? (sameHarness ? (existing?.chatModel ?? '') : '')}); if (chosen === null) { return Ok(persona); } @@ -213,17 +214,6 @@ function taskChatEntries(subAgent: SubAgentRow): (entry: EntryRow) => TaskChatEn }; } -/** The viewer, in an organization with Chat on: the Chat page's routes and the Alerts page's take it. */ -export function requireChat(request: AuthenticatedRequest): Promise> { - return Do(async ($) => { - const viewer = $(await requireViewer(request)); - if (!hasExperiment(viewer, 'harness')) { - return $(Err(ApiErr.forbidden())); - } - return viewer; - }); -} - // The persona in the address, one of the organization's the viewer sees: another member's own is not found. function requirePersona(store: ChatStore, viewer: OrgViewer, request: AuthenticatedRequest): Promise> { return Do(async ($) => { @@ -259,10 +249,10 @@ function requireThread(store: ChatStore, viewer: OrgViewer, persona: PersonaRow) /** `GET /api/orgs/:org/chat/personas`: the organization's personas the viewer sees, their own and those every member does, the one last active in the viewer's conversation first, with the viewer's conversations, the tasks they pinned in them, and the harnesses the server has for one to run on; a General one, every member's, is made where there is none, after the viewer's own, so a message that names no agent always has one to go to. */ function handleListPersonasRoute(store: ChatStore, harnesses: Harnesses, request: AuthenticatedRequest): Promise, ApiError>> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const [found, pinned] = await Promise.all([store.listPersonas(viewer.orgId, viewer.userId), store.listPinnedTasks(viewer.orgId, viewer.userId)]); const personas = $(found); - const offered = toHarnesses(harnesses()); + const offered = toHarnesses(harnesses(), viewer); const pinnedTasks = $(pinned).map(({id, personaId, title}) => ({id, personaId, title})); const listed = personas.map((persona) => toPersona(harnesses, viewer, persona)); if (personas.some((persona) => persona.ownerUserId === null)) { @@ -276,7 +266,7 @@ function handleListPersonasRoute(store: ChatStore, harnesses: Harnesses, request /** `GET /api/orgs/:org/chat/answers`: the persona's last answer in each of the viewer's conversations that has one, and whether they haven't seen it; read only, for every page to ask every so often. */ function handleListAnswersRoute(store: ChatStore, request: AuthenticatedRequest): Promise, ApiError>> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const answers = $(await store.listAnswers(viewer.orgId, viewer.userId)); return { answers: answers.map(({personaId, personaName, answer, unread}) => ({ @@ -292,8 +282,8 @@ function handleListAnswersRoute(store: ChatStore, request: AuthenticatedRequest) /** `POST /api/orgs/:org/chat/personas` with `{name, look, color, harness?, chatModel?}`: a new persona, the viewer's own, and the organization's built-in login with it where there is none yet. */ function handleCreatePersonaRoute(store: ChatStore, harnesses: Harnesses, request: AuthenticatedRequest): Promise, ApiError>> { return Do(async ($) => { - const viewer = $(await requireChat(request)); - const persona = $(readPersona(harnesses, request, null)); + const viewer = $(await requireViewer(request)); + const persona = $(readPersona(harnesses, viewer, request, null)); $(await ensureBuiltInAgent(viewer.orgId)); const made = $(await store.createPersona(viewer.orgId, viewer.userId, persona)); return {persona: toPersona(harnesses, viewer, made)}; @@ -303,9 +293,9 @@ function handleCreatePersonaRoute(store: ChatStore, harnesses: Harnesses, reques /** `PUT /api/orgs/:org/chat/personas/:personaId` with `{name, look, color, harness?, chatModel?}`: the persona renamed, with another head, or switched to another harness or model from its next turn on, every conversation with it and its sub-agents'. */ function handleUpdatePersonaRoute(store: ChatStore, harnesses: Harnesses, request: AuthenticatedRequest): Promise, ApiError>> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const existing = $(await requireManagedPersona(store, viewer, request)); - const persona = $(readPersona(harnesses, request, existing)); + const persona = $(readPersona(harnesses, viewer, request, existing)); if (!$(await store.updatePersona(viewer.orgId, existing.id, persona))) { return $(Err(ApiErr.notFound('persona', existing.id))); } @@ -316,7 +306,7 @@ function handleUpdatePersonaRoute(store: ChatStore, harnesses: Harnesses, reques /** `DELETE /api/orgs/:org/chat/personas/:personaId`: the persona, with every conversation with it. */ function handleDeletePersonaRoute(store: ChatStore, request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const persona = $(await requireManagedPersona(store, viewer, request)); if (!$(await store.deletePersona(viewer.orgId, persona.id))) { return $(Err(ApiErr.notFound('persona', persona.id))); @@ -327,7 +317,7 @@ function handleDeletePersonaRoute(store: ChatStore, request: AuthenticatedReques /** `GET /api/orgs/:org/chat/personas/:personaId/thread`: the viewer's conversation with the persona, begun where there was none, what they said while it was answering at the end, the tasks the persona started in it and what it sent them, whether the persona is busy on it, its sub-agents included, and what they typed in it and haven't sent. */ function handleGetThreadRoute(store: ChatStore, harnesses: Harnesses, request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const persona = $(await requirePersona(store, viewer, request)); const thread = $(await requireThread(store, viewer, persona)); const now = new Date(); @@ -351,7 +341,7 @@ function handleGetThreadRoute(store: ChatStore, harnesses: Harnesses, request: A /** `POST /api/orgs/:org/chat/personas/:personaId/messages` with `{text}`: what the viewer says to the persona; it is woken. */ function handlePostMessageRoute(store: ChatStore, request: AuthenticatedRequest): Promise, ApiError>> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const {text} = $(parseSchema(postMessageBodySchema, request.body)); const persona = $(await requirePersona(store, viewer, request)); const thread = $(await requireThread(store, viewer, persona)); @@ -371,7 +361,7 @@ function requireExistingThread(store: ChatStore, viewer: OrgViewer, persona: Per /** `GET /api/orgs/:org/chat/personas/:personaId/tasks/:taskId`: a task the persona started in the viewer's conversation with it, as its chat shows it: what it is up to, and what it, the persona and the viewer said in it. */ function handleGetTaskRoute(store: ChatStore, harnesses: Harnesses, request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const taskId = request.params.taskId ?? ''; const persona = $(await requirePersona(store, viewer, request)); const thread = $(await requireExistingThread(store, viewer, persona, taskId)); @@ -391,7 +381,7 @@ function handleGetTaskRoute(store: ChatStore, harnesses: Harnesses, request: Aut /** `POST /api/orgs/:org/chat/personas/:personaId/tasks/:taskId/messages` with `{text}`: what the viewer writes to a task of their conversation with the persona, in its chat; the task takes it up, and the persona is told, but not woken by it: the task's answer wakes it. */ function handlePostTaskMessageRoute(store: ChatStore, request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const taskId = request.params.taskId ?? ''; const {text} = $(parseSchema(postMessageBodySchema, request.body)); const persona = $(await requirePersona(store, viewer, request)); @@ -405,7 +395,7 @@ function handlePostTaskMessageRoute(store: ChatStore, request: AuthenticatedRequ /** `PUT /api/orgs/:org/chat/personas/:personaId/draft` with `{text}`: what the viewer has typed to the persona and not sent, kept in place of what was; nothing but whitespace forgets it. */ function handleSaveDraftRoute(store: ChatStore, request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const {text} = $(parseSchema(saveDraftBodySchema, request.body)); const persona = $(await requirePersona(store, viewer, request)); const thread = $(await requireThread(store, viewer, persona)); @@ -418,7 +408,7 @@ function handleSaveDraftRoute(store: ChatStore, request: AuthenticatedRequest): /** `POST /api/orgs/:org/chat/personas/:personaId/read` with `{entryId}`: the viewer was shown their conversation with the persona up to that answer; what the persona said after it stays unread. */ function handleMarkReadRoute(store: ChatStore, request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const {entryId} = $(parseSchema(markReadBodySchema, request.body)); const persona = $(await requirePersona(store, viewer, request)); // Read, never begun: a viewer without a conversation has no answer in it to have seen. @@ -432,7 +422,7 @@ function handleMarkReadRoute(store: ChatStore, request: AuthenticatedRequest): P /** `PUT /api/orgs/:org/chat/personas/:personaId/tasks/:taskId/pin` with `{pinned}`: a task of the viewer's conversation with the persona pinned to the list under it, or unpinned. */ function handlePinTaskRoute(store: ChatStore, request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const viewer = $(await requireChat(request)); + const viewer = $(await requireViewer(request)); const taskId = request.params.taskId ?? ''; const {pinned} = $(parseSchema(pinTaskBodySchema, request.body)); const persona = $(await requirePersona(store, viewer, request)); diff --git a/packages/api/src/intent/classify.test.ts b/packages/api/src/intent/classify.test.ts index 2865b333f..1e918ad35 100644 --- a/packages/api/src/intent/classify.test.ts +++ b/packages/api/src/intent/classify.test.ts @@ -1,7 +1,5 @@ import {beforeEach, describe, expect, mock, spyOn, test} from 'bun:test'; -import {WIDGETS} from '@proxy/contracts/intent'; - const mockEnv: {TYPESAFE_API_KEY?: string; OPENROUTER_KEY?: string} = {}; mock.module('../utils/env', () => ({env: mockEnv})); @@ -62,7 +60,7 @@ beforeEach(() => { describe('pickIntent', () => { test('asks Jev which widget fits, among the widgets, incomplete and none, and fills nothing in', async () => { const {pickIntent} = await import('./classify'); - expect((await pickIntent(text, WIDGETS)).unwrap()).toMatchObject({widget: 'new_event'}); + expect((await pickIntent(text)).unwrap()).toMatchObject({widget: 'new_event'}); const [jev] = calls('https://api.typesafe.ai/'); expect(jev?.headers.authorization).toBe('Bearer ts-1'); @@ -97,7 +95,7 @@ describe('pickIntent', () => { const {pickIntent} = await import('./classify'); pick('ask'); - expect((await pickIntent('what did I talk to sam about yesterday', WIDGETS)).unwrap()).toMatchObject({widget: 'ask'}); + expect((await pickIntent('what did I talk to sam about yesterday')).unwrap()).toMatchObject({widget: 'ask'}); expect(asked().questions.widget.criteria.ask).toContain('Ask what one meeting'); }); @@ -109,36 +107,21 @@ describe('pickIntent', () => { ['make_coffee', 'none'], ]) { pick(picked ?? ''); - expect((await pickIntent('hi', WIDGETS)).unwrap()).toMatchObject({widget}); + expect((await pickIntent('hi')).unwrap()).toMatchObject({widget}); } }); test('Jev failing is a provider out of reach', async () => { const {pickIntent} = await import('./classify'); jevStatus = 503; - expect((await pickIntent(text, WIDGETS)).unwrapErr().kind).toBe('provider_unreachable'); - }); - test('offers New agent only with Chat: without it, Jev is asked about every other widget, and it is filled in for nobody', async () => { - const {fillIntent, pickIntent, widgetsOffered} = await import('./classify'); - const text = 'add an agent that reminds me to stretch every hour'; - - await pickIntent(text, widgetsOffered(true)); - expect(Object.keys(asked().questions.widget.criteria)).toContain('new_agent'); - - await pickIntent(text, widgetsOffered(false)); - expect(Object.keys(asked().questions.widget.criteria)).not.toContain('new_agent'); - expect(Object.keys(asked().questions.widget.criteria)).toContain('new_event'); - // Picked all the same, as by an older answer, it is no widget. - pick('new_agent'); - expect((await pickIntent(text, widgetsOffered(false))).unwrap()).toMatchObject({widget: 'none'}); - expect((await fillIntent(text, 'new_agent', widgetsOffered(false))).unwrapErr()).toMatchObject({kind: 'not_found', resource: 'widget'}); + expect((await pickIntent(text)).unwrapErr().kind).toBe('provider_unreachable'); }); }); describe('fillIntent', () => { test("asks gpt-oss-120b, on Cerebras first, for the widget's fields alone", async () => { const {fillIntent} = await import('./classify'); - await fillIntent(text, 'new_event', WIDGETS); + await fillIntent(text, 'new_event'); const body = sent(); expect(body.model).toBe('openai/gpt-oss-120b'); @@ -155,7 +138,7 @@ describe('fillIntent', () => { test("answers the widget's fields, each value trimmed and once", async () => { const {fillIntent} = await import('./classify'); - const filled = (await fillIntent(text, 'new_event', WIDGETS)).unwrap(); + const filled = (await fillIntent(text, 'new_event')).unwrap(); expect(filled.fields).toEqual([ {id: 'attendees', name: 'Attendees', values: ['Jon Burke', 'Jacky Doe']}, @@ -167,7 +150,7 @@ describe('fillIntent', () => { test('answers search terms as each one\'s words joined by " | "', async () => { answer({from: null, about: [['flight', ' itinerary ', 'e|ticket'], ['SFO'], []]}); const {fillIntent} = await import('./classify'); - const filled = (await fillIntent('find the email about my flight to sfo', 'find_email', WIDGETS)).unwrap(); + const filled = (await fillIntent('find the email about my flight to sfo', 'find_email')).unwrap(); expect(filled.fields).toEqual([ {id: 'from', name: 'From', values: []}, @@ -180,7 +163,7 @@ describe('fillIntent', () => { const {fillIntent} = await import('./classify'); answer({merchant: 'uber', category: 'TRANSPORTATION', flow: 'out', transactionAccount: null}); - expect((await fillIntent('uber charges this month', 'find_transactions', WIDGETS)).unwrap().fields).toMatchObject([ + expect((await fillIntent('uber charges this month', 'find_transactions')).unwrap().fields).toMatchObject([ {id: 'merchant', values: ['uber']}, {id: 'category', values: ['TRANSPORTATION']}, {id: 'flow', values: ['out']}, @@ -190,7 +173,7 @@ describe('fillIntent', () => { fetchSpy.mockClear(); for (const widget of ['none', 'make_coffee']) { - expect((await fillIntent('uber charges this month', widget, WIDGETS)).unwrapErr()).toMatchObject({kind: 'not_found', resource: 'widget', id: widget}); + expect((await fillIntent('uber charges this month', widget)).unwrapErr()).toMatchObject({kind: 'not_found', resource: 'widget', id: widget}); } expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -198,7 +181,7 @@ describe('fillIntent', () => { test('holds a unit to the units there are', async () => { answer({amount: '5', fromUnit: 'mi', toUnit: 'km'}); const {fillIntent} = await import('./classify'); - await fillIntent('5 miles in km', 'convert_units', WIDGETS); + await fillIntent('5 miles in km', 'convert_units'); const properties = sent().response_format.json_schema.schema.properties as Record}>; expect(properties.fromUnit?.enum).toContain('mi'); @@ -209,10 +192,10 @@ describe('fillIntent', () => { const {fillIntent} = await import('./classify'); openRouterStatus = 503; - expect((await fillIntent(text, 'new_event', WIDGETS)).unwrapErr().kind).toBe('provider_unreachable'); + expect((await fillIntent(text, 'new_event')).unwrapErr().kind).toBe('provider_unreachable'); openRouterStatus = 200; content = 'Sure! Here is the form:'; - expect((await fillIntent(text, 'new_event', WIDGETS)).unwrapErr().kind).toBe('provider_unreachable'); + expect((await fillIntent(text, 'new_event')).unwrapErr().kind).toBe('provider_unreachable'); }); }); @@ -223,7 +206,7 @@ test('picking and filling in are unavailable without either key, and ask nothing mockEnv.TYPESAFE_API_KEY = 'ts-1'; mockEnv.OPENROUTER_KEY = 'sk-or-1'; delete mockEnv[key]; - for (const result of [await pickIntent(text, WIDGETS), await fillIntent(text, 'new_event', WIDGETS)]) { + for (const result of [await pickIntent(text), await fillIntent(text, 'new_event')]) { expect(result.unwrapErr()).toMatchObject({kind: 'unavailable', message: 'Quick actions are not set up: set `TYPESAFE_API_KEY` and `OPENROUTER_KEY`.'}); } } diff --git a/packages/api/src/intent/classify.ts b/packages/api/src/intent/classify.ts index 551111b85..d2781459d 100644 --- a/packages/api/src/intent/classify.ts +++ b/packages/api/src/intent/classify.ts @@ -2,7 +2,7 @@ import {Err, Ok, type Result} from 'ts-results-es'; import {z} from 'zod'; import {ApiErr, type ApiError, Do, httpRequest} from '@proxy/utils'; -import {CHAT_WIDGET_IDS, type Filled, type FilledField, INCOMPLETE, NO_WIDGET, type Picked, type Widget, type WidgetField, WIDGETS} from '@proxy/contracts/intent'; +import {type Filled, type FilledField, INCOMPLETE, NO_WIDGET, type Picked, type Widget, type WidgetField, WIDGETS} from '@proxy/contracts/intent'; import {askForJson} from '../llm/open-router'; import {env} from '../utils/env'; @@ -144,26 +144,21 @@ function keys(): Result<{typesafe: string; openRouter: string}, ApiError> { return Ok({typesafe: env.TYPESAFE_API_KEY, openRouter: env.OPENROUTER_KEY}); } -/** The widgets a person is offered: every one, those that work with Chat's agents only `withChat`. */ -export function widgetsOffered(withChat: boolean): readonly Widget[] { - return withChat ? WIDGETS : WIDGETS.filter((widget) => !CHAT_WIDGET_IDS.includes(widget.id)); -} - -/** Which of the widgets `offered` fits `text`, as Jev picks it. */ -export function pickIntent(text: string, offered: readonly Widget[]): Promise> { +/** Which of the widgets fits `text`, as Jev picks it. */ +export function pickIntent(text: string): Promise> { return Do(async ($) => { const {typesafe} = $(keys()); const start = performance.now(); - const picked = $(await pickWidget(text, offered, typesafe)); + const picked = $(await pickWidget(text, WIDGETS, typesafe)); return {widget: typeof picked === 'string' ? picked : picked.id, ms: Math.round(performance.now() - start)}; }); } -/** The fields of `widgetId`, one of the widgets `offered`, as gpt-oss-120b fills them in from `text`. */ -export function fillIntent(text: string, widgetId: string, offered: readonly Widget[]): Promise> { +/** The fields of `widgetId`, one of the widgets, as gpt-oss-120b fills them in from `text`. */ +export function fillIntent(text: string, widgetId: string): Promise> { return Do(async ($) => { const {openRouter} = $(keys()); - const widget = offered.find((candidate) => candidate.id === widgetId); + const widget = WIDGETS.find((candidate) => candidate.id === widgetId); if (!widget) { return $(Err(ApiErr.notFound('widget', widgetId))); } diff --git a/packages/api/src/intent/route.test.ts b/packages/api/src/intent/route.test.ts index 47d97714d..04d253572 100644 --- a/packages/api/src/intent/route.test.ts +++ b/packages/api/src/intent/route.test.ts @@ -3,13 +3,10 @@ import {Ok} from 'ts-results-es'; const pickIntent = mock(); const fillIntent = mock(); -// The widgets offered, as told apart by whether Chat's are among them. -const widgetsOffered = (withChat: boolean) => (withChat ? ['with chat'] : ['without chat']); -mock.module('./classify', () => ({pickIntent, fillIntent, widgetsOffered})); +mock.module('./classify', () => ({pickIntent, fillIntent})); -let experiments: string[] = []; -mock.module('../utils/user-org', () => ({requireViewer: async () => Ok({orgId: 'org-1', userId: 'user-1', slug: 'org-1', name: 'Workspace', role: 'owner', onboarded: true, experiments})})); +mock.module('../utils/user-org', () => ({requireViewer: async () => Ok({orgId: 'org-1', userId: 'user-1', slug: 'org-1', name: 'Workspace', role: 'owner', onboarded: true, experiments: []})})); function makeRequest(body: unknown) { return {user: {userId: 'user-1'}, params: {org: 'org-1'}, body} as never; @@ -17,7 +14,6 @@ function makeRequest(body: unknown) { beforeEach(() => { mock.clearAllMocks(); - experiments = []; pickIntent.mockResolvedValue(Ok({widget: 'none', ms: 1})); fillIntent.mockResolvedValue(Ok({fields: [], ms: 1})); }); @@ -28,18 +24,7 @@ describe('handlePickIntentRoute', () => { const result = await handlePickIntentRoute(makeRequest({text: ' who is hugo '})); expect(result.isOk()).toBe(true); - expect(pickIntent).toHaveBeenCalledWith('who is hugo', ['without chat']); - }); - - test('offers the widgets that work with Chat’s agents in an organization that has Chat', async () => { - const {handleFillIntentRoute, handlePickIntentRoute} = await import('./route'); - experiments = ['harness']; - - await handlePickIntentRoute(makeRequest({text: 'add an agent for my finances'})); - await handleFillIntentRoute(makeRequest({text: 'add an agent for my finances', widget: 'new_agent'})); - - expect(pickIntent).toHaveBeenCalledWith('add an agent for my finances', ['with chat']); - expect(fillIntent).toHaveBeenCalledWith('add an agent for my finances', 'new_agent', ['with chat']); + expect(pickIntent).toHaveBeenCalledWith('who is hugo'); }); test('refuses empty text, and text past 500 characters, without asking the models', async () => { @@ -58,7 +43,7 @@ describe('handleFillIntentRoute', () => { const result = await handleFillIntentRoute(makeRequest({text: ' who is hugo ', widget: 'find_person'})); expect(result.isOk()).toBe(true); - expect(fillIntent).toHaveBeenCalledWith('who is hugo', 'find_person', ['without chat']); + expect(fillIntent).toHaveBeenCalledWith('who is hugo', 'find_person'); }); test('refuses a request without a widget, or with text that is empty or past 500 characters', async () => { diff --git a/packages/api/src/intent/route.ts b/packages/api/src/intent/route.ts index 99ae8e85c..a2ce103b8 100644 --- a/packages/api/src/intent/route.ts +++ b/packages/api/src/intent/route.ts @@ -1,23 +1,18 @@ import type {Result} from 'ts-results-es'; -import {type ApiError, Do, hasExperiment, parseSchema} from '@proxy/utils'; +import {type ApiError, Do, parseSchema} from '@proxy/utils'; import {fillBodySchema, type Filled, type Picked, pickBodySchema} from '@proxy/contracts/intent'; import type {AuthenticatedRequest} from '../server/middleware/require-auth'; -import {type OrgViewer, requireViewer} from '../utils/user-org'; -import {fillIntent, pickIntent, widgetsOffered} from './classify'; - -// The widgets the viewer is offered: New agent only in an organization that has Chat. -function offeredTo(viewer: OrgViewer) { - return widgetsOffered(hasExperiment(viewer, 'harness')); -} +import {requireViewer} from '../utils/user-org'; +import {fillIntent, pickIntent} from './classify'; /** `POST /api/orgs/:org/intent` with `{text}` — which widget fits what the command bar is asked for, as it is typed. */ export function handlePickIntentRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { const body = $(parseSchema(pickBodySchema, request.body)); - const viewer = $(await requireViewer(request)); - return $(await pickIntent(body.text, offeredTo(viewer))); + $(await requireViewer(request)); + return $(await pickIntent(body.text)); }); } @@ -25,7 +20,7 @@ export function handlePickIntentRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { const body = $(parseSchema(fillBodySchema, request.body)); - const viewer = $(await requireViewer(request)); - return $(await fillIntent(body.text, body.widget, offeredTo(viewer))); + $(await requireViewer(request)); + return $(await fillIntent(body.text, body.widget)); }); } diff --git a/packages/api/src/orgs/route.test.ts b/packages/api/src/orgs/route.test.ts index b54c71f8c..a25984ab2 100644 --- a/packages/api/src/orgs/route.test.ts +++ b/packages/api/src/orgs/route.test.ts @@ -119,13 +119,13 @@ describe('handleListOrgsRoute', () => { describe('handleGetOrgRoute', () => { test('answers the organization in the address, with the person’s role and its experiments', async () => { - getMemberOrganization.mockResolvedValue(Ok({...membership('member'), experiments: ['harness']})); + getMemberOrganization.mockResolvedValue(Ok({...membership('member'), experiments: ['openrouter']})); listUserOrgs.mockResolvedValue(Ok([mine, {...acme, role: 'member' as const}])); const {handleGetOrgRoute} = await import('./route'); const result = await handleGetOrgRoute(makeRequest()); expect(getMemberOrganization).toHaveBeenCalledWith('user-1', 'acme'); - expect(result.unwrap()).toEqual({...acme, role: 'member', experiments: ['harness']}); + expect(result.unwrap()).toEqual({...acme, role: 'member', experiments: ['openrouter']}); }); test('one the person isn’t in is not found, as is one that doesn’t exist', async () => { diff --git a/packages/api/src/utils/env-schema.ts b/packages/api/src/utils/env-schema.ts index 723f9ff41..5d5a58c4b 100644 --- a/packages/api/src/utils/env-schema.ts +++ b/packages/api/src/utils/env-schema.ts @@ -132,12 +132,12 @@ 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 in the command bar (gpt-oss-120b, on Cerebras or Groq), and for the built-in agent on the Chat page (Claude Sonnet 5.5, or GLM 5.3 where an agent is set to it). What is typed there is sent to it. Without it, or without `TYPESAFE_API_KEY`, the command bar says it is not set up, and without it the Chat page does; everything else works as before.', + 'An OpenRouter API key, for the model that fills in the fields of the widget Jev picks in the command bar (gpt-oss-120b, on Cerebras or Groq), and for the built-in agent on the Chat page in an organization with the `openrouter` experiment on, where an agent can be set to run on it (Claude Sonnet 5.5, or GLM 5.3). What is typed there is sent to it. Without it, or without `TYPESAFE_API_KEY`, the command bar says it is not set up; everything else works as before.', }), CLAUDE_AGENT_SDK: documented(z.enum(['on', 'off']).default('on'), { group: 'Core', description: - 'Whether an agent on the Chat page can be set to run on the Claude Agent SDK, under each member’s own Claude account, connected on the Account page: their conversations with it then go to Anthropic directly, as from Claude Code, and count against their plan rather than `OPENROUTER_KEY`. `off` takes the harness away, and an agent that was set to it runs on the server’s OpenRouter key. Each turn on the SDK runs Claude Code as a subprocess of a few hundred megabytes; `CLAUDE_AGENT_SDK_TURNS` caps how many run at once.', + 'Whether the built-in agent on the Chat page runs on the Claude Agent SDK, under each member’s own Claude account, connected on the Account page: their conversations with it then go to Anthropic directly, as from Claude Code, and count against their plan. `off` takes the harness away: an agent then runs on `OPENROUTER_KEY` in an organization with the `openrouter` experiment on, and the Chat page says it is not set up everywhere else. Each turn on the SDK runs Claude Code as a subprocess of a few hundred megabytes; `CLAUDE_AGENT_SDK_TURNS` caps how many run at once.', }), CLAUDE_AGENT_SDK_TURNS: documented(z.coerce.number().int().positive().default(1), { group: 'Core', diff --git a/packages/contracts/src/intent.ts b/packages/contracts/src/intent.ts index 70ae45716..c145c9d3a 100644 --- a/packages/contracts/src/intent.ts +++ b/packages/contracts/src/intent.ts @@ -8,7 +8,7 @@ import {UNITS} from '@proxy/utils/units'; * the text (`packages/api/src/intent/`). Most are meant for one kind of data: Calendar, Contacts, * Gmail or Drive, or a bank; Math and Convert units are worked out in the web app; Ask reads one * meeting, file, email, page or issue and answers from it; New agent makes an agent of Chat's and - * hands it what it is for, so it is offered only where Chat is (`CHAT_WIDGET_IDS`). + * hands it what it is for. */ type FieldOf = { @@ -405,7 +405,7 @@ export const WIDGET_NAMES: Record = { new_agent: 'New agent', }; -/** The widgets that work with Chat's agents, picked only for an organization that has Chat. */ +/** The widgets that work with Chat's agents. */ export const CHAT_WIDGET_IDS: readonly WidgetId[] = ['new_agent']; /** Every field's id, of any widget, each once. */ diff --git a/packages/db/prisma/migrations/v059_agents_on_claude/migration.sql b/packages/db/prisma/migrations/v059_agents_on_claude/migration.sql new file mode 100644 index 000000000..aa88c9af8 --- /dev/null +++ b/packages/db/prisma/migrations/v059_agents_on_claude/migration.sql @@ -0,0 +1,16 @@ +-- The built-in agent runs on each member's own Claude account, and on the server's OpenRouter key +-- only for an organization with the `openrouter` experiment (or an insider, which has every +-- experiment). A new persona starts on Claude, and those on OpenRouter elsewhere are moved to it, +-- answering with Sonnet. + +-- AlterTable +ALTER TABLE "Persona" ALTER COLUMN "harness" SET DEFAULT 'claude-agent-sdk', +ALTER COLUMN "chatModel" SET DEFAULT 'sonnet'; + +UPDATE "Persona" AS persona +SET "harness" = 'claude-agent-sdk', "chatModel" = 'sonnet' +FROM "Organization" AS org +WHERE persona."orgId" = org."id" + AND persona."harness" = 'proxy' + AND org."insider" = false + AND NOT ('openrouter' = ANY (org."experiments")); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 7d6ce7cec..8173c5fb0 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -617,10 +617,10 @@ model Persona { color String // The harness that runs its turns, by the harness's id (@proxy/backends/harness), its // sub-agents' too, and the model it answers with, by the id the harness lists it under: chosen - // where it is made or changed. One the server hasn't got is read as one it has (the api's - // backends/harness.ts). - harness String @default("proxy") - chatModel String @default("claude") + // where it is made or changed. One the server doesn't offer the organization is read as one it + // does (the api's backends/harness.ts). + harness String @default("claude-agent-sdk") + chatModel String @default("sonnet") // The member whose persona it is, which nobody else in the organization sees, its owner // included; null for one every member sees, as General is, and as one from before personas // were anyone's is where others had talked to it. diff --git a/packages/docs/content/agents/chat.mdx b/packages/docs/content/agents/chat.mdx index f252c54bf..dcf21ee15 100644 --- a/packages/docs/content/agents/chat.mdx +++ b/packages/docs/content/agents/chat.mdx @@ -29,9 +29,8 @@ the agent titled the sub-agent, with every call it made under it: finished, with as the outcome, each time the sub-agent stops, and under way again when a message takes it up. What it does differently from an agent you connect is where it runs: in Personal Agent Proxy, not -on another company's servers. Unless you choose otherwise, 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`. +on another company's servers. Unless you choose otherwise, the model is Claude Sonnet 5.5, on each +member's own [Claude account](#your-claude-subscription). ## What runs an agent @@ -42,15 +41,17 @@ mark, with the models of the one you look at beside them to switch between. The agent's: it holds for every conversation with the agent, each member's with General, and for the sub-agents it starts, from the next turn on, and a conversation goes on from where it was. -- **OpenRouter** runs on the server's key, for every member alike, with Claude Sonnet 5.5 or - GLM 5.3, each only on hosts that keep nothing of what is sent. - **Claude** runs on each member's own [Claude account](#your-claude-subscription), with Claude Sonnet 5.5, Claude Opus 5.5 or Claude Fable 5.2, and counts against that member's plan. A member who hasn't connected a Claude account is told to, in the conversation, and nothing of theirs runs on the server's key in its place. +- **OpenRouter** runs on the server's key, for every member alike, with Claude Sonnet 5.5 or + GLM 5.3, each only on hosts that keep nothing of what is sent. It is only offered to an + organization that has it [turned on](#openrouter). -A new agent runs on the first harness the server has, with that harness's first model. A harness -the server hasn't got isn't offered, and an agent that was set to one runs on the first it has. +A new agent runs on the first harness offered, Claude where the server has it, with that harness's +first model. A harness that isn't offered can't be chosen, and an agent that was set to one runs on +the first that is. ## How a conversation runs @@ -127,14 +128,14 @@ that. **Disconnect** on the card deletes the credential, and deleting your accou an agent on Claude then asks you to connect one again. The token itself can be revoked at [claude.ai](https://claude.ai) as well, under your account's settings. - -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: +## OpenRouter + +Agents on the server's OpenRouter key are being tried out with some organizations first, so the +OpenRouter harness is only offered to an organization that has it turned on, on a server with an +`OPENROUTER_KEY`. 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); +UPDATE "Organization" SET experiments = array_append(experiments, 'openrouter') +WHERE NOT 'openrouter' = ANY(experiments); ``` - - diff --git a/packages/docs/content/self-hosting/environment.mdx b/packages/docs/content/self-hosting/environment.mdx index 4b1c01b41..e6119dddc 100644 --- a/packages/docs/content/self-hosting/environment.mdx +++ b/packages/docs/content/self-hosting/environment.mdx @@ -9,7 +9,7 @@ An empty variable counts as unset. With Docker Compose, the ## Core The variables marked **Required** are needed for Personal Agent Proxy to work. The optional Core -variables turn on quick actions and the built-in agent on the Chat page. +variables turn on quick actions, and set what the built-in agent on the Chat page runs on. @@ -17,8 +17,9 @@ A Chat agent can be set to run on the Claude Agent SDK, under each member's own which runs Claude Code as a subprocess for each turn: a few hundred megabytes of memory while the turn lasts, on top of the server's own. `CLAUDE_AGENT_SDK_TURNS` is how many such turns one server runs at once, one unless you raise it; a server with little memory to spare can turn the SDK off -with `CLAUDE_AGENT_SDK=off`: it is then not offered for an agent, and an agent that was set to it -runs on `OPENROUTER_KEY`. +with `CLAUDE_AGENT_SDK=off`. Agents then run on `OPENROUTER_KEY` in an organization that has +[OpenRouter turned on](/agents/chat#openrouter), and nowhere else: the Chat page says it is not set +up. ## Sign-in diff --git a/packages/utils/src/experiments.ts b/packages/utils/src/experiments.ts index 2f10d2aa3..aa6046891 100644 --- a/packages/utils/src/experiments.ts +++ b/packages/utils/src/experiments.ts @@ -6,9 +6,9 @@ * left in the database is ignored. */ export const EXPERIMENTS = { - // 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', + // The built-in agent on the server's OpenRouter key (OPENROUTER_KEY), beside each member's own + // Claude account, which every organization has. + openrouter: 'OpenRouter models', // Sandboxes: computers of their own that agents ask for, kept while they are away, run by the // sandbox backend the server is set up with. sandboxes: 'Sandboxes', diff --git a/packages/web/src/components/claude-card.tsx b/packages/web/src/components/claude-card.tsx index addf40ae3..e03914d97 100644 --- a/packages/web/src/components/claude-card.tsx +++ b/packages/web/src/components/claude-card.tsx @@ -26,6 +26,10 @@ type UsageWindow = NonNullable['fiveHour']; const SETUP_TOKEN_COMMAND = 'claude setup-token'; +// Where the desktop app is downloaded: the repository's latest release, which only the desktop +// app's releases are (.github/workflows/desktop.yml). +const DESKTOP_DOWNLOADS = 'https://github.com/personalagentproxy/proxy/releases/latest'; + // How long before the token's end the card says to connect again: a month, so the reminder is // there well before a conversation stops. const CONNECT_AGAIN_WITHIN_MS = 30 * 24 * 60 * 60 * 1000; @@ -34,8 +38,9 @@ const CONNECT_AGAIN_WITHIN_MS = 30 * 24 * 60 * 60 * 1000; // under for them. Not connected, or to connect again, it offers the ways to, each in a dialog: in // the desktop app, Connect Claude signs them in to Claude in the browser and the app mints a token // on their computer from the code they paste back; everywhere, Paste a token takes one from -// `claude setup-token` or an Anthropic API key. In the browser, it points to the desktop app; in -// a desktop app from before the sign-in, to the update that brings it. Connected, it says how, +// `claude setup-token` or an Anthropic API key. In the browser, it links the desktop app's +// downloads; in a desktop app from before the sign-in, it points to the update that brings it. +// Connected, it says how, // when the token ends where that is known, with the way to connect again as the end nears, that // the credential was refused, until they connect again, and how much of the plan the last turn // found used. @@ -102,7 +107,18 @@ export function ClaudeCard({account}: {account: Account}) { {connecting && !desktop && ( -

Or sign in with Claude from the desktop app.

+

+ Or sign in with Claude from the{' '} + + desktop app + + . +

)} {connecting && desktop && !signIn && (
diff --git a/packages/web/src/components/command-bar.tsx b/packages/web/src/components/command-bar.tsx index 2b5cb54e9..7adf6f1e8 100644 --- a/packages/web/src/components/command-bar.tsx +++ b/packages/web/src/components/command-bar.tsx @@ -14,7 +14,6 @@ import {UnsupportedWidget} from '@/components/widgets/unsupported-widget'; import {Input} from '@proxy/ui/components/input'; import {Skeleton} from '@proxy/ui/components/skeleton'; import {cn} from '@proxy/ui/lib/utils'; -import {useExperiments} from '@/hooks/use-experiment'; import {useOrgSlug} from '@/hooks/use-org'; import {apiMessage} from '@/lib/agent-errors'; import { @@ -48,10 +47,7 @@ const EXAMPLES = [ '72°F in Celsius', "What's my checking balance?", 'How much did I spend on restaurants last month?', -]; - -// What it suggests beside them where the organization has Chat: an agent made, and one asked. -const CHAT_EXAMPLES = [ + // An agent made, and one asked. 'Add an agent that reminds me to stretch every hour', 'Plan my trip to Lisbon in March', ]; @@ -114,9 +110,6 @@ type Props = { */ export function CommandBar({connections, agents, personas, open: up, onClose}: Props) { const org = useOrgSlug(); - // Whether the organization has Chat (the `harness` experiment), whose agents it then goes to, - // makes and sends messages to. - const chat = useExperiments().includes('harness'); const navigate = useNavigate(); const [text, setText] = useState(''); const [answer, setAnswer] = useState(null); @@ -167,8 +160,8 @@ export function CommandBar({connections, agents, personas, open: up, onClose}: P }, [up]); const destinations = useMemo( - () => destinationsOf({org, chat, personas, connections, agents}), - [org, chat, personas, connections, agents], + () => destinationsOf({org, personas, connections, agents}), + [org, personas, connections, agents], ); const words = text.trim(); const rows = useMemo( @@ -471,7 +464,7 @@ export function CommandBar({connections, agents, personas, open: up, onClose}: P return (
- {!active && } + {!active && } = []; // A message to an agent, answered once it settles, where a test holds it back. let messageSent: Promise | null = null; // Whether the organization has Chat. -let chat = true; // Whether the api refuses a message to an agent. let refusesMessages = false; // Whether what is connected is never answered, as on a slow connection. @@ -123,7 +122,6 @@ beforeEach(() => { picked = {widget: 'none'}; fields = []; messageSent = null; - chat = true; refusesMessages = false; holdsConnections = false; globalThis.fetch = (async (input: string, init?: RequestInit) => { @@ -164,7 +162,7 @@ function Where() { } function renderApp(entry = '/acme/activity') { - const human = testHuman({...testOrg, experiments: chat ? ['harness'] : []}); + const human = testHuman(testOrg); const router = createMemoryRouter( [ { @@ -623,21 +621,6 @@ describe('the command bar over any page', () => { expect(screen.getByRole('region', {name: 'Math'})).toBeTruthy(); }); - test('without Chat, it goes places and does quick actions, and what none does is said to be an agent’s', async () => { - chat = false; - renderApp(); - const input = await openBar(); - await waitFor(() => expect(rows().some((row) => row.startsWith('Granola'))).toBe(true)); - expect(rows().some((row) => row.startsWith('Chat'))).toBe(false); - - fireEvent.change(input, {target: {value: 'plan my trip to Lisbon in March'}}); - fireEvent.keyDown(input, {key: 'Enter'}); - - expect(await cardNamed('Not a quick action (yet)')).toBeTruthy(); - expect(asked.some((one) => one.path.includes('/chat/personas'))).toBe(false); - expect(await where()).toBe('/acme/activity'); - }); - test('on the account page, which is no organization’s, it works in the one the page was opened from: it classifies there, sends there and goes to its pages', async () => { renderApp('/account?org=acme'); const input = await openBar(); diff --git a/packages/web/src/components/command-palette.tsx b/packages/web/src/components/command-palette.tsx index 6620e8479..79a690367 100644 --- a/packages/web/src/components/command-palette.tsx +++ b/packages/web/src/components/command-palette.tsx @@ -1,7 +1,6 @@ import type {AgentLogin} from '@proxy/contracts/agents'; import type {Connection} from '@proxy/contracts/connections'; import type {ChatPersona} from '@proxy/contracts/harness'; -import {Ok} from 'ts-results-es'; import { type ReactNode, useCallback, @@ -18,7 +17,6 @@ import {listPersonas} from '@/client/harness-client'; import {CommandBar} from '@/components/command-bar'; import {Dialog, DialogContent, DialogTitle} from '@proxy/ui/components/dialog'; import {CommandPaletteContext} from '@/hooks/use-command-palette'; -import {useExperiments} from '@/hooks/use-experiment'; import {useOrgSlug} from '@/hooks/use-org'; import {isShortcut} from '@/lib/platform'; @@ -42,10 +40,10 @@ const FRESH_MS = 30_000; // waiting for it: what was read last shows meanwhile, and stays where it can't be read again. // They are read again as it opens, unless it was opened on the same page a moment ago, as it then // has what there is. Another organization's are none of this one's. -function useBarLists(org: string, chat: boolean, open: boolean, page: string): BarLists { +function useBarLists(org: string, open: boolean, page: string): BarLists { const [lists, setLists] = useState({org, ...NOTHING_READ}); // When all of them were last read, and for what. - const read = useRef<{org: string; chat: boolean; page: string; at: number} | null>(null); + const read = useRef<{org: string; page: string; at: number} | null>(null); useEffect(() => { if (!open || org === '') { @@ -55,43 +53,40 @@ function useBarLists(org: string, chat: boolean, open: boolean, page: string): B if ( last !== null && last.org === org && - last.chat === chat && last.page === page && Date.now() - last.at < FRESH_MS ) { return; } let current = true; - void Promise.all([ - listConnections(org), - listAgents(org), - chat ? listPersonas(org) : Promise.resolve(Ok(null)), - ]).then(([connections, agents, personas]) => { - if (!current) { - return; - } - if (connections.isOk() && agents.isOk() && personas.isOk()) { - read.current = {org, chat, page, at: Date.now()}; - } - setLists((previous) => { - const kept = previous.org === org ? previous : NOTHING_READ; - return { - org, - connections: connections.map((answer) => answer.connections).unwrapOr(kept.connections), - agents: agents.map((answer) => answer.agents).unwrapOr(kept.agents), - personas: personas.map((answer) => answer?.personas ?? null).unwrapOr(kept.personas), - }; - }); - }); + void Promise.all([listConnections(org), listAgents(org), listPersonas(org)]).then( + ([connections, agents, personas]) => { + if (!current) { + return; + } + if (connections.isOk() && agents.isOk() && personas.isOk()) { + read.current = {org, page, at: Date.now()}; + } + setLists((previous) => { + const kept = previous.org === org ? previous : NOTHING_READ; + return { + org, + connections: connections.map((answer) => answer.connections).unwrapOr(kept.connections), + agents: agents.map((answer) => answer.agents).unwrapOr(kept.agents), + personas: personas.map((answer) => answer.personas).unwrapOr(kept.personas), + }; + }); + }, + ); return () => { current = false; }; - }, [org, chat, open, page]); + }, [org, open, page]); if (lists.org !== org) { return {org, ...NOTHING_READ}; } - return chat ? lists : {...lists, personas: null}; + return lists; } /** @@ -132,10 +127,7 @@ export function CommandPaletteProvider({children}: {children: ReactNode}) { const close = useCallback(() => setOpenedOn(null), []); const open = org !== '' && openedOn === pathname; - // Whether the organization has Chat (the `harness` experiment), whose agents the bar goes to, - // makes and sends messages to. - const chat = useExperiments().includes('harness'); - const lists = useBarLists(org, chat, open, pathname); + const lists = useBarLists(org, open, pathname); return ( diff --git a/packages/web/src/components/markdown.test.tsx b/packages/web/src/components/markdown.test.tsx index 66d7448a6..c4452348a 100644 --- a/packages/web/src/components/markdown.test.tsx +++ b/packages/web/src/components/markdown.test.tsx @@ -1,5 +1,6 @@ -import {cleanup, fireEvent, render} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen} from '@testing-library/react'; import {afterEach, describe, expect, test} from 'bun:test'; +import {createMemoryRouter, RouterProvider} from 'react-router'; import {Markdown} from './markdown'; @@ -21,12 +22,35 @@ describe('Markdown', () => { expect(links[0]?.firstElementChild).toBe(favicons[0]); }); - test('leaves relative, email and insecure links without a favicon', () => { + test('leaves email and insecure links without a favicon', () => { const {container} = render( - , + , ); - expect(container.querySelectorAll('a')).toHaveLength(5); + expect(container.querySelectorAll('a')).toHaveLength(4); + expect(container.querySelector('img')).toBeNull(); + }); + + test('opens a link into the app in place, without a favicon, the account page kept to the organization', async () => { + const router = createMemoryRouter( + [ + { + path: '/:org/*', + element: ( + + ), + }, + ], + {initialEntries: ['/acme/chat']}, + ); + const {container} = render(); + + const account = await screen.findByRole('link', {name: 'Account page'}); + expect(account.getAttribute('href')).toBe('/account?org=acme'); + expect(account.getAttribute('target')).toBeNull(); + expect(screen.getByRole('link', {name: 'open settings'}).getAttribute('href')).toBe( + '/acme/settings', + ); expect(container.querySelector('img')).toBeNull(); }); diff --git a/packages/web/src/components/markdown.tsx b/packages/web/src/components/markdown.tsx index 1a8bb0a20..c4278fae2 100644 --- a/packages/web/src/components/markdown.tsx +++ b/packages/web/src/components/markdown.tsx @@ -1,16 +1,41 @@ -import {createContext, useContext, useState} from 'react'; +import {createContext, type ReactNode, useContext, useState} from 'react'; import ReactMarkdown, {type Components} from 'react-markdown'; +import {Link} from 'react-router'; import remarkGfm from 'remark-gfm'; import {cn} from '@proxy/ui/lib/utils'; +import {useOrgSlug} from '@/hooks/use-org'; +import {accountPath} from '@/lib/orgs'; const MAX_FAVICON_HOSTS = 8; const FaviconHostsContext = createContext | null>(null); // What a persona answers, as Markdown: paragraphs, lists, tables, code and links, styled to sit -// in a chat bubble. Every link opens in a new tab, since the conversation stays here. Each +// in a chat bubble. A link elsewhere opens in a new tab, since the conversation stays here; one +// into the app, by its path, opens in place. Each // component takes the element's own props, without the syntax tree node react-markdown hands // along, which is no attribute of an element. +const LINK_CLASS = 'underline underline-offset-4 hover:text-foreground'; + +// A path in the app, as the conversation's own word on connecting Claude links the Account page: +// that page kept to the organization the link was followed from. +function AppLink({ + path, + className, + children, +}: { + path: string; + className?: string; + children: ReactNode; +}) { + const org = useOrgSlug(); + return ( + + {children} + + ); +} + const MarkdownLink: NonNullable = ({ node: _node, className, @@ -19,10 +44,17 @@ const MarkdownLink: NonNullable = ({ ...props }) => { const hosts = useContext(FaviconHostsContext); + if (href?.startsWith('/') && !href.startsWith('//')) { + return ( + + {children} + + ); + } const host = hosts ? faviconHost(href, hosts) : null; return ( { cleanup(); @@ -70,9 +70,9 @@ describe('AppSidebar', () => { ).toBe(false); expect(groupLabels()).toEqual([ - [], + ['Chat'], ['Connections and permissions', 'External agents'], - ['Activity'], + ['Activity', 'Alerts'], ]); fireEvent.click(screen.getByRole('button', {name: 'Organization: Acme'})); @@ -82,24 +82,14 @@ describe('AppSidebar', () => { expect(settings.getAttribute('href')).toBe('/acme/settings'); }); - test('shows Chat under Search, and Alerts under Activity, with the harness experiment', async () => { - renderSidebar(testHuman({...testOrg, experiments: ['harness']}), '/acme/chat/general'); + test('lights Chat in a conversation', async () => { + renderSidebar(testHuman(), '/acme/chat/general'); const chat = await screen.findByRole('link', {name: 'Chat'}); expect(chat.hasAttribute('data-active')).toBe(true); - - expect(groupLabels()).toEqual([ - ['Chat'], - ['Connections and permissions', 'External agents'], - ['Activity', 'Alerts'], - ]); }); test('counts the conversations with something unread on Chat', async () => { - renderSidebar( - testHuman({...testOrg, experiments: ['harness']}), - '/acme/connections', - unreadOf(['general', 'finance']), - ); + renderSidebar(testHuman(), '/acme/connections', unreadOf(['general', 'finance'])); await screen.findByRole('link', {name: 'Chat'}); const badge = screen.getByRole('status', {name: '2 unread'}); diff --git a/packages/web/src/components/nav.tsx b/packages/web/src/components/nav.tsx index 037ca5da1..bc812509d 100644 --- a/packages/web/src/components/nav.tsx +++ b/packages/web/src/components/nav.tsx @@ -7,11 +7,9 @@ import { SearchIcon, type LucideIcon, } from 'lucide-react'; -import type {Experiment} from '@proxy/utils'; import {Link, useLocation} from 'react-router'; import {OrgSwitcher} from '@/components/org-switcher'; import {PersonAvatar} from '@/components/person-avatar'; -import {useExperiments} from '@/hooks/use-experiment'; import { Sidebar, SidebarContent, @@ -30,8 +28,7 @@ import {useUnread} from '@/hooks/use-unread'; import {accountPath} from '@/lib/orgs'; import {shortcutLabel} from '@/lib/platform'; -// `experiment` is the experiment a destination is behind, shown only with it on. -type Destination = {to: string; label: string; icon: LucideIcon; experiment?: Experiment}; +type Destination = {to: string; label: string; icon: LucideIcon}; const CHAT = '/chat'; @@ -52,14 +49,14 @@ function UnreadBadge({count}: {count: number}) { // The organization's, under the switcher, each under its slug, in groups set apart from each // other, the first under Search. Its members and settings are in the switcher. const GROUPS: Destination[][] = [ - [{to: CHAT, label: 'Chat', icon: MessageSquareIcon, experiment: 'harness'}], + [{to: CHAT, label: 'Chat', icon: MessageSquareIcon}], [ {to: '/connections', label: 'Connections and permissions', icon: PlugIcon}, {to: '/agents', label: 'External agents', icon: ListChecksIcon}, ], [ {to: '/activity', label: 'Activity', icon: ChartNoAxesGanttIcon}, - {to: '/alerts', label: 'Alerts', icon: BellIcon, experiment: 'harness'}, + {to: '/alerts', label: 'Alerts', icon: BellIcon}, ], ]; @@ -70,19 +67,11 @@ const GROUPS: Destination[][] = [ export function AppSidebar() { const {pathname} = useLocation(); const human = useHuman(); - const experiments = useExperiments(); const unread = useUnread(); const {setOpenMobile} = useSidebar(); const palette = useCommandPalette(); const org = useOrgSlug(); const base = `/${org}`; - const groups = GROUPS.map((group) => - group.filter( - (destination) => - destination.experiment === undefined || experiments.includes(destination.experiment), - ), - ); - return ( @@ -93,7 +82,7 @@ export function AppSidebar() { - {groups.map((group, index) => ( + {GROUPS.map((group, index) => ( {index === 0 && ( diff --git a/packages/web/src/components/unread-provider.test.tsx b/packages/web/src/components/unread-provider.test.tsx index 694d73ea1..f82e1646a 100644 --- a/packages/web/src/components/unread-provider.test.tsx +++ b/packages/web/src/components/unread-provider.test.tsx @@ -1,7 +1,6 @@ /// import type {ChatAnswer} from '@proxy/contracts/harness'; -import type {Experiment} from '@proxy/utils'; import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'; import {act, cleanup, render, screen, waitFor} from '@testing-library/react'; import {createMemoryRouter, RouterProvider} from 'react-router'; @@ -63,12 +62,12 @@ function Consumer() { ); } -function renderProvider(experiments: Experiment[] = ['harness'], entry = '/acme') { +function renderProvider(entry = '/acme') { const router = createMemoryRouter( [ { id: 'human', - loader: () => testHuman({...testOrg, experiments}), + loader: () => testHuman(testOrg), element: , children: [ {path: '/account', element: }, @@ -304,7 +303,7 @@ describe('UnreadProvider', () => { }); test('keeps the count of the organization in the sidebar on the person’s account page', async () => { - renderProvider(['harness'], '/account'); + renderProvider('/account'); await waitFor(() => { expect(screen.getByTestId('count').textContent).toBe('2'); @@ -400,12 +399,4 @@ describe('UnreadProvider', () => { unmount(); expect(setBadge).toHaveBeenLastCalledWith(0); }); - - test('asks nothing without the harness experiment', async () => { - renderProvider([]); - - await screen.findByTestId('count'); - expect(screen.getByTestId('count').textContent).toBe('0'); - expect(fetchMock).not.toHaveBeenCalled(); - }); }); diff --git a/packages/web/src/components/unread-provider.tsx b/packages/web/src/components/unread-provider.tsx index 96f78fe9d..9075552cf 100644 --- a/packages/web/src/components/unread-provider.tsx +++ b/packages/web/src/components/unread-provider.tsx @@ -3,7 +3,6 @@ import type {FetchError} from '@proxy/utils'; import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useNavigate} from 'react-router'; import {listAnswers, markRead} from '@/client/harness-client'; -import {useExperiments} from '@/hooks/use-experiment'; import {useOrgSlug} from '@/hooks/use-org'; import {usePoll} from '@/hooks/use-poll'; import {type Unread, UnreadContext, type Viewing} from '@/hooks/use-unread'; @@ -51,8 +50,7 @@ function sameAnswers(kept: ChatAnswer[] | null, listed: ChatAnswer[]): boolean { export function UnreadProvider({children}: {children: ReactNode}) { const org = useOrgSlug(); const navigate = useNavigate(); - const experiments = useExperiments(); - const enabled = org !== '' && experiments.includes('harness'); + const enabled = org !== ''; const [answers, setAnswers] = useState(null); const [viewing, setViewing] = useState(null); const [attending, setAttending] = useState(isAttending); diff --git a/packages/web/src/lib/command-bar.test.ts b/packages/web/src/lib/command-bar.test.ts index 8a164b832..20eb194d4 100644 --- a/packages/web/src/lib/command-bar.test.ts +++ b/packages/web/src/lib/command-bar.test.ts @@ -72,7 +72,6 @@ const personas = [finance, team, general]; const everywhere = destinationsOf({ org: 'acme', - chat: true, personas, connections: [ connection('vault-1', 'vault', 'Acme'), @@ -114,19 +113,6 @@ describe('destinationsOf', () => { expect(detail['Google (me@home.example)']).toBeNull(); expect(detail['My vault']).toBeNull(); }); - - test('without Chat, leaves out its pages, and a list still loading is left out', () => { - const labels = destinationsOf({ - org: 'acme', - chat: false, - personas: null, - connections: null, - agents: null, - }).map((destination) => destination.label); - expect(labels).not.toContain('Chat'); - expect(labels).not.toContain('Alerts'); - expect(labels).toContain('Activity'); - }); }); describe('findDestinations', () => { diff --git a/packages/web/src/lib/command-bar.ts b/packages/web/src/lib/command-bar.ts index 9bcf1dde0..c08f48c0f 100644 --- a/packages/web/src/lib/command-bar.ts +++ b/packages/web/src/lib/command-bar.ts @@ -52,8 +52,6 @@ type Page = { to: (org: string) => string; icon: LucideIcon; keywords: string[]; - // Only with Chat on (the `harness` experiment). - chat?: true; }; /** The id of a destination's row in the bar, which the bar's input names as the one Enter opens. */ @@ -69,7 +67,6 @@ const PAGES: Page[] = [ to: (org) => `/${org}/chat`, icon: MessageSquareIcon, keywords: ['conversations', 'messages'], - chat: true, }, { label: 'Connections and permissions', @@ -94,7 +91,6 @@ const PAGES: Page[] = [ to: (org) => `/${org}/alerts`, icon: BellIcon, keywords: ['reminders', 'watches'], - chat: true, }, { label: 'Members', @@ -140,19 +136,18 @@ function accountOf(connection: Connection, label: string): string | null { /** * Everywhere the bar goes in the organization `org`: its pages, the person's conversations with * Chat's agents, their connections, and the external agents that aren't revoked. A list still - * loading, or that couldn't be, is left out. `chat` is whether the organization has Chat. + * loading, or that couldn't be, is left out. */ export function destinationsOf(lists: { org: string; - chat: boolean; personas: ChatPersona[] | null; connections: Connection[] | null; agents: AgentLogin[] | null; }): Destination[] { - const {org, chat} = lists; + const {org} = lists; const connections = lists.connections ?? []; return [ - ...PAGES.filter((page) => chat || !page.chat).map((page): Destination => ({ + ...PAGES.map((page): Destination => ({ id: `page:${page.label}`, kind: 'page', label: page.label, diff --git a/packages/web/src/pages/account-page.test.tsx b/packages/web/src/pages/account-page.test.tsx index e6b659195..a84236d1e 100644 --- a/packages/web/src/pages/account-page.test.tsx +++ b/packages/web/src/pages/account-page.test.tsx @@ -123,7 +123,9 @@ describe('AccountPage, the Claude card', () => { expect( await screen.findByText(/Run the built-in agent on your own Claude subscription/), ).toBeDefined(); - expect(screen.getByText('Or sign in with Claude from the desktop app.')).toBeDefined(); + expect(screen.getByRole('link', {name: 'desktop app'}).getAttribute('href')).toBe( + 'https://github.com/personalagentproxy/proxy/releases/latest', + ); expect(screen.queryByRole('button', {name: 'Connect Claude'})).toBeNull(); expect(screen.queryByLabelText('Token or API key')).toBeNull(); @@ -201,7 +203,7 @@ describe('AccountPage, the Claude card', () => { expect( await screen.findByText(/Run the built-in agent on your own Claude subscription/), ).toBeDefined(); - expect(screen.queryByText('Or sign in with Claude from the desktop app.')).toBeNull(); + expect(screen.queryByRole('link', {name: 'desktop app'})).toBeNull(); expect(screen.queryByText(/An update to the desktop app/)).toBeNull(); fireEvent.click(screen.getByRole('button', {name: 'Connect Claude'})); @@ -264,7 +266,7 @@ describe('AccountPage, the Claude card', () => { }); renderPage({...token, expiresAt: new Date(Date.now() + 10 * DAY_MS).toISOString()}); expect(await screen.findByRole('button', {name: 'Connect Claude'})).toBeDefined(); - expect(screen.queryByText('Or sign in with Claude from the desktop app.')).toBeNull(); + expect(screen.queryByRole('link', {name: 'desktop app'})).toBeNull(); fireEvent.click(screen.getByRole('button', {name: 'Paste a token'})); const dialog = await screen.findByRole('dialog'); @@ -283,7 +285,7 @@ describe('AccountPage, the Claude card', () => { ).toBeDefined(); expect(screen.getByText(/Quit and reopen the app to update it/)).toBeDefined(); expect(screen.queryByRole('button', {name: 'Connect Claude'})).toBeNull(); - expect(screen.queryByText('Or sign in with Claude from the desktop app.')).toBeNull(); + expect(screen.queryByRole('link', {name: 'desktop app'})).toBeNull(); expect(screen.getByRole('button', {name: 'Paste a token'})).toBeDefined(); }); }); diff --git a/packages/web/src/pages/account-page.tsx b/packages/web/src/pages/account-page.tsx index f0d1657ea..fa9de403e 100644 --- a/packages/web/src/pages/account-page.tsx +++ b/packages/web/src/pages/account-page.tsx @@ -72,7 +72,6 @@ function Account() { // The organizations deleted with the account that others are in or invited to. const shared = human.orgs.filter((org) => org.role === 'owner' && hasOtherPeople(org)); - const chats = human.orgs.some((org) => org.experiments.includes('harness')); const desktop = desktopBridge()?.checkForUpdates ? desktopVersion() : null; return ( @@ -91,7 +90,7 @@ function Account() { - {chats && notificationSupport() !== 'none' && ( + {notificationSupport() !== 'none' && (
diff --git a/packages/web/src/pages/chat-page.test.tsx b/packages/web/src/pages/chat-page.test.tsx index 26d1fab1e..953655ea1 100644 --- a/packages/web/src/pages/chat-page.test.tsx +++ b/packages/web/src/pages/chat-page.test.tsx @@ -131,7 +131,7 @@ function renderChatPage({ [ { id: 'human', - loader: () => testHuman({...testOrg, experiments: ['harness']}), + loader: () => testHuman(testOrg), element: , children: [ { diff --git a/packages/web/src/pages/org-start.test.tsx b/packages/web/src/pages/org-start.test.tsx index f4b6828d3..c295f635e 100644 --- a/packages/web/src/pages/org-start.test.tsx +++ b/packages/web/src/pages/org-start.test.tsx @@ -1,6 +1,5 @@ /// -import type {Experiment} from '@proxy/utils'; import {afterEach, describe, expect, test} from 'bun:test'; import {cleanup, render, screen} from '@testing-library/react'; import {createMemoryRouter, RouterProvider, useLocation} from 'react-router'; @@ -16,12 +15,12 @@ function Where() { return

{useLocation().pathname}

; } -function renderOrg(experiments: Experiment[]) { +function renderOrg() { const router = createMemoryRouter( [ { id: 'human', - loader: () => testHuman({...testOrg, experiments}), + loader: () => testHuman(testOrg), children: [ {path: '/:org', element: }, {path: '/:org/*', element: }, @@ -34,13 +33,8 @@ function renderOrg(experiments: Experiment[]) { } describe('OrgStart', () => { - test('opens Chat in an organization that has it', async () => { - renderOrg(['harness']); + test('opens Chat', async () => { + renderOrg(); expect((await screen.findByTestId('where')).textContent).toBe('/acme/chat'); }); - - test('opens Connections in one without it', async () => { - renderOrg([]); - expect((await screen.findByTestId('where')).textContent).toBe('/acme/connections'); - }); }); diff --git a/packages/web/src/pages/org-start.tsx b/packages/web/src/pages/org-start.tsx index f58985562..d88496aee 100644 --- a/packages/web/src/pages/org-start.tsx +++ b/packages/web/src/pages/org-start.tsx @@ -1,11 +1,9 @@ import {Navigate} from 'react-router'; -import {useExperiments} from '@/hooks/use-experiment'; /** * The organization's own address (`/:org`), where the app opens: Chat, which opens the person's - * last conversation, or Connections in an organization without it (the `harness` experiment). + * last conversation. */ export function OrgStart() { - const chat = useExperiments().includes('harness'); - return ; + return ; } From e86fead65e082baa3917b84494eaaa2efc90fcf5 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:26:45 +0000 Subject: [PATCH 2/3] run the api on 2 cpus and 4 gb, for four claude agent sdk turns at once per instance --- render.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/render.yaml b/render.yaml index f15320f0a..1f4bb4cc2 100644 --- a/render.yaml +++ b/render.yaml @@ -12,7 +12,10 @@ services: dockerfilePath: ./Dockerfile dockerContext: ./ region: oregon - plan: 0.5c-512mb + # Room for the built-in agent's turns on the Claude Agent SDK, each Claude Code as a + # subprocess of a few hundred megabytes, CLAUDE_AGENT_SDK_TURNS of them at once per instance, + # beside the api itself. + plan: 2c-4g # Two, so a deploy or a failed instance never takes Personal Agent Proxy down. The api keeps # nothing in memory between requests, and two instances starting at once migrate safely: Prisma # takes a lock on the database first. @@ -68,6 +71,10 @@ services: sync: false - key: OPENROUTER_KEY sync: false + # How many of the built-in agent's turns on the Claude Agent SDK one instance runs at once, + # as the plan above has the memory for; the rest wait their turn. + - key: CLAUDE_AGENT_SDK_TURNS + value: '4' - key: GOOGLE_CLIENT_ID sync: false - key: GOOGLE_CLIENT_SECRET From 543135e67d2d45fff6bcd45841f2bdb7edf5a851 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:29:10 +0000 Subject: [PATCH 3/3] run the api on 1 cpu and 2 gb instead, for three claude agent sdk turns at once per instance --- render.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/render.yaml b/render.yaml index 1f4bb4cc2..474802cfa 100644 --- a/render.yaml +++ b/render.yaml @@ -15,7 +15,7 @@ services: # Room for the built-in agent's turns on the Claude Agent SDK, each Claude Code as a # subprocess of a few hundred megabytes, CLAUDE_AGENT_SDK_TURNS of them at once per instance, # beside the api itself. - plan: 2c-4g + plan: 1c-2g # Two, so a deploy or a failed instance never takes Personal Agent Proxy down. The api keeps # nothing in memory between requests, and two instances starting at once migrate safely: Prisma # takes a lock on the database first. @@ -74,7 +74,7 @@ services: # How many of the built-in agent's turns on the Claude Agent SDK one instance runs at once, # as the plan above has the memory for; the rest wait their turn. - key: CLAUDE_AGENT_SDK_TURNS - value: '4' + value: '3' - key: GOOGLE_CLIENT_ID sync: false - key: GOOGLE_CLIENT_SECRET