226 lines
7.9 KiB
TypeScript
226 lines
7.9 KiB
TypeScript
/**
|
|
* Lightweight AgentBase for reference implementation.
|
|
*
|
|
* When instantiated by the platform Host, deps (AgentDeps from platform_impl)
|
|
* is passed as the third constructor argument. AgentBase wires callTool to go
|
|
* through the platform's MCPlet router with pool-access, rate-limit, and
|
|
* Passkey enforcement — without importing platform types directly.
|
|
*
|
|
* In standalone/test mode, call bindCallTool() to inject a mock implementation.
|
|
*/
|
|
import { randomUUID } from 'node:crypto';
|
|
import type { A2ATaskRequest, A2ATaskResponse } from './platform-types.js';
|
|
|
|
// Structural shape of the platform deps we need (duck typing, no import required)
|
|
interface PlatformDeps {
|
|
mcpRouter?: {
|
|
callTool?: (
|
|
toolName: string,
|
|
args: Record<string, unknown>,
|
|
) => Promise<{ content: Array<{ type: string; text?: string }> }>;
|
|
};
|
|
poolRegistry?: {
|
|
getAllTools?: () => Array<{
|
|
name: string;
|
|
meta: {
|
|
pool?: string;
|
|
mcpletType: string;
|
|
auth?: { required?: string; enforcement?: string; promptMessage?: string };
|
|
};
|
|
}>;
|
|
canAgentAccess?: (agentId: string, pool: string | undefined, pools: string[]) => boolean;
|
|
checkRateLimit?: (pool: string) => boolean;
|
|
};
|
|
auditLog?: {
|
|
record?: (entry: Record<string, unknown>) => void;
|
|
};
|
|
passkeyServer?: {
|
|
startCeremony?: (message: string) => Promise<unknown>;
|
|
};
|
|
}
|
|
|
|
export abstract class AgentBase {
|
|
abstract readonly agentId: string;
|
|
abstract readonly accessiblePools: string[];
|
|
|
|
abstract handle(task: A2ATaskRequest): Promise<A2ATaskResponse>;
|
|
|
|
protected callTool: (
|
|
toolName: string,
|
|
args: Record<string, unknown>,
|
|
) => Promise<unknown>;
|
|
|
|
/** Set by subclasses (or AgentBase.handle wrapper) before calling tools in a task. */
|
|
protected _currentContextId: string | undefined = undefined;
|
|
|
|
/** Per-contextId assertion cache for 'workflow' enforcement tools. */
|
|
protected readonly _assertionCache = new Map<string, unknown>();
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
constructor(_agentId?: string, _accessiblePools?: string[], deps?: any) {
|
|
if (deps) {
|
|
this.callTool = buildPlatformCallTool(this as unknown as AgentBase & { agentId: string; accessiblePools: string[] }, deps as PlatformDeps);
|
|
} else {
|
|
this.callTool = async (toolName) => {
|
|
throw new Error(
|
|
`[agent-base] callTool("${toolName}") called without a bound implementation. ` +
|
|
'Inject via bindCallTool() or use the platform Host.',
|
|
);
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Bind a tool caller (used in standalone/test mode). */
|
|
bindCallTool(fn: (toolName: string, args: Record<string, unknown>) => Promise<unknown>): void {
|
|
this.callTool = fn;
|
|
}
|
|
|
|
protected success(task: A2ATaskRequest, result: unknown): A2ATaskResponse {
|
|
return {
|
|
messageId: randomUUID(),
|
|
contextId: task.contextId,
|
|
senderId: this.agentId,
|
|
recipientId: task.senderId,
|
|
timestamp: new Date().toISOString(),
|
|
type: 'task_response',
|
|
replyToMessageId: task.messageId,
|
|
status: 'success',
|
|
payload: { result },
|
|
};
|
|
}
|
|
|
|
protected error(task: A2ATaskRequest, message: string, code?: string): A2ATaskResponse {
|
|
return {
|
|
messageId: randomUUID(),
|
|
contextId: task.contextId,
|
|
senderId: this.agentId,
|
|
recipientId: task.senderId,
|
|
timestamp: new Date().toISOString(),
|
|
type: 'task_response',
|
|
replyToMessageId: task.messageId,
|
|
status: 'error',
|
|
payload: { error: { message, code } },
|
|
};
|
|
}
|
|
|
|
protected cancelled(task: A2ATaskRequest, reason: string): A2ATaskResponse {
|
|
return {
|
|
messageId: randomUUID(),
|
|
contextId: task.contextId,
|
|
senderId: this.agentId,
|
|
recipientId: task.senderId,
|
|
timestamp: new Date().toISOString(),
|
|
type: 'task_response',
|
|
replyToMessageId: task.messageId,
|
|
status: 'cancelled',
|
|
payload: { error: { message: reason } },
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a callTool function that goes through the platform's MCPlet router
|
|
* with pool-access, rate-limit, and Passkey enforcement.
|
|
*/
|
|
function buildPlatformCallTool(
|
|
agent: AgentBase & { agentId: string; accessiblePools: string[] },
|
|
deps: PlatformDeps,
|
|
): (toolName: string, args: Record<string, unknown>) => Promise<unknown> {
|
|
// Access protected cache fields via a typed cast (same class file, safe).
|
|
const agentCache = agent as unknown as {
|
|
_currentContextId: string | undefined;
|
|
_assertionCache: Map<string, unknown>;
|
|
};
|
|
|
|
return async (toolName: string, args: Record<string, unknown>) => {
|
|
const tools = deps.poolRegistry?.getAllTools?.() ?? [];
|
|
const tool = tools.find((t) => t.name === toolName);
|
|
|
|
// Pool access check
|
|
if (tool?.meta.pool) {
|
|
const canAccess =
|
|
deps.poolRegistry?.canAgentAccess?.(agent.agentId, tool.meta.pool, agent.accessiblePools) ?? true;
|
|
if (!canAccess) {
|
|
throw new Error(`[${agent.agentId}] Pool access denied for tool "${toolName}" (pool: ${tool.meta.pool})`);
|
|
}
|
|
}
|
|
|
|
// Rate limit check
|
|
if (tool?.meta.pool) {
|
|
const allowed = deps.poolRegistry?.checkRateLimit?.(tool.meta.pool) ?? true;
|
|
if (!allowed) {
|
|
throw new Error(`[${agent.agentId}] Rate limit exceeded for pool "${tool.meta.pool}"`);
|
|
}
|
|
}
|
|
|
|
// Passkey interception for action tools
|
|
let callArgs = args;
|
|
if (tool?.meta.mcpletType === 'action' && tool.meta.auth?.required === 'passkey') {
|
|
const enforcement = tool.meta.auth.enforcement ?? 'strict';
|
|
|
|
let assertion: unknown;
|
|
if (enforcement === 'workflow') {
|
|
// Cache key: contextId (scopes assertion to one Director cycle)
|
|
const contextId = agentCache._currentContextId ?? 'default';
|
|
if (agentCache._assertionCache.has(contextId)) {
|
|
assertion = agentCache._assertionCache.get(contextId);
|
|
console.log(`[${agent.agentId}] Reusing cached Passkey assertion for contextId="${contextId}" (tool: ${toolName})`);
|
|
} else {
|
|
assertion = await deps.passkeyServer?.startCeremony?.(
|
|
tool.meta.auth.promptMessage ?? `Authorize workflow actions for this task`,
|
|
);
|
|
if (!assertion) {
|
|
throw new Error(`[${agent.agentId}] Passkey authentication cancelled for "${toolName}"`);
|
|
}
|
|
agentCache._assertionCache.set(contextId, assertion);
|
|
console.log(`[${agent.agentId}] Passkey assertion cached for contextId="${contextId}"`);
|
|
}
|
|
} else {
|
|
// 'strict': new ceremony per invocation
|
|
assertion = await deps.passkeyServer?.startCeremony?.(
|
|
tool.meta.auth.promptMessage ?? `Authorize action: ${toolName}`,
|
|
);
|
|
if (!assertion) {
|
|
throw new Error(`[${agent.agentId}] Passkey authentication cancelled for "${toolName}"`);
|
|
}
|
|
}
|
|
|
|
callArgs = { ...args, _mcplet_auth: assertion };
|
|
}
|
|
|
|
// Audit log for action tools
|
|
if (tool?.meta.mcpletType === 'action') {
|
|
deps.auditLog?.record?.({
|
|
type: 'action_invocation',
|
|
agentId: agent.agentId,
|
|
toolName,
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
if (!deps.mcpRouter?.callTool) {
|
|
throw new Error(`[${agent.agentId}] mcpRouter not available for tool "${toolName}"`);
|
|
}
|
|
|
|
const result = await deps.mcpRouter.callTool(toolName, callArgs);
|
|
const text = result.content.find((c) => c.type === 'text')?.text ?? '{}';
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
|
|
// MCPlet server wraps results in { result, _meta } envelope — unwrap it.
|
|
const envelope = parsed as { result?: unknown; error?: { message: string; code: string } };
|
|
if (envelope && typeof envelope === 'object' && 'error' in envelope && envelope.error) {
|
|
throw new Error(`Tool "${toolName}" returned error: ${envelope.error.message} (${envelope.error.code})`);
|
|
}
|
|
if (envelope && typeof envelope === 'object' && 'result' in envelope) {
|
|
return envelope.result;
|
|
}
|
|
return parsed;
|
|
};
|
|
}
|