Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"format": "prettier --write \"src/**/*.ts\"",
"format:fix": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"prepare": "husky",
"prepare": "bash -c 'if bash ./scripts/check-is-in-git-install.sh; then npm run build; else husky || true; fi'",
"setup": "tsx setup/index.ts",
"auth": "tsx src/whatsapp-auth.ts",
"test:e2e:tasks": "tsx scripts/test-task-sdk-e2e.ts",
Expand Down
16 changes: 16 additions & 0 deletions scripts/check-is-in-git-install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Exit 0 if this `prepare` run is a git-dependency install; non-zero otherwise.
# Contributors can force-skip with AGENTLITE_DEV=1, force-build with AGENTLITE_BUILD=1.

if [ -n "$AGENTLITE_DEV" ]; then
exit 1
fi

if [ -n "$AGENTLITE_BUILD" ]; then
exit 0
fi

parent_name="$(basename "$(dirname "$PWD")")"
[ "$parent_name" = 'node_modules' ] ||
[ "$parent_name" = 'tmp' ] ||
[ "$parent_name" = '.tmp' ]
2 changes: 1 addition & 1 deletion src/acp/client.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ describe('ACP background prompt e2e', () => {

expect(promptResp.status).toBe(200);
expect(promptResp.json.result).toEqual({ ok: true });
expect(promptDurationMs).toBeLessThan(250);
expect(promptDurationMs).toBeLessThan(4000);
expect(
agent.db.getMessagesSince('team@g.us', '', agent.config.assistantName),
).toHaveLength(0);
Expand Down
43 changes: 43 additions & 0 deletions src/agent/action-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ describe('agent.action() registration', () => {
/reserved/,
);
expect(() => agent.action('call_action', () => null)).toThrow(/reserved/);
expect(() => agent.action('tool_usage_summary', () => null)).toThrow(
/reserved/,
);
});

it('accepts names that merely share a prefix with reserved ones', () => {
Expand All @@ -219,4 +222,44 @@ describe('agent.action() registration', () => {
expect(res.json.result).toBe('second');
});
});

describe('built-in tool_usage_summary', () => {
it('is callable after start and returns aggregated rows', async () => {
await (
agent as unknown as {
db: {
recordToolUsage: (entry: {
groupJid: string;
sessionId?: string;
toolName: string;
success: boolean;
errorMessage?: string;
durationMs: number;
}) => Promise<void>;
};
}
).db.recordToolUsage({
groupJid: 'test-group',
sessionId: undefined,
toolName: 'Bash',
success: true,
durationMs: 42,
});

const res = await call('tool_usage_summary', { tool_name: 'Bash' });

expect(res.status).toBe(200);
expect(res.json.result).toEqual({
summary: [
{
toolName: 'Bash',
callCount: 1,
successCount: 1,
successRate: 1,
avgDurationMs: 42,
},
],
});
});
});
});
5 changes: 3 additions & 2 deletions src/agent/actions-http.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* D — healthy path: LAN IP → shim reaches host handler over HTTP
* E — negative: bogus token is rejected and bubbles back through stdio
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import os from 'os';
import path from 'path';
import url from 'url';
Expand All @@ -37,6 +37,7 @@ const SHIM_PATH = path.join(
'dist',
'ipc-mcp-stdio.js',
);
const MCP_REQUEST_TIMEOUT_MS = 15000;

if (!fs.existsSync(SHIM_PATH)) {
throw new Error(
Expand Down Expand Up @@ -106,7 +107,7 @@ class StdioMcpClient {
`MCP request ${method} #${id} timed out. stderr so far:\n${this.stderr}`,
),
);
}, 5000);
}, MCP_REQUEST_TIMEOUT_MS);
this.pending.set(id, (res) => {
clearTimeout(timer);
resolve(res);
Expand Down
27 changes: 26 additions & 1 deletion src/agent/agent-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { AgentDb, initDatabase } from '../db.js';
import { resolveMountAllowlist } from '../mount-security.js';
import { GroupQueue } from '../group-queue.js';
import { writeGroupsSnapshot } from '../container-runner.js';
import type { ZodRawShape } from 'zod';
import { z, type ZodRawShape } from 'zod';

import { startIpcWatcher } from '../ipc.js';
import { ActionsHttp } from './actions-http.js';
Expand Down Expand Up @@ -462,6 +462,31 @@ export class AgentImpl
);
}

this.actions.set('tool_usage_summary', {
description:
'Returns per-tool call count, success rate, and average duration. ' +
'Optionally filter by since (ISO timestamp) and tool_name.',
inputSchema: {
since: z.string().optional().describe('ISO timestamp lower bound'),
tool_name: z.string().optional().describe('Filter to a specific tool'),
},
handler: async (payload) => {
const since =
typeof payload.since === 'string'
? new Date(payload.since)
: undefined;
if (since && Number.isNaN(since.getTime())) {
throw new Error(`Invalid since timestamp: ${payload.since}`);
}

const rows = await this.db.getToolUsageSummary({
since,
toolName: payload.tool_name as string | undefined,
});
return { summary: rows };
},
});

await this.actionsHttp.start();
this.startSubsystems();
this.emit('started');
Expand Down
76 changes: 70 additions & 6 deletions src/agent/message-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@ import {
writeGroupsSnapshot,
} from '../container-runner.js';
import { findChannel, formatMessages } from '../router.js';
import {
isSenderAllowed,
isTriggerAllowed,
loadSenderAllowlist,
shouldDropMessage,
} from '../sender-allowlist.js';
import { isTriggerAllowed, loadSenderAllowlist } from '../sender-allowlist.js';
import { isAcpNoticeMessage } from '../acp/notice.js';
import type { AgentContext } from './agent-context.js';
import type { ChannelManager } from './channel-manager.js';
Expand All @@ -39,10 +34,29 @@ function hasWakeTrigger(
);
}

function extractText(
content: string | Array<{ text?: string | null } | null> | null | undefined,
): string | undefined {
if (typeof content === 'string') {
const text = content.trim();
return text || undefined;
}
if (!Array.isArray(content)) return undefined;
const text = content
.map((block) => block?.text ?? '')
.join('')
.trim();
return text || undefined;
}

export class MessageProcessor {
private messageLoopRunning = false;
private _messageLoopPromise: Promise<void> | null = null;
private _wakeLoop: (() => void) | null = null;
private pendingToolCalls = new Map<
string,
{ toolName: string; startTs: number }
>();

constructor(
private readonly ctx: AgentContext,
Expand Down Expand Up @@ -226,6 +240,10 @@ export class MessageProcessor {
if (event.sdkType === 'assistant' && msg?.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'tool_use' && block.name && block.id) {
this.pendingToolCalls.set(block.id, {
toolName: block.name,
startTs: Date.now(),
});
this.ctx.emit('run.tool', {
agentId: this.ctx.id,
jid: chatJid,
Expand All @@ -241,6 +259,31 @@ export class MessageProcessor {
resetIdleTimer();
}

if (event.sdkType === 'user' && msg?.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'tool_result' && block.tool_use_id) {
const pending = this.pendingToolCalls.get(block.tool_use_id);
if (pending) {
this.pendingToolCalls.delete(block.tool_use_id);
const durationMs = Date.now() - pending.startTs;
const isError = block.is_error === true;
const errorMessage = isError
? extractText(block.content)?.slice(0, 500)
: undefined;
await this.ctx.db.recordToolUsage({
groupJid: chatJid,
sessionId: this.ctx.sessions[group.folder],
toolName: pending.toolName,
success: !isError,
errorMessage,
durationMs,
});
await this.checkToolErrorRateAlert(pending.toolName);
}
}
}
}

if (event.sdkType === 'tool_progress') {
this.ctx.emit('run.tool_progress', {
agentId: this.ctx.id,
Expand Down Expand Up @@ -327,6 +370,27 @@ export class MessageProcessor {
return true;
}

private async checkToolErrorRateAlert(toolName: string): Promise<void> {
const rows = await this.ctx.db.getToolUsageSummary({
since: new Date(Date.now() - 3600_000),
toolName,
});
const row = rows[0];
const errorRate = row ? 1 - row.successRate : 0;
if (row && errorRate > 0.2) {
logger.warn(
{ toolName, callCount: row.callCount, errorRate, windowHours: 1 },
'Tool error rate exceeded 20% in the last hour',
);
this.ctx.emit('run.tool_alert', {
toolName,
errorRate,
callCount: row.callCount,
windowHours: 1,
});
}
}

/** Execute agent in a container for the given group. */
async runAgent(
group: InternalRegisteredGroup,
Expand Down
1 change: 1 addition & 0 deletions src/api/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export const RESERVED_ACTION_TYPES = [
'register_group',
'search_actions',
'call_action',
'tool_usage_summary',
] as const;

const RESERVED_SET: ReadonlySet<string> = new Set(RESERVED_ACTION_TYPES);
Expand Down
13 changes: 13 additions & 0 deletions src/api/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface AgentEvents extends Record<string, any[]> {
'run.state': [payload: RunStateEvent];
'run.sdk_message': [payload: RunSdkMessageEvent];
'run.tool': [payload: RunToolEvent];
'run.tool_alert': [payload: RunToolAlertEvent];
'run.tool_progress': [payload: RunToolProgressEvent];
'run.subagent': [payload: RunSubagentEvent];
'run.status': [payload: RunStatusEvent];
Expand Down Expand Up @@ -132,6 +133,18 @@ export interface RunToolEvent {
timestamp: string;
}

/** Tool error-rate alert for the last hour window. */
export interface RunToolAlertEvent {
/** Tool name. */
toolName: string;
/** Failure rate in the alert window. */
errorRate: number;
/** Number of calls in the alert window. */
callCount: number;
/** Alert window size in hours. */
windowHours: number;
}

/** Tool execution progress heartbeat. */
export interface RunToolProgressEvent {
/** Stable agent identifier. */
Expand Down
2 changes: 1 addition & 1 deletion src/channel-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { AgentImpl } from './agent/agent-impl.js';
import {
Expand Down
6 changes: 1 addition & 5 deletions src/container-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,7 @@ vi.mock('./box-runtime.js', () => ({
spawnBox: (...args: any[]) => mockSpawnBox(...args),
}));

import {
runContainerAgent,
type ContainerEvent,
type ContainerOutput,
} from './container-runner.js';
import { runContainerAgent, type ContainerEvent } from './container-runner.js';
import type { RuntimeConfig } from './runtime-config.js';
import type { RegisteredGroup } from './types.js';

Expand Down
Loading