First running version
This commit is contained in:
225
reference_impl/agents/agent-base.ts
Normal file
225
reference_impl/agents/agent-base.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user