Files
MCPletA2A/reference_impl/agents/agent-base.ts
2026-03-30 17:39:13 +09:00

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;
};
}