First running version

This commit is contained in:
qingjie.du
2026-03-30 17:39:13 +09:00
parent 5ffea3d849
commit bce2a5672c
67 changed files with 16503 additions and 0 deletions

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

View File

@@ -0,0 +1,77 @@
/**
* 発信・発注・発令 Agent
*
* accessiblePools: [media-pool]
* 利用可能ツール: read_site_stats (media-pool, read),
* send_email (media-pool, action, passkey-workflow),
* post_sns (media-pool, action, passkey-workflow)
*
* タスク: 企画・Plan Agent が作成した施策を実行する。
* send_email は action ツール (enforcement: workflow)。
* 最初の send_email 呼び出し時に一度だけ Passkey 認証を行い、
* 同じ contextId 内の後続呼び出しはキャッシュされた assertion を再利用する。
*/
import type { A2ATaskRequest, A2ATaskResponse } from '../platform-types.js';
import { AgentBase } from '../agent-base.js';
interface Plan {
title: string;
targetDate: string;
campaign: { dessertItem: string };
targetCustomers: Array<{ customerId: string; name: string }>;
emailTemplate: string;
}
export class DispatchAgent extends AgentBase {
readonly agentId = 'dispatch-agent';
readonly accessiblePools = ['media-pool'];
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
const { plan } = task.payload.parameters as { plan?: Plan };
if (!plan) {
return this.error(task, 'No plan provided in task parameters');
}
// Set contextId so buildPlatformCallTool can scope the workflow assertion cache
this._currentContextId = task.contextId;
const results: Array<{ customerId: string; name: string; status: string; error?: string }> = [];
for (const customer of plan.targetCustomers) {
try {
// send_email enforcement: 'workflow'. First call in this contextId triggers
// one Passkey ceremony; subsequent calls reuse the cached assertion.
await this.callTool('send_email', {
to: `${customer.customerId}@example.com`,
subject: `【特別ご招待】明日のご来店に無料${plan.campaign.dessertItem}をプレゼント`,
body: plan.emailTemplate,
customerId: customer.customerId,
});
results.push({ customerId: customer.customerId, name: customer.name, status: 'sent' });
console.log(`[dispatch] Email sent to ${customer.name} (${customer.customerId})`);
} catch (err) {
results.push({
customerId: customer.customerId,
name: customer.name,
status: 'failed',
error: (err as Error).message,
});
console.error(`[dispatch] Failed to send to ${customer.name}: ${(err as Error).message}`);
}
}
const sent = results.filter((r) => r.status === 'sent').length;
const failed = results.filter((r) => r.status === 'failed').length;
console.log(`[dispatch] Campaign dispatch complete: ${sent} sent, ${failed} failed`);
return this.success(task, {
campaign: plan.title,
targetDate: plan.targetDate,
dispatch: { sent, failed, total: plan.targetCustomers.length },
results,
});
}
}

View File

@@ -0,0 +1,109 @@
/**
* 情報収集・分析 Agent
*
* accessiblePools: [info-pool]
* 利用可能ツール: fetch_web_content (info-pool), call_external_api (info-pool),
* query_crm (pool-less), query_erp (pool-less), query_hr (pool-less)
*
* タスク: 天気予報・在庫・顧客キャンセル傾向を収集し、分析サマリを返す
*/
import type { A2ATaskRequest, A2ATaskResponse } from '../platform-types.js';
import { AgentBase } from '../agent-base.js';
export class InfoGatheringAgent extends AgentBase {
readonly agentId = 'info-gathering-agent';
readonly accessiblePools = ['info-pool'];
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
const instruction = (task.payload.parameters['instruction'] as string | undefined) ?? '';
const targetDate = new Date().toISOString().slice(0, 10);
try {
// 1. Collect weather forecast
const weather = await this.callTool('fetch_web_content', {
source: 'weather_forecast',
date: targetDate,
});
// 2. Collect dessert inventory via external API
const inventory = await this.callTool('call_external_api', {
api: 'inventory',
params: { item: 'dessert' },
});
// 3. Query CRM for high-cancellation-tendency customers
const customers = await this.callTool('query_crm', {
entity: 'customers',
filter: 'rain_cancel_tendency',
});
// 4. Query reservations for tomorrow
const reservations = await this.callTool('query_crm', {
entity: 'reservations',
filter: `date=${targetDate}`,
});
// 5. Synthesize analysis
const analysis = this.synthesize({ weather, inventory, customers, reservations, targetDate, instruction });
console.log(`[info-gathering] Analysis complete: ${analysis.summary}`);
return this.success(task, {
analysis,
rawData: { weather, inventory, customers, reservations },
});
} catch (err) {
return this.error(task, (err as Error).message);
}
}
private synthesize(data: {
weather: unknown;
inventory: unknown;
customers: unknown;
reservations: unknown;
targetDate: string;
instruction: string;
}): Record<string, unknown> {
const wx = data.weather as { forecasts?: Array<{ condition?: string; summary?: string; precipitationProbability?: number }> };
const forecast = wx?.forecasts?.[0];
const isRainy = forecast?.condition === 'rainy';
const precipProb = forecast?.precipitationProbability ?? 0;
const inv = data.inventory as { items?: Array<{ name?: string; stock?: number; category?: string }> };
const desserts = (inv?.items ?? []).filter((i) => i.category === 'dessert');
const hasDessertStock = desserts.some((d) => (d.stock ?? 0) > 0);
const cust = data.customers as { customers?: Array<unknown>; total?: number };
const highCancelCount = cust?.total ?? (cust?.customers?.length ?? 0);
const res = data.reservations as { reservations?: Array<unknown>; total?: number };
const reservationCount = res?.total ?? (res?.reservations?.length ?? 0);
const actionRecommended = isRainy && hasDessertStock && highCancelCount > 0;
return {
targetDate: data.targetDate,
weather: {
condition: forecast?.condition ?? 'unknown',
isRainy,
precipitationProbability: precipProb,
summary: forecast?.summary ?? '',
},
inventory: {
hasDessertStock,
dessertsAvailable: desserts.map((d) => ({ name: d.name, stock: d.stock })),
},
customers: {
highCancelTendencyCount: highCancelCount,
},
reservations: {
tomorrowCount: reservationCount,
},
actionRecommended,
summary: actionRecommended
? `明日は雨予報(${precipProb}%)で、デザート在庫あり、高キャンセル傾向顧客${highCancelCount}名・予約${reservationCount}件あり。無料デザートキャンペーンを推奨。`
: `現時点でキャンペーン実施条件が揃っていません。`,
};
}
}

View File

@@ -0,0 +1,108 @@
/**
* 企画・Plan Agent
*
* accessiblePools: [] (pool-less のみ)
* 利用可能ツール: query_crm, query_erp, query_hr (全て pool-less)
*
* タスク: 情報収集結果を受け取り、具体的な施策を立案する。
* Passkey (host-only) で店長に承認を求める。
*/
import type { A2ATaskRequest, A2ATaskResponse } from '../platform-types.js';
import { AgentBase } from '../agent-base.js';
interface AnalysisResult {
targetDate: string;
weather: { isRainy: boolean; precipitationProbability: number; summary: string };
inventory: { hasDessertStock: boolean; dessertsAvailable: Array<{ name: string; stock: number }> };
customers: { highCancelTendencyCount: number };
reservations: { tomorrowCount: number };
actionRecommended: boolean;
summary: string;
}
export class PlanningAgent extends AgentBase {
readonly agentId = 'planning-agent';
readonly accessiblePools: string[] = [];
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
const { analysis, rawData } = task.payload.parameters as {
analysis?: AnalysisResult;
rawData?: unknown;
};
if (!analysis) {
return this.error(task, 'No analysis data provided in task parameters');
}
if (!analysis.actionRecommended) {
return this.cancelled(task, `施策実施条件未達: ${analysis.summary}`);
}
try {
// 1. Get today's reservations from CRM for targeting
const reservationData = await this.callTool('query_crm', {
entity: 'reservations',
filter: `date=${analysis.targetDate}`,
}) as { reservations?: Array<{ customerName: string; customerId: string }> };
// 2. Get dessert inventory details from ERP
const inventoryData = await this.callTool('query_erp', {
entity: 'inventory',
item: 'dessert',
}) as { items?: Array<{ name: string; stock: number; costPerUnit: number }> };
// 3. Build the plan
const desserts = (inventoryData?.items ?? []).filter((i) => (i.stock ?? 0) > 0);
const targetDessert = desserts[0];
const targetCustomers = (reservationData?.reservations ?? []).slice(0, analysis.customers.highCancelTendencyCount);
const plan = {
title: '無料デザートキャンペーン',
targetDate: analysis.targetDate,
rationale: analysis.summary,
campaign: {
dessertItem: targetDessert?.name ?? 'デザート',
freeItemPerCustomer: 1,
totalCost: (targetDessert?.costPerUnit ?? 0) * targetCustomers.length,
},
targetCustomers: targetCustomers.map((c) => ({
customerId: c.customerId,
name: c.customerName,
})),
emailTemplate: buildEmailTemplate(analysis.targetDate, targetDessert?.name ?? 'デザート'),
createdAt: new Date().toISOString(),
status: 'pending_approval',
};
console.log(
`[planning] Plan created: ${plan.title} for ${targetCustomers.length} customers`,
);
// 4. Approval is handled by the Host (Passkey ceremony triggered by DispatchAgent)
// PlanningAgent returns the plan with status: pending_approval.
// The orchestration layer (Director → Dispatch) is responsible for auth flow.
return this.success(task, {
plan,
rawData,
nextAgent: 'dispatch-agent',
});
} catch (err) {
return this.error(task, (err as Error).message);
}
}
}
function buildEmailTemplate(date: string, dessertName: string): string {
return `件名: 【特別ご招待】明日のご来店に無料${dessertName}をプレゼント
お客様へ
明日 ${date} のご予約、誠にありがとうございます。
明日は雨模様のお天気が予想されますが、
特別に無料の${dessertName}をご用意しております。
ぜひお越しください。スタッフ一同、お待ちしております。
※ 本メールは予約システムより自動送信されています。`;
}

View File

@@ -0,0 +1,33 @@
/**
* Minimal A2A type definitions for reference implementation agents.
* Mirrors platform_impl/src/types/a2a.ts — keep in sync.
*/
export interface A2ATaskRequest {
messageId: string;
contextId?: string;
senderId: string;
recipientId: string;
timestamp?: string;
locale?: string;
type: 'task_request';
payload: {
parameters: Record<string, unknown>;
history?: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>;
};
}
export interface A2ATaskResponse {
messageId: string;
contextId?: string;
senderId: string;
recipientId: string;
timestamp?: string;
type: 'task_response';
replyToMessageId: string;
status: 'success' | 'error' | 'timeout' | 'cancelled' | 'partial';
payload?: {
result?: unknown;
error?: { message: string; code?: string };
};
}

View File

@@ -0,0 +1,22 @@
/**
* Register reference implementation Agent classes with the platform Host.
* Import this module before calling MCPletHost.start() to make the agent
* classes available for instantiation from config.
*
* Usage in platform host entry point:
* import '../../reference_impl/agents/register.js';
* import { MCPletHost } from '../platform_impl/src/host/mcplet-host.js';
*/
import { InfoGatheringAgent } from './info-gathering/index.js';
import { PlanningAgent } from './planning/index.js';
import { DispatchAgent } from './dispatch/index.js';
// Lazy import of registerAgentClass to avoid circular dependency at module level
// In production, this registration is done in the host entry point.
export { InfoGatheringAgent, PlanningAgent, DispatchAgent };
export const AGENT_CLASSES: Record<string, new (...args: never[]) => unknown> = {
InfoGatheringAgent,
PlanningAgent,
DispatchAgent,
};