Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/api/src/backends/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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', () => {
Expand Down
41 changes: 29 additions & 12 deletions packages/api/src/backends/harness.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
9 changes: 4 additions & 5 deletions packages/api/src/harness/alerts-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -44,7 +43,7 @@ function toAlert(alert: AlertRow & {persona: PersonaRow}, connections: Map<strin
/** `GET /api/orgs/:org/alerts`: the viewer's alerts in the organization, newest first, each with the persona it was set with and the connections it watches. */
function handleListAlertsRoute(store: AlertsStore, request: AuthenticatedRequest): Promise<Result<z.infer<typeof alertListSchema>, 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]));
Expand All @@ -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<Result<void, ApiError>> {
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)));
Expand Down
32 changes: 26 additions & 6 deletions packages/api/src/harness/inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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: {},
},
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand Down
26 changes: 14 additions & 12 deletions packages/api/src/harness/inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Result<HarnessChoice, ApiError>> {
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));
});
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading