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
3 changes: 3 additions & 0 deletions packages/api/src/activity/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ describe('handleListActivityRoute', () => {
via: 'mcp',
taskId: 'task-1',
durationMs: 340,
error: null,
createdAt: new Date('2026-10-15T10:00:00Z'),
};
listAuditEntries.mockResolvedValue(Ok([entry, {...entry, id: 'e2', taskId: null, agentId: null, durationMs: null}]));
Expand Down Expand Up @@ -155,6 +156,7 @@ describe('handleListActivityRoute', () => {
via: 'mcp',
taskId: 'task-1',
durationMs: 340,
error: null,
at: '2026-10-15T10:00:00.000Z',
},
{
Expand All @@ -169,6 +171,7 @@ describe('handleListActivityRoute', () => {
via: 'mcp',
taskId: null,
durationMs: null,
error: null,
at: '2026-10-15T10:00:00.000Z',
},
],
Expand Down
7 changes: 5 additions & 2 deletions packages/api/src/activity/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ export type AuditEntryResponse = {
action: string;
recordTitle: string | null;
query: string | null;
outcome: 'allowed' | 'denied';
outcome: 'allowed' | 'denied' | 'failed';
// 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 agent's task the request was made for, among the answer's `tasks`; none for entries from
Expand Down Expand Up @@ -60,7 +63,7 @@ const querySchema = z.object({
agentId: z.string().min(1).optional(),
connectionId: z.string().min(1).optional(),
taskId: z.string().min(1).optional(),
outcome: z.enum(['allowed', 'denied']).optional(),
outcome: z.enum(['allowed', 'denied', 'failed']).optional(),
});

/**
Expand Down
35 changes: 31 additions & 4 deletions packages/api/src/agent-side/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ describe('runAgentTool', () => {
expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({action: 'sendNew', recordTitle: 'Friday', outcome: 'allowed'});
});

test('a failed run is not logged as allowed, and a run that cannot be logged is not returned', async () => {
test('a run the provider fails is logged as failed with what went wrong, not as allowed, and passed on', async () => {
useConnection(emailConnection);
getAgent.mockResolvedValue(Ok(triage));
connector.get.mockResolvedValue(Ok(inboxEmail));
Expand All @@ -362,11 +362,37 @@ describe('runAgentTool', () => {
const {runAgentTool} = await import('./tools');
const failed = await runAgentTool(caller, 'mail-1', 'emails_archive', {id: inboxEmail.id});
expect(failed.unwrapErr().kind).toBe('not_found');
expect(logAgentRequest).not.toHaveBeenCalled();
expect(logAgentRequest).toHaveBeenCalledTimes(1);
expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({action: 'archive', recordTitle: 'Invoice #20931', outcome: 'failed', error: 'not_found'});
});

test("a failure keeps the provider's HTTP status, and a read that failed before it had a record has no title", async () => {
const tooMany = ApiErr.providerUnreachable({kind: 'http', url: 'https://api.example.com', status: 429, body: 'Slow down, Alex'});
connector.get.mockResolvedValue(Err(tooMany));
connector.list.mockResolvedValue(Err(ApiErr.providerUnreachable({kind: 'network', url: 'https://api.example.com', cause: new Error('ECONNRESET')})));

const {runAgentTool} = await import('./tools');
expect((await runAgentTool(caller, 'vault-1', 'entries_get', {id: 'rec-1'})).unwrapErr()).toEqual(tooMany);
expect((await runAgentTool(caller, 'vault-1', 'entries_list', {search: 'passport'})).unwrapErr().kind).toBe('provider_unreachable');

expect(logAgentRequest.mock.calls.map(([logged]) => logged)).toMatchObject([
{action: 'view', recordTitle: null, query: null, outcome: 'failed', error: 'provider_unreachable 429'},
{action: 'list', recordTitle: null, query: 'passport', outcome: 'failed', error: 'provider_unreachable'},
]);
});

test('a run that cannot be logged is not returned', async () => {
useConnection(emailConnection);
getAgent.mockResolvedValue(Ok(triage));
logAgentRequest.mockResolvedValue(Err(ApiErr.dbError(new Error('boom'))));

const {runAgentTool} = await import('./tools');
const unlogged = await runAgentTool(caller, 'mail-1', 'emails_list', {});
expect(unlogged.unwrapErr().kind).toBe('db_error');

connector.list.mockResolvedValue(Err(ApiErr.credentialsRejected()));
const failedUnlogged = await runAgentTool(caller, 'mail-1', 'emails_list', {});
expect(failedUnlogged.unwrapErr().kind).toBe('db_error');
});
});

Expand Down Expand Up @@ -547,12 +573,13 @@ describe('custom MCP servers', () => {
expect(logAgentRequest).toHaveBeenCalledTimes(1);
});

test('what the tool answered with is passed on, and a tool that failed is what the server said', async () => {
test('what the tool answered with is passed on, and a tool that failed is what the server said, logged as failed', async () => {
callCustomTool.mockResolvedValue(Err(ApiErr.validationError('query is required')));
const {runAgentTool} = await import('./tools');
const failed = (await runAgentTool(caller, 'mcp-1', 'search_docs', {})).unwrapErr();

expect(failed).toEqual(ApiErr.validationError('query is required'));
expect(logAgentRequest).not.toHaveBeenCalled();
expect(logAgentRequest).toHaveBeenCalledTimes(1);
expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({collectionId: 'tools', action: 'search_docs', query: null, outcome: 'failed', error: 'validation_error'});
});
});
67 changes: 49 additions & 18 deletions packages/api/src/agent-side/tools.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {Err, Ok, type Result} from 'ts-results-es';
import {z} from 'zod';

import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils';
import {getAgent, type AgentRow, type SignedInAgent} from '@proxy/db/agent';
Expand Down Expand Up @@ -33,8 +34,8 @@ import {isPlainObject, parseToolParams} from './tool-params';
/**
* The agent side, whichever way an agent comes in: the connections it can use, the tools it has
* with each, and running one. Every run is made for one of the agent's tasks (tasks.ts), checked
* against the agent's access as it is now and logged with the task, a refused one too; one that
* can't be logged fails rather than go unrecorded.
* against the agent's access as it is now and logged with the task, a refused one too, and one the
* provider fails; one that can't be logged fails rather than go unrecorded.
*/

export type AgentConnection = {
Expand Down Expand Up @@ -125,8 +126,10 @@ function recordTitle(collection: Collection, values: RecordValues): string | nul
return values[collection.titleField]?.trim() || null;
}

// `query` is what a list searched for.
function log(run: ToolRun, outcome: 'allowed' | 'denied', title: string | null, query: string | null = null): Promise<Result<void, ApiError>> {
type Outcome = 'allowed' | 'denied' | 'failed';

// `query` is what a list searched for, and `error` what went wrong, for a failed run.
function log(run: ToolRun, outcome: Outcome, title: string | null, query: string | null = null, error?: string): Promise<Result<void, ApiError>> {
return logAgentRequest({
orgId: run.caller.agent.orgId,
agentId: run.caller.agent.agentId,
Expand All @@ -139,6 +142,28 @@ function log(run: ToolRun, outcome: 'allowed' | 'denied', title: string | null,
via: run.caller.via,
taskId: run.caller.taskId,
startedAt: run.startedAt,
error,
});
}

const httpCauseSchema = z.object({kind: z.literal('http'), status: z.number()});

// What the activity log keeps of what went wrong: the error's kind, with the provider's HTTP status
// where it answered with one, as `provider_unreachable 429`; never the provider's words, which can
// hold the person's data.
function failureOf(error: ApiError): string {
const cause = 'cause' in error ? httpCauseSchema.safeParse(error.cause) : null;
return cause?.success ? `${error.kind} ${cause.data.status}` : error.kind;
}

// What a call to the provider answered; one that failed is logged as failed first, with `title`
// and `query` as an allowed one would have been.
function logFailure<T>(run: ToolRun, answer: Result<T, ApiError>, title: string | null, query: string | null = null): Promise<Result<T, ApiError>> {
return Do(async ($) => {
if (answer.isErr()) {
$(await log(run, 'failed', title, query, failureOf(answer.error)));
}
return $(answer);
});
}

Expand All @@ -147,7 +172,8 @@ function log(run: ToolRun, outcome: 'allowed' | 'denied', title: string | null,
* caller's task, which has to be one of the agent's and still under way; a run without one is
* refused, unlogged. A connection outside the organization, or a tool its integration doesn't have,
* is not found and not logged; a tool whose action the agent doesn't have is refused and logged as
* denied, saying how to allow it where the connection's sign-in didn't cover the action.
* denied, saying how to allow it where the connection's sign-in didn't cover the action. A run the
* provider fails, turning it down or out of reach, is logged as failed, with what went wrong.
* `readOnly` limits the run to tools that only read, or only to those that change something, as
* the MCP server's `read` and `run` do; any other is refused, unlogged.
*/
Expand Down Expand Up @@ -221,7 +247,7 @@ function loggedParams(args: Record<string, unknown>): string | null {

// A custom server's tool, called as it is: its params go to the server as given, which checks
// them against its own schema, and its answer comes back as text. Logged by the tool's name, with
// its params, allowed or denied.
// its params, allowed, denied or failed.
function runServerTool(
caller: TaskCaller,
agent: AgentRow,
Expand All @@ -237,7 +263,7 @@ function runServerTool(
return $(Err(ApiErr.validationError(`${tool.name} takes an object of params`)));
}
const logged = loggedParams(args);
const logRun = (outcome: 'allowed' | 'denied') =>
const logRun = (outcome: Outcome, error?: string) =>
logAgentRequest({
orgId: caller.agent.orgId,
agentId: caller.agent.agentId,
Expand All @@ -250,12 +276,17 @@ function runServerTool(
via: caller.via,
taskId: caller.taskId,
startedAt,
error,
});
if (!actionsOf(agent, connection, integration, unavailableActions(connection)).includes(tool.action)) {
$(await logRun('denied'));
return $(Err(ApiErr.forbidden()));
}
const text = $(await callCustomTool(connection, tool.name, args));
const answer = await callCustomTool(connection, tool.name, args);
if (answer.isErr()) {
$(await logRun('failed', failureOf(answer.error)));
}
const text = $(answer);
$(await logRun('allowed'));
return {text};
});
Expand Down Expand Up @@ -303,15 +334,15 @@ function list(run: ToolRun, params: Record<string, string>): Promise<Result<Tool
return Do(async ($) => {
const {filterField} = run.target.collection;
const query = {search: searchOf(params), page: params.page ?? null, filter: filterField ? (params[filterField] ?? null) : null, parent: params.parent ?? null};
const page = $(await run.connector.list(run.target, query));
const page = $(await logFailure(run, await run.connector.list(run.target, query), null, query.search));
$(await log(run, 'allowed', page.trail?.at(-1)?.title ?? null, query.search));
return page;
});
}

function get(run: ToolRun, recordId: string): Promise<Result<ToolResult, ApiError>> {
return Do(async ($) => {
const record = $(await run.connector.get(run.target, recordId));
const record = $(await logFailure(run, await run.connector.get(run.target, recordId), null));
$(await log(run, 'allowed', recordTitle(run.target.collection, record.values)));
return {record};
});
Expand All @@ -337,7 +368,7 @@ function create(run: ToolRun, params: Record<string, string>): Promise<Result<To
return Do(async ($) => {
const values = $(parseRecordValues(run.target.collection, fieldsOf(params)));
$(await requireFieldActions(run, values));
const record = $(await run.connector.create(run.target, values, params.parent ?? null));
const record = $(await logFailure(run, await run.connector.create(run.target, values, params.parent ?? null), recordTitle(run.target.collection, values)));
$(await log(run, 'allowed', recordTitle(run.target.collection, record.values)));
return {record};
});
Expand All @@ -348,22 +379,22 @@ function update(run: ToolRun, params: Record<string, string>): Promise<Result<To
return Do(async ($) => {
const {collection} = run.target;
const recordId = params.id ?? '';
const before = $(await run.connector.get(run.target, recordId));
const before = $(await logFailure(run, await run.connector.get(run.target, recordId), null));
$(requireApplies(run.tool.where, before));
const current = Object.fromEntries(collection.fields.filter((field) => !field.system).map((field) => [field.key, before.values[field.key] ?? '']));
const values = $(parseRecordValues(collection, {...current, ...fieldsOf(params)}));
$(await requireFieldActions(run, values, before.values));
const record = $(await run.connector.update(run.target, recordId, values, before));
const record = $(await logFailure(run, await run.connector.update(run.target, recordId, values, before), recordTitle(collection, values)));
$(await log(run, 'allowed', recordTitle(collection, record.values)));
return {record};
});
}

function remove(run: ToolRun, recordId: string): Promise<Result<ToolResult, ApiError>> {
return Do(async ($) => {
const before = $(await run.connector.get(run.target, recordId));
const before = $(await logFailure(run, await run.connector.get(run.target, recordId), null));
$(requireApplies(run.tool.where, before));
$(await run.connector.remove(run.target, recordId));
$(await logFailure(run, await run.connector.remove(run.target, recordId), recordTitle(run.target.collection, before.values)));
$(await log(run, 'allowed', recordTitle(run.target.collection, before.values)));
return {record: null};
});
Expand All @@ -372,9 +403,9 @@ function remove(run: ToolRun, recordId: string): Promise<Result<ToolResult, ApiE
function command(run: ToolRun, recordId: string): Promise<Result<ToolResult, ApiError>> {
return Do(async ($) => {
const runner = $(requirePresent(run.connector.commands?.[run.tool.operation], ApiErr.notFound('tool', run.tool.name)));
const before = $(await run.connector.get(run.target, recordId));
const before = $(await logFailure(run, await run.connector.get(run.target, recordId), null));
$(requireApplies(run.tool.where, before));
const record = $(await runner(run.target, recordId));
const record = $(await logFailure(run, await runner(run.target, recordId), recordTitle(run.target.collection, before.values)));
$(await log(run, 'allowed', recordTitle(run.target.collection, before.values)));
return {record};
});
Expand All @@ -384,7 +415,7 @@ function newCommand(run: ToolRun, params: Record<string, string>): Promise<Resul
return Do(async ($) => {
const runner = $(requirePresent(run.connector.newCommands?.[run.tool.operation], ApiErr.notFound('tool', run.tool.name)));
const values = $(parseRecordValues(run.target.collection, params));
const record = $(await runner(run.target, values));
const record = $(await logFailure(run, await runner(run.target, values), recordTitle(run.target.collection, values)));
$(await log(run, 'allowed', recordTitle(run.target.collection, values)));
return {record};
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- A request the agent was allowed to make that then failed, the provider turning it down or out of
-- reach, is logged as failed, with what went wrong, rather than not at all.
ALTER TYPE "AuditOutcome" ADD VALUE 'failed';

ALTER TABLE "AuditEntry" ADD COLUMN "error" TEXT;
13 changes: 10 additions & 3 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,12 @@ model AgentSession {
@@index([agentId])
}

// Allowed and done, refused by the agent's access, or allowed but failed: the provider turned it
// down, or couldn't be reached.
enum AuditOutcome {
allowed
denied
failed
}

// How an agent came in: the web app's agent pages, or the MCP server.
Expand All @@ -286,9 +289,9 @@ enum AuditVia {
mcp
}

// One request an agent made, allowed or denied, or one thing Personal Agent Proxy did itself for a
// person, as the home page's widgets write. Plain ids rather than relations, so the entry stays
// when the agent or the connection is gone.
// One request an agent made, allowed, denied or failed, or one thing Personal Agent Proxy did
// itself for a person, as the home page's widgets write. Plain ids rather than relations, so the
// entry stays when the agent or the connection is gone.
model AuditEntry {
id String @id @default(cuid())
orgId String
Expand All @@ -311,6 +314,10 @@ model AuditEntry {
// every entry logged since this was kept; none for the entries from before, and for what
// Personal Agent Proxy did itself.
durationMs Int?
// What went wrong, for a failed request: the api's kind of error, such as `credentials_rejected`,
// and the provider's HTTP status after it where it answered with one, such as
// `provider_unreachable 429`. None for every other outcome.
error String?

createdAt DateTime @default(now())

Expand Down
26 changes: 24 additions & 2 deletions packages/db/scripts/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,28 @@ function isAllowed(agent: (typeof agents)[number], request: Request): boolean {
);
}

// Now and then the service fails an allowed request: too many requests, out of reach, or the
// connection's sign-in turned down, as the api logs what went wrong.
const FAILURES: [string, ...string[]] = [
'provider_unreachable 429',
'provider_unreachable 503',
'provider_unreachable',
'credentials_rejected',
];

function outcomeOf(
agent: (typeof agents)[number],
request: Request,
): {outcome: 'allowed' | 'denied' | 'failed'; error: string | null} {
if (!isAllowed(agent, request)) {
return {outcome: 'denied', error: null};
}
if (random() < 0.05) {
return {outcome: 'failed', error: pick(FAILURES)};
}
return {outcome: 'allowed', error: null};
}

// What the agents set out to do: every request is made for a task, as the api logs them, a few
// requests in a row to each.
const TASKS: [{title: string; goal: string}, ...Array<{title: string; goal: string}>] = [
Expand Down Expand Up @@ -693,7 +715,7 @@ for (const agent of agents) {
action: request.action,
recordTitle: onOneRecord ? recordTitle(request.connection, request.collection.id) : null,
query: null,
outcome: isAllowed(agent, request) ? 'allowed' : 'denied',
...outcomeOf(agent, request),
via: task.via,
taskId: task.id,
durationMs: requestDuration(),
Expand Down Expand Up @@ -743,7 +765,7 @@ for (const agent of agents) {
action: request.action,
recordTitle: onOneRecord ? recordTitle(request.connection, collectionId) : null,
query: searched ? pick(SEARCHES) : null,
outcome: isAllowed(agent, request) ? 'allowed' : 'denied',
...outcomeOf(agent, request),
via,
taskId,
durationMs: requestDuration(),
Expand Down
Loading
Loading