Skip to content
Draft
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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions packages/api/src/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ export type AuditEntryResponse = {
// What went wrong, for a failed request: the api's kind of error, with the provider's HTTP status
// where it answered with one, such as `provider_unreachable 429`; none for every other outcome.
error: string | null;
// The web app's agent pages, or the MCP server.
via: 'web' | 'mcp';
// The web app's agent pages, the MCP server, or the built-in agent's own loop.
via: 'web' | 'mcp' | 'harness';
// The agent's task the request was made for, among the answer's `tasks`; none for entries from
// before tasks, and for what Personal Agent Proxy did itself.
taskId: string | null;
Expand All @@ -50,7 +50,7 @@ export type ActivityTaskResponse = {
parentId: string | null;
title: string;
goal: string;
via: 'web' | 'mcp';
via: 'web' | 'mcp' | 'harness';
startedAt: string;
// Null while the agent hasn't called it finished, abandoned (@proxy/utils/tasks) or not.
finishedAt: string | null;
Expand Down
5 changes: 3 additions & 2 deletions packages/api/src/agent-side/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@ export function listAgentTools(signedIn: SignedInAgent, connectionId: string): P
});
}

// Who calls and which way it came in: the web app's agent pages, or the MCP server.
export type AgentCaller = {agent: SignedInAgent; via: 'web' | 'mcp'};
// Who calls and which way it came in: the web app's agent pages, the MCP server, or the built-in
// agent's own loop.
export type AgentCaller = {agent: SignedInAgent; via: 'web' | 'mcp' | 'harness'};

/** Who runs a tool, and the task it is run for, by its id. */
// PRE-TASK CLIENTS: null for a pre-task client's call (mcp/pre-task-clients.ts).
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/agents/create-agent-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ function nameFor(provider: AgentProvider, name: string | undefined): Result<stri
export function createAgentLogin(viewer: MemberViewer, providerId: string, options: {name?: string; shared?: boolean} = {}): Promise<Result<{agent: AgentRow; password: string}, ApiError>> {
return Do(async ($) => {
const product = $(requirePresent(findAgentProvider(providerId), ApiErr.parseError(`Unknown agent ${providerId}`)));
// The built-in agents are made on the Chat page (harness/built-in-agent.ts), never handed out.
if (product.connects === 'built-in') {
return $(Err(ApiErr.parseError(`${product.name} is not an agent a login is made for`)));
}
const provider = $(requirePresent(findAgentProvider(loginProviderIdFor(product)), ApiErr.parseError(`Unknown login provider for ${providerId}`)));
const name = $(nameFor(provider, options.name));
const password = generatePassword();
Expand Down
29 changes: 29 additions & 0 deletions packages/api/src/harness/built-in-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {randomBytes} from 'node:crypto';

import type {Result} from 'ts-results-es';

import type {ApiError} from '@proxy/utils';
import {type AgentRow, createAgent} from '@proxy/db/agent';
import type {Persona} from '@proxy/db/harness';
import type {MemberViewer} from '@proxy/db/viewer';
import {BUILT_IN_AGENT_PROVIDER} from '@proxy/integrations';

/**
* A new built-in agent of the organization, shared with every member of it, made by the viewer:
* an Agent like any other, with grants and an activity log of its own, so what it may do on the
* shared connections is set where it is set for Claude. It has a password nobody knows, since it
* never signs in: its loop runs in the api, and in each member's conversation it acts as them.
*/
export async function createBuiltInAgent(viewer: MemberViewer, persona: Persona): Promise<Result<AgentRow, ApiError>> {
return createAgent({
orgId: viewer.orgId,
ownerUserId: null,
createdById: viewer.userId,
providerId: BUILT_IN_AGENT_PROVIDER.id,
name: persona.name,
shape: persona.shape,
color: persona.color,
username: `proxy-${randomBytes(6).toString('hex')}`,
passwordHash: await Bun.password.hash(randomBytes(32).toString('base64url')),
});
}
129 changes: 129 additions & 0 deletions packages/api/src/harness/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {describe, expect, test} from 'bun:test';

import type {EntryRow} from '@proxy/db/harness';

import {COMPACT_AT_TOKENS, compactionCut, toMessages} from './context';

const now = new Date('2026-10-21T10:00:00Z');

type Kind = EntryRow['kind'];

function entry(seq: number, kind: Kind, text: string, extra: Partial<Pick<EntryRow, 'toolCalls' | 'callId' | 'inputTokens' | 'coversSeq'>> = {}): EntryRow {
return {
id: `e${seq}`,
seq,
threadId: 'thread-1',
kind,
text,
toolCalls: extra.toolCalls ?? null,
callId: extra.callId ?? null,
isError: false,
inputTokens: extra.inputTokens ?? null,
outputTokens: null,
coversSeq: extra.coversSeq ?? null,
createdAt: now,
};
}

const read = (seq: number, id: string, inputTokens = 10) => entry(seq, 'assistant', '', {toolCalls: [{id, name: 'read', arguments: '{}'}], inputTokens});
const result = (seq: number, id: string, text = 'rows') => entry(seq, 'tool_result', text, {callId: id});

// Three turns of the person, each answered after a read.
const long = [
entry(1, 'person', 'One'),
read(2, 'c1'),
result(3, 'c1', 'rows one'),
entry(4, 'assistant', 'Done one', {inputTokens: 10}),
entry(5, 'person', 'Two'),
read(6, 'c2'),
result(7, 'c2', 'rows two'),
entry(8, 'assistant', 'Done two', {inputTokens: 10}),
entry(9, 'person', 'Three'),
read(10, 'c3', COMPACT_AT_TOKENS),
result(11, 'c3', 'rows three'),
];

describe('toMessages', () => {
test('gives the log in the chat form, as it is, where it has no summary', () => {
expect(toMessages(long.slice(0, 4), {clearOld: true})).toEqual([
{role: 'user', content: 'One'},
{role: 'assistant', content: null, tool_calls: [{id: 'c1', type: 'function', function: {name: 'read', arguments: '{}'}}]},
{role: 'tool', tool_call_id: 'c1', content: 'rows one'},
{role: 'assistant', content: 'Done one'},
]);
});

test('reads the last summary in place of what it covers, and clears the results of turns before the current one', () => {
const log = [...long, entry(12, 'summary', 'The person asked for one.', {coversSeq: 4})];

const messages = toMessages(log, {clearOld: true});

expect(messages[0]).toEqual({role: 'user', content: expect.stringContaining('The person asked for one.')});
expect(String(messages[0]?.content)).toContain('The person did not write this');
expect(messages.slice(1)).toEqual([
{role: 'user', content: 'Two'},
{role: 'assistant', content: null, tool_calls: [{id: 'c2', type: 'function', function: {name: 'read', arguments: '{}'}}]},
{role: 'tool', tool_call_id: 'c2', content: expect.stringContaining('cleared')},
{role: 'assistant', content: 'Done two'},
{role: 'user', content: 'Three'},
{role: 'assistant', content: null, tool_calls: [{id: 'c3', type: 'function', function: {name: 'read', arguments: '{}'}}]},
{role: 'tool', tool_call_id: 'c3', content: 'rows three'},
]);
expect(toMessages(log, {clearOld: false})[3]).toEqual({role: 'tool', tool_call_id: 'c2', content: 'rows two'});
});

test('with summaries on summaries, only the last counts', () => {
const log = [...long, entry(12, 'summary', 'First.', {coversSeq: 4}), entry(13, 'summary', 'Second.', {coversSeq: 8})];

const messages = toMessages(log, {clearOld: true});

expect(String(messages[0]?.content)).toContain('Second.');
expect(messages).toHaveLength(4);
expect(messages[1]).toEqual({role: 'user', content: 'Three'});
});
});

describe('compactionCut', () => {
test('is nothing while the last request was under the limit', () => {
expect(compactionCut(long.slice(0, 8))).toBeNull();
});

test('covers the log before the person’s last two turns once a request was over the limit', () => {
expect(compactionCut(long)).toBe(4);
});

test('is nothing where the last turns are the whole log', () => {
expect(compactionCut([entry(1, 'person', 'One'), read(2, 'c1', COMPACT_AT_TOKENS), result(3, 'c1')])).toBeNull();
});

test('moves back so that no call is left without its result', () => {
// The person spoke while a read was still running: its result is after their message.
const log = [
entry(1, 'person', 'One'),
entry(2, 'assistant', 'Ok', {inputTokens: 10}),
entry(3, 'person', 'Two'),
read(4, 'c1'),
entry(5, 'person', 'Three'),
result(6, 'c1'),
entry(7, 'assistant', 'Done', {inputTokens: 10}),
entry(8, 'person', 'Four'),
read(9, 'c2', COMPACT_AT_TOKENS),
result(10, 'c2'),
];
expect(compactionCut(log)).toBe(3);
});

test('is nothing again right after a summary, until a request since was over the limit', () => {
const summarized = [...long, entry(12, 'summary', 'So far.', {coversSeq: 4})];
expect(compactionCut(summarized)).toBeNull();

const grown = [
...summarized,
entry(13, 'assistant', 'Done three', {inputTokens: 10}),
entry(14, 'person', 'Four'),
entry(15, 'assistant', 'Ok', {inputTokens: COMPACT_AT_TOKENS}),
entry(16, 'person', 'Five'),
];
expect(compactionCut(grown)).toBe(13);
});
});
85 changes: 85 additions & 0 deletions packages/api/src/harness/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {pendingToolCalls} from '@proxy/utils/harness';
import type {EntryRow} from '@proxy/db/harness';

import type {ModelMessage} from './model';

/**
* The log as the model reads it. A log that has grown long is summarized: a summary entry stands
* for every entry up to the one it covers, and the model reads the summary, then what came since.
* Of that, the person's last turns are kept as said, except what tools answered in the turns
* before the current one, which is cleared: it is the bulk, and the model can ask again.
*/

/** Once a request to the model was this many tokens, the log is summarized before the next. */
export const COMPACT_AT_TOKENS = 80_000;

// How many of the person's turns are kept behind a summary: the current one and the one before.
const KEEP_TURNS = 2;

const SUMMARY_LEAD = 'What came before in this conversation, summarized so that it fits. The person did not write this; take it as what happened.';

const CLEARED = '(This result was cleared from the conversation to save room. Call again if you need it.)';

function toMessage(entry: EntryRow): ModelMessage {
if (entry.kind === 'person') {
return {role: 'user', content: entry.text};
}
if (entry.kind === 'tool_result') {
return {role: 'tool', tool_call_id: entry.callId ?? '', content: entry.text};
}
if (entry.kind === 'summary') {
return {role: 'user', content: `${SUMMARY_LEAD}\n\n${entry.text}`};
}
const calls = entry.toolCalls ?? [];
return {
role: 'assistant',
content: entry.text === '' && calls.length > 0 ? null : entry.text,
...(calls.length > 0 ? {tool_calls: calls.map((call) => ({id: call.id, type: 'function' as const, function: {name: call.name, arguments: call.arguments}}))} : {}),
};
}

// The log from its last summary on: the summary, then the entries it doesn't cover, no other
// summary among them.
function sinceSummary(entries: EntryRow[]): {summary: EntryRow | null; tail: EntryRow[]} {
const summary = entries.findLast((entry) => entry.kind === 'summary') ?? null;
const covered = summary?.coversSeq ?? -1;
return {summary, tail: entries.filter((entry) => entry.kind !== 'summary' && entry.seq > covered)};
}

/**
* The log in the chat form, from its last summary on. With `clearOld`, what tools answered before
* the person's current turn is cleared, once there is a summary: that is how the model reads a
* log, where a summary is made from the log as it is.
*/
export function toMessages(entries: EntryRow[], {clearOld}: {clearOld: boolean}): ModelMessage[] {
const {summary, tail} = sinceSummary(entries);
if (!summary) {
return tail.map(toMessage);
}
const current = clearOld ? tail.findLastIndex((entry) => entry.kind === 'person') : -1;
return [toMessage(summary), ...tail.map((entry, index) => (entry.kind === 'tool_result' && index < current ? toMessage({...entry, text: CLEARED}) : toMessage(entry)))];
}

/**
* How far a summary would reach, as the count of entries from the start it would cover: the log
* before the person's last turns, cut so that no call is left without its result. Null when the
* log needs none: the last request to the model was under the limit, or nothing since the last
* summary would be covered.
*/
export function compactionCut(entries: EntryRow[]): number | null {
const {summary, tail} = sinceSummary(entries);
const lastAnswer = tail.findLast((entry) => entry.kind === 'assistant');
if ((lastAnswer?.inputTokens ?? 0) < COMPACT_AT_TOKENS) {
return null;
}
const turns = entries.flatMap((entry, index) => (entry.kind === 'person' ? [index] : []));
let cut = turns.at(-KEEP_TURNS) ?? 0;
while (cut > 0 && pendingToolCalls(entries.slice(0, cut)).length > 0) {
cut -= 1;
}
const covered = summary?.coversSeq ?? -1;
if (!entries.slice(0, cut).some((entry) => entry.kind !== 'summary' && entry.seq > covered)) {
return null;
}
return cut;
}
Loading