feat: implement Phase 4 - Multi-Agent Swarm + Skill Discovery
Phase 4a: Agent Swarm Collaboration Framework (agent-swarm.ts) - AgentSwarm class with configurable coordinator + specialist agents - Three collaboration modes: Sequential (chain), Parallel (concurrent), Debate (multi-round) - Auto task decomposition based on specialist capabilities - Debate consensus detection with keyword similarity heuristic - Rule-based result aggregation with structured markdown output - Specialist management (add/update/remove) and config updates - History persistence to localStorage (last 25 tasks) - Memory integration: saves task completion as lesson memories Phase 4b: Skill Discovery Engine (skill-discovery.ts) - SkillDiscoveryEngine with 12 built-in skill definitions from skills/ directory - Multi-signal search: name, description, triggers, capabilities, category matching - Conversation-based skill recommendation via topic extraction (CN + EN patterns) - Memory-augmented confidence scoring for suggestions - Skill registration, install status toggle, category filtering - localStorage persistence for skill index and suggestion cache Phase 4c: chatStore Integration - dispatchSwarmTask(description, style): creates and executes swarm task, adds result as message - searchSkills(query): exposes skill search to UI layer Tests: 317 passing across 13 test files (43 new for swarm + skills) - AgentSwarm: createTask, sequential/parallel/debate execution, history, specialist mgmt - SkillDiscovery: search, suggest, register, persist, categories Refs: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md updated - all 4 phases complete
This commit is contained in:
549
desktop/src/lib/agent-swarm.ts
Normal file
549
desktop/src/lib/agent-swarm.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* Agent Swarm - Multi-Agent collaboration framework for ZCLAW
|
||||
*
|
||||
* Enables multiple agents (clones) to collaborate on complex tasks through:
|
||||
* - Sequential: Agents process in chain, each building on the previous
|
||||
* - Parallel: Agents work simultaneously on different subtasks
|
||||
* - Debate: Agents provide competing perspectives, coordinator synthesizes
|
||||
*
|
||||
* Integrates with existing Clone/Agent infrastructure via agentStore/gatewayStore.
|
||||
*
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.5.1
|
||||
*/
|
||||
|
||||
import { getMemoryManager } from './agent-memory';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export type CommunicationStyle = 'sequential' | 'parallel' | 'debate';
|
||||
export type TaskDecomposition = 'auto' | 'manual';
|
||||
export type SubtaskStatus = 'pending' | 'running' | 'done' | 'failed';
|
||||
export type SwarmTaskStatus = 'planning' | 'executing' | 'aggregating' | 'done' | 'failed';
|
||||
|
||||
export interface SwarmSpecialist {
|
||||
agentId: string;
|
||||
role: string;
|
||||
capabilities: string[];
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface SwarmConfig {
|
||||
coordinator: string;
|
||||
specialists: SwarmSpecialist[];
|
||||
taskDecomposition: TaskDecomposition;
|
||||
communicationStyle: CommunicationStyle;
|
||||
maxRoundsDebate: number;
|
||||
timeoutPerSubtaskMs: number;
|
||||
}
|
||||
|
||||
export interface Subtask {
|
||||
id: string;
|
||||
assignedTo: string;
|
||||
description: string;
|
||||
status: SubtaskStatus;
|
||||
result?: string;
|
||||
error?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
round?: number;
|
||||
}
|
||||
|
||||
export interface SwarmTask {
|
||||
id: string;
|
||||
description: string;
|
||||
subtasks: Subtask[];
|
||||
status: SwarmTaskStatus;
|
||||
communicationStyle: CommunicationStyle;
|
||||
finalResult?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SwarmExecutionResult {
|
||||
task: SwarmTask;
|
||||
summary: string;
|
||||
participantCount: number;
|
||||
totalDurationMs: number;
|
||||
}
|
||||
|
||||
export type AgentExecutor = (
|
||||
agentId: string,
|
||||
prompt: string,
|
||||
context?: string
|
||||
) => Promise<string>;
|
||||
|
||||
// === Default Config ===
|
||||
|
||||
export const DEFAULT_SWARM_CONFIG: SwarmConfig = {
|
||||
coordinator: 'zclaw-main',
|
||||
specialists: [],
|
||||
taskDecomposition: 'auto',
|
||||
communicationStyle: 'sequential',
|
||||
maxRoundsDebate: 3,
|
||||
timeoutPerSubtaskMs: 60_000,
|
||||
};
|
||||
|
||||
// === Storage ===
|
||||
|
||||
const SWARM_HISTORY_KEY = 'zclaw-swarm-history';
|
||||
|
||||
// === Swarm Engine ===
|
||||
|
||||
export class AgentSwarm {
|
||||
private config: SwarmConfig;
|
||||
private history: SwarmTask[] = [];
|
||||
private executor: AgentExecutor | null = null;
|
||||
|
||||
constructor(config?: Partial<SwarmConfig>) {
|
||||
this.config = { ...DEFAULT_SWARM_CONFIG, ...config };
|
||||
this.loadHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the executor function used to dispatch prompts to individual agents.
|
||||
* This decouples the swarm from the gateway transport layer.
|
||||
*/
|
||||
setExecutor(executor: AgentExecutor): void {
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
// === Task Creation ===
|
||||
|
||||
/**
|
||||
* Create a new swarm task. If taskDecomposition is 'auto', subtasks are
|
||||
* generated based on specialist capabilities and the task description.
|
||||
*/
|
||||
createTask(
|
||||
description: string,
|
||||
options?: {
|
||||
subtasks?: Array<{ assignedTo: string; description: string }>;
|
||||
communicationStyle?: CommunicationStyle;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): SwarmTask {
|
||||
const style = options?.communicationStyle || this.config.communicationStyle;
|
||||
const taskId = `swarm_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
let subtasks: Subtask[];
|
||||
|
||||
if (options?.subtasks && options.subtasks.length > 0) {
|
||||
// Manual decomposition
|
||||
subtasks = options.subtasks.map((st, i) => ({
|
||||
id: `${taskId}_sub_${i}`,
|
||||
assignedTo: st.assignedTo,
|
||||
description: st.description,
|
||||
status: 'pending' as SubtaskStatus,
|
||||
}));
|
||||
} else {
|
||||
// Auto decomposition based on specialists
|
||||
subtasks = this.autoDecompose(taskId, description, style);
|
||||
}
|
||||
|
||||
const task: SwarmTask = {
|
||||
id: taskId,
|
||||
description,
|
||||
subtasks,
|
||||
status: 'planning',
|
||||
communicationStyle: style,
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata: options?.metadata,
|
||||
};
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a swarm task using the configured communication style.
|
||||
*/
|
||||
async execute(task: SwarmTask): Promise<SwarmExecutionResult> {
|
||||
if (!this.executor) {
|
||||
throw new Error('[AgentSwarm] No executor set. Call setExecutor() first.');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
task.status = 'executing';
|
||||
|
||||
try {
|
||||
switch (task.communicationStyle) {
|
||||
case 'sequential':
|
||||
await this.executeSequential(task);
|
||||
break;
|
||||
case 'parallel':
|
||||
await this.executeParallel(task);
|
||||
break;
|
||||
case 'debate':
|
||||
await this.executeDebate(task);
|
||||
break;
|
||||
}
|
||||
|
||||
// Aggregation phase
|
||||
task.status = 'aggregating';
|
||||
task.finalResult = await this.aggregate(task);
|
||||
task.status = 'done';
|
||||
task.completedAt = new Date().toISOString();
|
||||
} catch (err) {
|
||||
task.status = 'failed';
|
||||
task.finalResult = `执行失败: ${err instanceof Error ? err.message : String(err)}`;
|
||||
task.completedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
const totalDurationMs = Date.now() - startTime;
|
||||
|
||||
// Store in history
|
||||
this.history.push(task);
|
||||
if (this.history.length > 50) {
|
||||
this.history = this.history.slice(-25);
|
||||
}
|
||||
this.saveHistory();
|
||||
|
||||
// Save task result as memory
|
||||
try {
|
||||
await getMemoryManager().save({
|
||||
agentId: this.config.coordinator,
|
||||
content: `协作任务完成: "${task.description}" — ${task.subtasks.length}个子任务, 模式: ${task.communicationStyle}, 结果: ${(task.finalResult || '').slice(0, 200)}`,
|
||||
type: 'lesson',
|
||||
importance: 6,
|
||||
source: 'auto',
|
||||
tags: ['swarm', task.communicationStyle],
|
||||
});
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
const result: SwarmExecutionResult = {
|
||||
task,
|
||||
summary: task.finalResult || '',
|
||||
participantCount: new Set(task.subtasks.map(s => s.assignedTo)).size,
|
||||
totalDurationMs,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[AgentSwarm] Task "${task.description}" completed in ${totalDurationMs}ms, ` +
|
||||
`${result.participantCount} participants, ${task.subtasks.length} subtasks`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// === Execution Strategies ===
|
||||
|
||||
/**
|
||||
* Sequential: Each agent runs in order, receiving the previous agent's output as context.
|
||||
*/
|
||||
private async executeSequential(task: SwarmTask): Promise<void> {
|
||||
let previousResult = '';
|
||||
|
||||
for (const subtask of task.subtasks) {
|
||||
subtask.status = 'running';
|
||||
subtask.startedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const context = previousResult
|
||||
? `前一个Agent的输出:\n${previousResult}\n\n请基于以上内容继续完成你的部分。`
|
||||
: '';
|
||||
|
||||
const result = await this.executeSubtask(subtask, context);
|
||||
subtask.result = result;
|
||||
subtask.status = 'done';
|
||||
previousResult = result;
|
||||
} catch (err) {
|
||||
subtask.status = 'failed';
|
||||
subtask.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
subtask.completedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parallel: All agents run simultaneously, results collected independently.
|
||||
*/
|
||||
private async executeParallel(task: SwarmTask): Promise<void> {
|
||||
const promises = task.subtasks.map(async (subtask) => {
|
||||
subtask.status = 'running';
|
||||
subtask.startedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const result = await this.executeSubtask(subtask);
|
||||
subtask.result = result;
|
||||
subtask.status = 'done';
|
||||
} catch (err) {
|
||||
subtask.status = 'failed';
|
||||
subtask.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
subtask.completedAt = new Date().toISOString();
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debate: All agents respond to the same prompt in multiple rounds.
|
||||
* Each round, agents can see previous round results.
|
||||
*/
|
||||
private async executeDebate(task: SwarmTask): Promise<void> {
|
||||
const maxRounds = this.config.maxRoundsDebate;
|
||||
const agents = [...new Set(task.subtasks.map(s => s.assignedTo))];
|
||||
|
||||
for (let round = 1; round <= maxRounds; round++) {
|
||||
const roundContext = round > 1
|
||||
? this.buildDebateContext(task, round - 1)
|
||||
: '';
|
||||
|
||||
const roundPromises = agents.map(async (agentId) => {
|
||||
const subtaskId = `${task.id}_debate_r${round}_${agentId}`;
|
||||
const subtask: Subtask = {
|
||||
id: subtaskId,
|
||||
assignedTo: agentId,
|
||||
description: task.description,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
round,
|
||||
};
|
||||
|
||||
try {
|
||||
const prompt = round === 1
|
||||
? task.description
|
||||
: `这是第${round}轮讨论。请参考其他Agent的观点,给出你的更新后的分析。\n\n${roundContext}`;
|
||||
|
||||
const result = await this.executeSubtask(subtask, '', prompt);
|
||||
subtask.result = result;
|
||||
subtask.status = 'done';
|
||||
} catch (err) {
|
||||
subtask.status = 'failed';
|
||||
subtask.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
subtask.completedAt = new Date().toISOString();
|
||||
task.subtasks.push(subtask);
|
||||
});
|
||||
|
||||
await Promise.allSettled(roundPromises);
|
||||
|
||||
// Check if consensus reached (all agents give similar answers)
|
||||
if (round < maxRounds && this.checkConsensus(task, round)) {
|
||||
console.log(`[AgentSwarm] Debate consensus reached at round ${round}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
private async executeSubtask(
|
||||
subtask: Subtask,
|
||||
context?: string,
|
||||
promptOverride?: string
|
||||
): Promise<string> {
|
||||
if (!this.executor) throw new Error('No executor');
|
||||
|
||||
const prompt = promptOverride || subtask.description;
|
||||
return this.executor(subtask.assignedTo, prompt, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-decompose a task into subtasks based on specialist roles.
|
||||
*/
|
||||
private autoDecompose(
|
||||
taskId: string,
|
||||
description: string,
|
||||
style: CommunicationStyle
|
||||
): Subtask[] {
|
||||
const specialists = this.config.specialists;
|
||||
|
||||
if (specialists.length === 0) {
|
||||
// Single agent fallback
|
||||
return [{
|
||||
id: `${taskId}_sub_0`,
|
||||
assignedTo: this.config.coordinator,
|
||||
description,
|
||||
status: 'pending',
|
||||
}];
|
||||
}
|
||||
|
||||
if (style === 'debate') {
|
||||
// In debate mode, all specialists get the same task
|
||||
return specialists.map((spec, i) => ({
|
||||
id: `${taskId}_sub_${i}`,
|
||||
assignedTo: spec.agentId,
|
||||
description: `作为${spec.role},请分析以下问题: ${description}`,
|
||||
status: 'pending' as SubtaskStatus,
|
||||
round: 1,
|
||||
}));
|
||||
}
|
||||
|
||||
if (style === 'parallel') {
|
||||
// In parallel, assign based on capabilities
|
||||
return specialists.map((spec, i) => ({
|
||||
id: `${taskId}_sub_${i}`,
|
||||
assignedTo: spec.agentId,
|
||||
description: `从${spec.role}的角度完成: ${description}`,
|
||||
status: 'pending' as SubtaskStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sequential: chain through specialists
|
||||
return specialists.map((spec, i) => ({
|
||||
id: `${taskId}_sub_${i}`,
|
||||
assignedTo: spec.agentId,
|
||||
description: i === 0
|
||||
? `作为${spec.role},请首先分析: ${description}`
|
||||
: `作为${spec.role},请基于前一位同事的分析继续深化: ${description}`,
|
||||
status: 'pending' as SubtaskStatus,
|
||||
}));
|
||||
}
|
||||
|
||||
private buildDebateContext(task: SwarmTask, upToRound: number): string {
|
||||
const roundSubtasks = task.subtasks.filter(
|
||||
s => s.round && s.round <= upToRound && s.status === 'done'
|
||||
);
|
||||
|
||||
if (roundSubtasks.length === 0) return '';
|
||||
|
||||
const lines = roundSubtasks.map(s => {
|
||||
const specialist = this.config.specialists.find(sp => sp.agentId === s.assignedTo);
|
||||
const role = specialist?.role || s.assignedTo;
|
||||
return `**${role}** (第${s.round}轮):\n${s.result || '(无输出)'}`;
|
||||
});
|
||||
|
||||
return `其他Agent的观点:\n\n${lines.join('\n\n---\n\n')}`;
|
||||
}
|
||||
|
||||
private checkConsensus(task: SwarmTask, round: number): boolean {
|
||||
const roundResults = task.subtasks
|
||||
.filter(s => s.round === round && s.status === 'done' && s.result)
|
||||
.map(s => s.result!);
|
||||
|
||||
if (roundResults.length < 2) return false;
|
||||
|
||||
// Simple heuristic: if all results share > 60% keywords, consider consensus
|
||||
const tokenSets = roundResults.map(r =>
|
||||
new Set(r.toLowerCase().split(/[\s,;.!?。,;!?]+/).filter(t => t.length > 1))
|
||||
);
|
||||
|
||||
for (let i = 1; i < tokenSets.length; i++) {
|
||||
const common = [...tokenSets[0]].filter(t => tokenSets[i].has(t));
|
||||
const similarity = (2 * common.length) / (tokenSets[0].size + tokenSets[i].size);
|
||||
if (similarity < 0.6) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate subtask results into a final summary.
|
||||
* Phase 4: rule-based. Future: LLM-powered synthesis.
|
||||
*/
|
||||
private async aggregate(task: SwarmTask): Promise<string> {
|
||||
const completedSubtasks = task.subtasks.filter(s => s.status === 'done' && s.result);
|
||||
const failedSubtasks = task.subtasks.filter(s => s.status === 'failed');
|
||||
|
||||
if (completedSubtasks.length === 0) {
|
||||
return failedSubtasks.length > 0
|
||||
? `所有子任务失败: ${failedSubtasks.map(s => s.error).join('; ')}`
|
||||
: '无结果';
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
sections.push(`## 协作任务: ${task.description}`);
|
||||
sections.push(`**模式**: ${task.communicationStyle} | **参与者**: ${new Set(completedSubtasks.map(s => s.assignedTo)).size}`);
|
||||
|
||||
if (task.communicationStyle === 'debate') {
|
||||
// Group by round
|
||||
const maxRound = Math.max(...completedSubtasks.map(s => s.round || 1));
|
||||
for (let r = 1; r <= maxRound; r++) {
|
||||
const roundResults = completedSubtasks.filter(s => s.round === r);
|
||||
if (roundResults.length > 0) {
|
||||
sections.push(`\n### 第${r}轮讨论`);
|
||||
for (const s of roundResults) {
|
||||
const spec = this.config.specialists.find(sp => sp.agentId === s.assignedTo);
|
||||
sections.push(`**${spec?.role || s.assignedTo}**:\n${s.result}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const s of completedSubtasks) {
|
||||
const spec = this.config.specialists.find(sp => sp.agentId === s.assignedTo);
|
||||
sections.push(`\n### ${spec?.role || s.assignedTo}\n${s.result}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedSubtasks.length > 0) {
|
||||
sections.push(`\n### 失败的子任务 (${failedSubtasks.length})`);
|
||||
for (const s of failedSubtasks) {
|
||||
sections.push(`- ${s.assignedTo}: ${s.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
// === Query ===
|
||||
|
||||
getHistory(limit: number = 20): SwarmTask[] {
|
||||
return this.history.slice(-limit);
|
||||
}
|
||||
|
||||
getTask(taskId: string): SwarmTask | undefined {
|
||||
return this.history.find(t => t.id === taskId);
|
||||
}
|
||||
|
||||
getSpecialists(): SwarmSpecialist[] {
|
||||
return [...this.config.specialists];
|
||||
}
|
||||
|
||||
addSpecialist(specialist: SwarmSpecialist): void {
|
||||
const existing = this.config.specialists.findIndex(s => s.agentId === specialist.agentId);
|
||||
if (existing >= 0) {
|
||||
this.config.specialists[existing] = specialist;
|
||||
} else {
|
||||
this.config.specialists.push(specialist);
|
||||
}
|
||||
}
|
||||
|
||||
removeSpecialist(agentId: string): void {
|
||||
this.config.specialists = this.config.specialists.filter(s => s.agentId !== agentId);
|
||||
}
|
||||
|
||||
// === Config ===
|
||||
|
||||
getConfig(): SwarmConfig {
|
||||
return { ...this.config, specialists: [...this.config.specialists] };
|
||||
}
|
||||
|
||||
updateConfig(updates: Partial<SwarmConfig>): void {
|
||||
this.config = { ...this.config, ...updates };
|
||||
}
|
||||
|
||||
// === Persistence ===
|
||||
|
||||
private loadHistory(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(SWARM_HISTORY_KEY);
|
||||
if (raw) this.history = JSON.parse(raw);
|
||||
} catch {
|
||||
this.history = [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveHistory(): void {
|
||||
try {
|
||||
localStorage.setItem(SWARM_HISTORY_KEY, JSON.stringify(this.history.slice(-25)));
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: AgentSwarm | null = null;
|
||||
|
||||
export function getAgentSwarm(config?: Partial<SwarmConfig>): AgentSwarm {
|
||||
if (!_instance) {
|
||||
_instance = new AgentSwarm(config);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetAgentSwarm(): void {
|
||||
_instance = null;
|
||||
}
|
||||
468
desktop/src/lib/skill-discovery.ts
Normal file
468
desktop/src/lib/skill-discovery.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Skill Discovery - Agent-driven skill search, recommendation, and management
|
||||
*
|
||||
* Enables ZCLAW agents to:
|
||||
* - Search available skills by keyword/capability
|
||||
* - Recommend skills based on recent conversation patterns
|
||||
* - Manage skill installation lifecycle (with user approval)
|
||||
*
|
||||
* Scans the local `skills/` directory for SKILL.md manifests and indexes them.
|
||||
*
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.5.2
|
||||
*/
|
||||
|
||||
import { getMemoryManager } from './agent-memory';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface SkillInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
triggers: string[];
|
||||
capabilities: string[];
|
||||
toolDeps: string[];
|
||||
installed: boolean;
|
||||
category?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface SkillSuggestion {
|
||||
skill: SkillInfo;
|
||||
reason: string;
|
||||
confidence: number; // 0-1
|
||||
matchedPatterns: string[];
|
||||
}
|
||||
|
||||
export interface SkillSearchResult {
|
||||
query: string;
|
||||
results: SkillInfo[];
|
||||
totalAvailable: number;
|
||||
}
|
||||
|
||||
export interface ConversationContext {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// === Storage ===
|
||||
|
||||
const SKILL_INDEX_KEY = 'zclaw-skill-index';
|
||||
const SKILL_SUGGESTIONS_KEY = 'zclaw-skill-suggestions';
|
||||
|
||||
// === Built-in Skill Registry ===
|
||||
|
||||
/**
|
||||
* Pre-indexed skills from the skills/ directory.
|
||||
* In production, this would be dynamically scanned from SKILL.md files.
|
||||
* For Phase 4, we maintain a static registry that can be refreshed.
|
||||
*/
|
||||
const BUILT_IN_SKILLS: SkillInfo[] = [
|
||||
{
|
||||
id: 'code-review',
|
||||
name: 'Code Review',
|
||||
description: '审查代码、分析代码质量、提供改进建议',
|
||||
triggers: ['审查代码', '代码审查', 'code review', 'PR review', '检查代码'],
|
||||
capabilities: ['代码质量分析', '架构评估', '安全审计', '最佳实践检查'],
|
||||
toolDeps: ['read', 'grep', 'glob'],
|
||||
installed: true,
|
||||
category: 'development',
|
||||
},
|
||||
{
|
||||
id: 'frontend-developer',
|
||||
name: 'Frontend Developer',
|
||||
description: '前端开发专家,擅长 React/Vue/CSS/TypeScript',
|
||||
triggers: ['前端开发', '页面开发', 'UI开发', 'React', 'Vue', 'CSS'],
|
||||
capabilities: ['组件开发', '样式调整', '性能优化', '响应式设计'],
|
||||
toolDeps: ['read', 'write', 'shell'],
|
||||
installed: true,
|
||||
category: 'development',
|
||||
},
|
||||
{
|
||||
id: 'backend-architect',
|
||||
name: 'Backend Architect',
|
||||
description: '后端架构设计、API设计、数据库建模',
|
||||
triggers: ['后端架构', 'API设计', '数据库设计', '系统架构', '微服务'],
|
||||
capabilities: ['架构设计', 'API规范', '数据库建模', '性能优化'],
|
||||
toolDeps: ['read', 'write', 'shell'],
|
||||
installed: true,
|
||||
category: 'development',
|
||||
},
|
||||
{
|
||||
id: 'security-engineer',
|
||||
name: 'Security Engineer',
|
||||
description: '安全工程师,负责安全审计、漏洞检测、合规检查',
|
||||
triggers: ['安全审计', '漏洞检测', '安全检查', 'security', '渗透测试'],
|
||||
capabilities: ['漏洞扫描', '合规检查', '安全加固', '威胁建模'],
|
||||
toolDeps: ['read', 'grep', 'shell'],
|
||||
installed: true,
|
||||
category: 'security',
|
||||
},
|
||||
{
|
||||
id: 'data-analysis',
|
||||
name: 'Data Analysis',
|
||||
description: '数据分析、可视化、报告生成',
|
||||
triggers: ['数据分析', '数据可视化', '报表', '统计', 'analytics'],
|
||||
capabilities: ['数据清洗', '统计分析', '可视化图表', '报告生成'],
|
||||
toolDeps: ['read', 'write', 'shell'],
|
||||
installed: true,
|
||||
category: 'analytics',
|
||||
},
|
||||
{
|
||||
id: 'chinese-writing',
|
||||
name: 'Chinese Writing',
|
||||
description: '中文写作、文案创作、内容优化',
|
||||
triggers: ['写文章', '文案', '写作', '中文创作', '内容优化'],
|
||||
capabilities: ['文案创作', '文章润色', '标题优化', 'SEO写作'],
|
||||
toolDeps: ['read', 'write'],
|
||||
installed: true,
|
||||
category: 'content',
|
||||
},
|
||||
{
|
||||
id: 'devops-automator',
|
||||
name: 'DevOps Automator',
|
||||
description: 'CI/CD、Docker、K8s、自动化部署',
|
||||
triggers: ['DevOps', 'CI/CD', 'Docker', '部署', '自动化', 'K8s'],
|
||||
capabilities: ['CI/CD配置', '容器化', '自动化部署', '监控告警'],
|
||||
toolDeps: ['shell', 'read', 'write'],
|
||||
installed: true,
|
||||
category: 'ops',
|
||||
},
|
||||
{
|
||||
id: 'senior-pm',
|
||||
name: 'Senior PM',
|
||||
description: '项目管理、需求分析、迭代规划',
|
||||
triggers: ['项目管理', '需求分析', '迭代规划', '产品设计', 'PRD'],
|
||||
capabilities: ['需求拆解', '迭代排期', '风险评估', '文档撰写'],
|
||||
toolDeps: ['read', 'write'],
|
||||
installed: true,
|
||||
category: 'management',
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
name: 'Git Operations',
|
||||
description: 'Git 版本控制操作、分支管理、冲突解决',
|
||||
triggers: ['git', '版本控制', '分支', '合并', 'commit', 'merge'],
|
||||
capabilities: ['分支管理', '冲突解决', 'rebase', 'cherry-pick'],
|
||||
toolDeps: ['shell'],
|
||||
installed: true,
|
||||
category: 'development',
|
||||
},
|
||||
{
|
||||
id: 'api-tester',
|
||||
name: 'API Tester',
|
||||
description: 'API 测试、接口调试、自动化测试脚本',
|
||||
triggers: ['API测试', '接口测试', '接口调试', 'Postman', 'curl'],
|
||||
capabilities: ['接口调试', '自动化测试', '性能测试', '断言验证'],
|
||||
toolDeps: ['shell', 'read', 'write'],
|
||||
installed: true,
|
||||
category: 'testing',
|
||||
},
|
||||
{
|
||||
id: 'finance-tracker',
|
||||
name: 'Finance Tracker',
|
||||
description: '财务追踪、预算管理、报表分析',
|
||||
triggers: ['财务', '预算', '记账', '报销', '财务报表'],
|
||||
capabilities: ['收支分析', '预算规划', '报表生成', '趋势预测'],
|
||||
toolDeps: ['read', 'write'],
|
||||
installed: true,
|
||||
category: 'business',
|
||||
},
|
||||
{
|
||||
id: 'social-media-strategist',
|
||||
name: 'Social Media Strategist',
|
||||
description: '社交媒体运营策略、内容规划、数据分析',
|
||||
triggers: ['社交媒体', '运营', '小红书', '抖音', '微博', '内容运营'],
|
||||
capabilities: ['内容策划', '发布排期', '数据分析', '竞品监控'],
|
||||
toolDeps: ['read', 'write'],
|
||||
installed: true,
|
||||
category: 'marketing',
|
||||
},
|
||||
];
|
||||
|
||||
// === Skill Discovery Engine ===
|
||||
|
||||
export class SkillDiscoveryEngine {
|
||||
private skills: SkillInfo[] = [];
|
||||
private suggestionHistory: SkillSuggestion[] = [];
|
||||
|
||||
constructor() {
|
||||
this.loadIndex();
|
||||
this.loadSuggestions();
|
||||
if (this.skills.length === 0) {
|
||||
this.skills = [...BUILT_IN_SKILLS];
|
||||
this.saveIndex();
|
||||
}
|
||||
}
|
||||
|
||||
// === Search ===
|
||||
|
||||
/**
|
||||
* Search skills by keyword. Matches against name, description, triggers, capabilities.
|
||||
*/
|
||||
searchSkills(query: string): SkillSearchResult {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) {
|
||||
return { query, results: [...this.skills], totalAvailable: this.skills.length };
|
||||
}
|
||||
|
||||
const tokens = q.split(/[\s,;.!?。,;!?]+/).filter(t => t.length > 0);
|
||||
|
||||
const scored = this.skills.map(skill => {
|
||||
let score = 0;
|
||||
|
||||
// Name match (highest weight)
|
||||
if (skill.name.toLowerCase().includes(q)) score += 10;
|
||||
|
||||
// Description match
|
||||
if (skill.description.toLowerCase().includes(q)) score += 5;
|
||||
|
||||
// Trigger match (exact or partial)
|
||||
for (const trigger of skill.triggers) {
|
||||
const tLower = trigger.toLowerCase();
|
||||
if (tLower === q) { score += 15; break; }
|
||||
if (tLower.includes(q) || q.includes(tLower)) score += 8;
|
||||
}
|
||||
|
||||
// Capability match
|
||||
for (const cap of skill.capabilities) {
|
||||
if (cap.toLowerCase().includes(q)) score += 4;
|
||||
}
|
||||
|
||||
// Token-level matching
|
||||
for (const token of tokens) {
|
||||
if (skill.name.toLowerCase().includes(token)) score += 2;
|
||||
if (skill.description.toLowerCase().includes(token)) score += 1;
|
||||
for (const trigger of skill.triggers) {
|
||||
if (trigger.toLowerCase().includes(token)) score += 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Category match
|
||||
if (skill.category && skill.category.toLowerCase().includes(q)) score += 3;
|
||||
|
||||
return { skill, score };
|
||||
});
|
||||
|
||||
const results = scored
|
||||
.filter(s => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(s => s.skill);
|
||||
|
||||
return { query, results, totalAvailable: this.skills.length };
|
||||
}
|
||||
|
||||
// === Recommendation ===
|
||||
|
||||
/**
|
||||
* Suggest skills based on recent conversation content and memory patterns.
|
||||
*/
|
||||
async suggestSkills(
|
||||
recentConversations: ConversationContext[],
|
||||
agentId: string,
|
||||
limit: number = 5
|
||||
): Promise<SkillSuggestion[]> {
|
||||
const suggestions: SkillSuggestion[] = [];
|
||||
|
||||
// 1. Extract key topics from conversations
|
||||
const topics = this.extractTopics(recentConversations);
|
||||
|
||||
// 2. Match topics against skill triggers and capabilities
|
||||
for (const skill of this.skills) {
|
||||
const matchedPatterns: string[] = [];
|
||||
let confidence = 0;
|
||||
|
||||
for (const topic of topics) {
|
||||
const topicLower = topic.toLowerCase();
|
||||
|
||||
// Check triggers
|
||||
for (const trigger of skill.triggers) {
|
||||
if (trigger.toLowerCase().includes(topicLower) || topicLower.includes(trigger.toLowerCase())) {
|
||||
matchedPatterns.push(`话题"${topic}"匹配触发词"${trigger}"`);
|
||||
confidence += 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
// Check capabilities
|
||||
for (const cap of skill.capabilities) {
|
||||
if (cap.toLowerCase().includes(topicLower)) {
|
||||
matchedPatterns.push(`话题"${topic}"匹配能力"${cap}"`);
|
||||
confidence += 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check memory patterns for recurring needs
|
||||
try {
|
||||
const memories = await getMemoryManager().search(skill.name, {
|
||||
agentId,
|
||||
limit: 5,
|
||||
minImportance: 3,
|
||||
});
|
||||
if (memories.length > 0) {
|
||||
matchedPatterns.push(`记忆中有${memories.length}条相关记录`);
|
||||
confidence += memories.length * 0.1;
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
if (matchedPatterns.length > 0 && confidence > 0) {
|
||||
suggestions.push({
|
||||
skill,
|
||||
reason: matchedPatterns.slice(0, 3).join(';'),
|
||||
confidence: Math.min(confidence, 1),
|
||||
matchedPatterns,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by confidence
|
||||
const sorted = suggestions
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, limit);
|
||||
|
||||
// Cache suggestions
|
||||
this.suggestionHistory = sorted;
|
||||
this.saveSuggestions();
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
// === Skill Management ===
|
||||
|
||||
/**
|
||||
* Get all available skills.
|
||||
*/
|
||||
getAllSkills(): SkillInfo[] {
|
||||
return [...this.skills];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skills by category.
|
||||
*/
|
||||
getSkillsByCategory(category: string): SkillInfo[] {
|
||||
return this.skills.filter(s => s.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique categories.
|
||||
*/
|
||||
getCategories(): string[] {
|
||||
return [...new Set(this.skills.map(s => s.category).filter(Boolean))] as string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new skill (e.g., from a SKILL.md file scan).
|
||||
*/
|
||||
registerSkill(skill: SkillInfo): void {
|
||||
const existing = this.skills.findIndex(s => s.id === skill.id);
|
||||
if (existing >= 0) {
|
||||
this.skills[existing] = skill;
|
||||
} else {
|
||||
this.skills.push(skill);
|
||||
}
|
||||
this.saveIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a skill as installed/uninstalled.
|
||||
*/
|
||||
setSkillInstalled(skillId: string, installed: boolean): void {
|
||||
const skill = this.skills.find(s => s.id === skillId);
|
||||
if (skill) {
|
||||
skill.installed = installed;
|
||||
this.saveIndex();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last cached suggestions.
|
||||
*/
|
||||
getLastSuggestions(): SkillSuggestion[] {
|
||||
return [...this.suggestionHistory];
|
||||
}
|
||||
|
||||
// === Topic Extraction ===
|
||||
|
||||
private extractTopics(conversations: ConversationContext[]): string[] {
|
||||
const topics = new Set<string>();
|
||||
|
||||
const patterns = [
|
||||
// Chinese task patterns
|
||||
/帮我(.{2,15})/g,
|
||||
/我想(.{2,15})/g,
|
||||
/如何(.{2,15})/g,
|
||||
/怎么(.{2,15})/g,
|
||||
/需要(.{2,15})/g,
|
||||
/分析(.{2,15})/g,
|
||||
/写一个(.{2,15})/g,
|
||||
/做一个(.{2,15})/g,
|
||||
// English task patterns
|
||||
/(?:help me |I need to |how to |please )(.{3,30})/gi,
|
||||
// Technical terms
|
||||
/(?:React|Vue|Python|Docker|K8s|API|SQL|CSS|TypeScript|Node|Git)/gi,
|
||||
// Domain keywords
|
||||
/(?:部署|测试|审查|优化|设计|开发|分析|报告|运营|安全)/g,
|
||||
];
|
||||
|
||||
for (const msg of conversations.filter(m => m.role === 'user')) {
|
||||
for (const pattern of patterns) {
|
||||
const matches = msg.content.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
const topic = (match[1] || match[0]).trim();
|
||||
if (topic.length >= 2 && topic.length <= 30) {
|
||||
topics.add(topic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...topics].slice(0, 20);
|
||||
}
|
||||
|
||||
// === Persistence ===
|
||||
|
||||
private loadIndex(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(SKILL_INDEX_KEY);
|
||||
if (raw) this.skills = JSON.parse(raw);
|
||||
} catch {
|
||||
this.skills = [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveIndex(): void {
|
||||
try {
|
||||
localStorage.setItem(SKILL_INDEX_KEY, JSON.stringify(this.skills));
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
private loadSuggestions(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(SKILL_SUGGESTIONS_KEY);
|
||||
if (raw) this.suggestionHistory = JSON.parse(raw);
|
||||
} catch {
|
||||
this.suggestionHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveSuggestions(): void {
|
||||
try {
|
||||
localStorage.setItem(SKILL_SUGGESTIONS_KEY, JSON.stringify(this.suggestionHistory));
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: SkillDiscoveryEngine | null = null;
|
||||
|
||||
export function getSkillDiscovery(): SkillDiscoveryEngine {
|
||||
if (!_instance) {
|
||||
_instance = new SkillDiscoveryEngine();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetSkillDiscovery(): void {
|
||||
_instance = null;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { getAgentIdentityManager } from '../lib/agent-identity';
|
||||
import { getMemoryExtractor } from '../lib/memory-extractor';
|
||||
import { getContextCompactor } from '../lib/context-compactor';
|
||||
import { getReflectionEngine } from '../lib/reflection-engine';
|
||||
import { getAgentSwarm } from '../lib/agent-swarm';
|
||||
import { getSkillDiscovery } from '../lib/skill-discovery';
|
||||
|
||||
export interface MessageFile {
|
||||
name: string;
|
||||
@@ -91,6 +93,8 @@ interface ChatState {
|
||||
newConversation: () => void;
|
||||
switchConversation: (id: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
dispatchSwarmTask: (description: string, style?: 'sequential' | 'parallel' | 'debate') => Promise<string | null>;
|
||||
searchSkills: (query: string) => { results: Array<{ id: string; name: string; description: string }>; totalAvailable: number };
|
||||
}
|
||||
|
||||
function generateConvId(): string {
|
||||
@@ -489,6 +493,48 @@ export const useChatStore = create<ChatState>()(
|
||||
}
|
||||
},
|
||||
|
||||
dispatchSwarmTask: async (description: string, style?: 'sequential' | 'parallel' | 'debate') => {
|
||||
try {
|
||||
const swarm = getAgentSwarm();
|
||||
const task = swarm.createTask(description, {
|
||||
communicationStyle: style || 'parallel',
|
||||
});
|
||||
|
||||
// Set up executor that uses gateway client
|
||||
swarm.setExecutor(async (agentId: string, prompt: string, context?: string) => {
|
||||
const client = getGatewayClient();
|
||||
const fullPrompt = context ? `${context}\n\n${prompt}` : prompt;
|
||||
const result = await client.chat(fullPrompt, { agentId: agentId.startsWith('clone_') ? undefined : agentId });
|
||||
return result?.response || '(无响应)';
|
||||
});
|
||||
|
||||
const result = await swarm.execute(task);
|
||||
|
||||
// Add swarm result as assistant message
|
||||
const swarmMsg: Message = {
|
||||
id: `swarm_${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: result.summary || '协作任务完成',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
get().addMessage(swarmMsg);
|
||||
|
||||
return result.task.id;
|
||||
} catch (err) {
|
||||
console.warn('[Chat] Swarm dispatch failed:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
searchSkills: (query: string) => {
|
||||
const discovery = getSkillDiscovery();
|
||||
const result = discovery.searchSkills(query);
|
||||
return {
|
||||
results: result.results.map(s => ({ id: s.id, name: s.name, description: s.description })),
|
||||
totalAvailable: result.totalAvailable,
|
||||
};
|
||||
},
|
||||
|
||||
initStreamListener: () => {
|
||||
const client = getGatewayClient();
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
| **Phase 1: 持久记忆 + 身份演化** | ✅ 已完成 | `agent-memory.ts`, `agent-identity.ts`, `memory-extractor.ts`, `MemoryPanel.tsx` | 42 tests |
|
||||
| **Phase 2: 上下文压缩** | ✅ 已完成 | `context-compactor.ts` + chatStore 集成 | 23 tests |
|
||||
| **Phase 3: 主动智能 + 自我反思** | ✅ 已完成 | `heartbeat-engine.ts`, `reflection-engine.ts` | 28 tests |
|
||||
| **Phase 4: 多 Agent 协作** | 📋 规划中 | — | — |
|
||||
| **全量测试** | ✅ 274 passing | 12 test files | — |
|
||||
| **Phase 4: 多 Agent 协作 + 技能生态** | ✅ 已完成 | `agent-swarm.ts`, `skill-discovery.ts` + chatStore 集成 | 43 tests |
|
||||
| **全量测试** | ✅ 317 passing | 13 test files | — |
|
||||
|
||||
---
|
||||
|
||||
@@ -913,7 +913,7 @@ interface ReflectionResult {
|
||||
|
||||
---
|
||||
|
||||
### 6.5 Phase 4:多 Agent 协作 + 技能生态(第 11-16 周)
|
||||
### 6.5 Phase 4:多 Agent 协作 + 技能生态(第 11-16 周) — ✅ 已完成
|
||||
|
||||
**目标**:构建 Agent 协作框架和技能自主发现能力
|
||||
|
||||
@@ -971,10 +971,12 @@ interface SkillDiscovery {
|
||||
|
||||
#### 6.5.3 Phase 4 交付物
|
||||
|
||||
- `src/lib/agent-swarm.ts` — 多 Agent 协作引擎
|
||||
- `src/lib/skill-discovery.ts` — 技能发现与推荐
|
||||
- `src/components/SwarmDashboard.tsx` — 协作任务面板
|
||||
- `src/components/SkillMarket.tsx` — 技能市场 UI
|
||||
- ✅ `desktop/src/lib/agent-swarm.ts` — 多 Agent 协作引擎(Sequential/Parallel/Debate 三种模式)
|
||||
- ✅ `desktop/src/lib/skill-discovery.ts` — 技能发现与推荐(12 个内置技能,关键词搜索 + 对话模式推荐)
|
||||
- ✅ `desktop/src/store/chatStore.ts` — `dispatchSwarmTask` + `searchSkills` 集成
|
||||
- ✅ `tests/desktop/swarm-skills.test.ts` — 43 项单元测试
|
||||
- 📋 `src/components/SwarmDashboard.tsx` — 协作任务面板(UI 待实现)
|
||||
- 📋 `src/components/SkillMarket.tsx` — 技能市场 UI(UI 待实现)
|
||||
|
||||
---
|
||||
|
||||
|
||||
483
tests/desktop/swarm-skills.test.ts
Normal file
483
tests/desktop/swarm-skills.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Tests for Phase 4: Agent Swarm + Skill Discovery
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
AgentSwarm,
|
||||
resetAgentSwarm,
|
||||
getAgentSwarm,
|
||||
AgentExecutor,
|
||||
} from '../../desktop/src/lib/agent-swarm';
|
||||
import {
|
||||
SkillDiscoveryEngine,
|
||||
resetSkillDiscovery,
|
||||
getSkillDiscovery,
|
||||
} from '../../desktop/src/lib/skill-discovery';
|
||||
import { MemoryManager, resetMemoryManager } from '../../desktop/src/lib/agent-memory';
|
||||
|
||||
// === localStorage mock ===
|
||||
const store: Record<string, string> = {};
|
||||
const localStorageMock = {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value; },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { for (const k of Object.keys(store)) delete store[k]; },
|
||||
get length() { return Object.keys(store).length; },
|
||||
key: (i: number) => Object.keys(store)[i] ?? null,
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
|
||||
|
||||
// === Agent Swarm Tests ===
|
||||
|
||||
describe('AgentSwarm', () => {
|
||||
let swarm: AgentSwarm;
|
||||
let mockExecutor: ReturnType<typeof vi.fn<AgentExecutor>>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
resetAgentSwarm();
|
||||
resetMemoryManager();
|
||||
mockExecutor = vi.fn<AgentExecutor>(
|
||||
async (agentId: string, prompt: string, _context?: string) => `[${agentId}] 完成: ${prompt.slice(0, 30)}`
|
||||
);
|
||||
swarm = new AgentSwarm({
|
||||
coordinator: 'main-agent',
|
||||
specialists: [
|
||||
{ agentId: 'dev-agent', role: '开发工程师', capabilities: ['coding', 'review'] },
|
||||
{ agentId: 'pm-agent', role: '产品经理', capabilities: ['planning', 'requirements'] },
|
||||
{ agentId: 'qa-agent', role: '测试工程师', capabilities: ['testing', 'qa'] },
|
||||
],
|
||||
});
|
||||
swarm.setExecutor(mockExecutor as unknown as AgentExecutor);
|
||||
});
|
||||
|
||||
describe('createTask', () => {
|
||||
it('creates task with auto decomposition', () => {
|
||||
const task = swarm.createTask('实现用户登录功能');
|
||||
expect(task.id).toMatch(/^swarm_/);
|
||||
expect(task.description).toBe('实现用户登录功能');
|
||||
expect(task.status).toBe('planning');
|
||||
expect(task.subtasks.length).toBe(3); // one per specialist
|
||||
});
|
||||
|
||||
it('creates task with manual subtasks', () => {
|
||||
const task = swarm.createTask('发布新版本', {
|
||||
subtasks: [
|
||||
{ assignedTo: 'dev-agent', description: '打包构建' },
|
||||
{ assignedTo: 'qa-agent', description: '回归测试' },
|
||||
],
|
||||
});
|
||||
expect(task.subtasks.length).toBe(2);
|
||||
expect(task.subtasks[0].assignedTo).toBe('dev-agent');
|
||||
expect(task.subtasks[1].assignedTo).toBe('qa-agent');
|
||||
});
|
||||
|
||||
it('creates task with custom communication style', () => {
|
||||
const task = swarm.createTask('讨论技术选型', {
|
||||
communicationStyle: 'debate',
|
||||
});
|
||||
expect(task.communicationStyle).toBe('debate');
|
||||
});
|
||||
|
||||
it('falls back to coordinator when no specialists', () => {
|
||||
const emptySwarm = new AgentSwarm({ coordinator: 'solo' });
|
||||
emptySwarm.setExecutor(mockExecutor as unknown as AgentExecutor);
|
||||
const task = emptySwarm.createTask('单人任务');
|
||||
expect(task.subtasks.length).toBe(1);
|
||||
expect(task.subtasks[0].assignedTo).toBe('solo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - sequential', () => {
|
||||
it('executes subtasks in order with context chaining', async () => {
|
||||
const task = swarm.createTask('设计并实现API', {
|
||||
communicationStyle: 'sequential',
|
||||
subtasks: [
|
||||
{ assignedTo: 'pm-agent', description: '需求分析' },
|
||||
{ assignedTo: 'dev-agent', description: '编码实现' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await swarm.execute(task);
|
||||
expect(result.task.status).toBe('done');
|
||||
expect(mockExecutor).toHaveBeenCalledTimes(2); // 2 subtasks
|
||||
// First call has empty context
|
||||
expect(mockExecutor.mock.calls[0][2]).toBe('');
|
||||
// Second call has previous result as context
|
||||
expect(mockExecutor.mock.calls[1][2]).toContain('前一个Agent的输出');
|
||||
});
|
||||
|
||||
it('handles subtask failure gracefully', async () => {
|
||||
mockExecutor.mockImplementationOnce(async () => { throw new Error('Agent offline'); });
|
||||
|
||||
const task = swarm.createTask('有风险的任务', {
|
||||
subtasks: [
|
||||
{ assignedTo: 'dev-agent', description: '可能失败的任务' },
|
||||
{ assignedTo: 'qa-agent', description: '后续任务' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await swarm.execute(task);
|
||||
expect(result.task.subtasks[0].status).toBe('failed');
|
||||
expect(result.task.subtasks[0].error).toBe('Agent offline');
|
||||
expect(result.task.status).toBe('done'); // task still completes
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - parallel', () => {
|
||||
it('executes all subtasks simultaneously', async () => {
|
||||
const task = swarm.createTask('全方位分析', {
|
||||
communicationStyle: 'parallel',
|
||||
});
|
||||
|
||||
const result = await swarm.execute(task);
|
||||
expect(result.task.status).toBe('done');
|
||||
expect(result.participantCount).toBe(3);
|
||||
// All subtasks should be done
|
||||
const doneCount = result.task.subtasks.filter(s => s.status === 'done').length;
|
||||
expect(doneCount).toBe(3);
|
||||
});
|
||||
|
||||
it('continues even if some parallel subtasks fail', async () => {
|
||||
mockExecutor
|
||||
.mockImplementationOnce(async () => 'success-1')
|
||||
.mockImplementationOnce(async () => { throw new Error('fail'); })
|
||||
.mockImplementationOnce(async () => 'success-2');
|
||||
|
||||
const task = swarm.createTask('混合结果', { communicationStyle: 'parallel' });
|
||||
const result = await swarm.execute(task);
|
||||
|
||||
const done = result.task.subtasks.filter(s => s.status === 'done');
|
||||
const failed = result.task.subtasks.filter(s => s.status === 'failed');
|
||||
expect(done.length).toBe(2);
|
||||
expect(failed.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - debate', () => {
|
||||
it('runs multiple rounds of debate', async () => {
|
||||
const task = swarm.createTask('选择数据库: PostgreSQL vs MongoDB', {
|
||||
communicationStyle: 'debate',
|
||||
});
|
||||
|
||||
const result = await swarm.execute(task);
|
||||
expect(result.task.status).toBe('done');
|
||||
// Should have subtasks from multiple rounds
|
||||
expect(result.task.subtasks.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('stops early on consensus', async () => {
|
||||
// Make all agents return identical responses to trigger consensus
|
||||
mockExecutor.mockImplementation(async () => '我建议使用 PostgreSQL 因为它支持 JSONB 和强一致性');
|
||||
|
||||
const task = swarm.createTask('数据库选型', { communicationStyle: 'debate' });
|
||||
const result = await swarm.execute(task);
|
||||
|
||||
expect(result.task.status).toBe('done');
|
||||
// Should stop before max rounds due to consensus
|
||||
});
|
||||
});
|
||||
|
||||
describe('history', () => {
|
||||
it('stores executed tasks in history', async () => {
|
||||
const task1 = swarm.createTask('任务1', {
|
||||
subtasks: [{ assignedTo: 'dev-agent', description: '小任务' }],
|
||||
});
|
||||
await swarm.execute(task1);
|
||||
|
||||
const history = swarm.getHistory();
|
||||
expect(history.length).toBe(1);
|
||||
expect(history[0].description).toBe('任务1');
|
||||
});
|
||||
|
||||
it('retrieves task by ID', async () => {
|
||||
const task = swarm.createTask('查找任务', {
|
||||
subtasks: [{ assignedTo: 'dev-agent', description: '测试' }],
|
||||
});
|
||||
await swarm.execute(task);
|
||||
|
||||
const found = swarm.getTask(task.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.description).toBe('查找任务');
|
||||
});
|
||||
|
||||
it('persists history to localStorage', async () => {
|
||||
const task = swarm.createTask('持久化测试', {
|
||||
subtasks: [{ assignedTo: 'dev-agent', description: '测试' }],
|
||||
});
|
||||
await swarm.execute(task);
|
||||
|
||||
// Create new instance — should load from localStorage
|
||||
const swarm2 = new AgentSwarm();
|
||||
const history = swarm2.getHistory();
|
||||
expect(history.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('specialist management', () => {
|
||||
it('lists specialists', () => {
|
||||
expect(swarm.getSpecialists().length).toBe(3);
|
||||
});
|
||||
|
||||
it('adds a specialist', () => {
|
||||
swarm.addSpecialist({ agentId: 'design-agent', role: '设计师', capabilities: ['UI', 'UX'] });
|
||||
expect(swarm.getSpecialists().length).toBe(4);
|
||||
});
|
||||
|
||||
it('updates existing specialist', () => {
|
||||
swarm.addSpecialist({ agentId: 'dev-agent', role: '高级开发', capabilities: ['coding', 'architecture'] });
|
||||
const specs = swarm.getSpecialists();
|
||||
expect(specs.length).toBe(3);
|
||||
expect(specs.find(s => s.agentId === 'dev-agent')!.role).toBe('高级开发');
|
||||
});
|
||||
|
||||
it('removes a specialist', () => {
|
||||
swarm.removeSpecialist('qa-agent');
|
||||
expect(swarm.getSpecialists().length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config', () => {
|
||||
it('returns current config', () => {
|
||||
const config = swarm.getConfig();
|
||||
expect(config.coordinator).toBe('main-agent');
|
||||
expect(config.specialists.length).toBe(3);
|
||||
});
|
||||
|
||||
it('updates config', () => {
|
||||
swarm.updateConfig({ maxRoundsDebate: 5 });
|
||||
expect(swarm.getConfig().maxRoundsDebate).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('singleton', () => {
|
||||
it('returns same instance', () => {
|
||||
const a = getAgentSwarm();
|
||||
const b = getAgentSwarm();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('resets singleton', () => {
|
||||
const a = getAgentSwarm();
|
||||
resetAgentSwarm();
|
||||
const b = getAgentSwarm();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws if no executor set', async () => {
|
||||
const noExecSwarm = new AgentSwarm();
|
||||
const task = noExecSwarm.createTask('无执行器');
|
||||
await expect(noExecSwarm.execute(task)).rejects.toThrow('No executor');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// === Skill Discovery Tests ===
|
||||
|
||||
describe('SkillDiscoveryEngine', () => {
|
||||
let engine: SkillDiscoveryEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
resetSkillDiscovery();
|
||||
resetMemoryManager();
|
||||
engine = new SkillDiscoveryEngine();
|
||||
});
|
||||
|
||||
describe('searchSkills', () => {
|
||||
it('returns all skills for empty query', () => {
|
||||
const result = engine.searchSkills('');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.totalAvailable).toBe(result.results.length);
|
||||
});
|
||||
|
||||
it('finds skills by name', () => {
|
||||
const result = engine.searchSkills('Code Review');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBe('code-review');
|
||||
});
|
||||
|
||||
it('finds skills by Chinese trigger', () => {
|
||||
const result = engine.searchSkills('审查代码');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBe('code-review');
|
||||
});
|
||||
|
||||
it('finds skills by capability', () => {
|
||||
const result = engine.searchSkills('安全审计');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
const ids = result.results.map(s => s.id);
|
||||
expect(ids).toContain('security-engineer');
|
||||
});
|
||||
|
||||
it('finds skills by category keyword', () => {
|
||||
const result = engine.searchSkills('development');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns empty for non-matching query', () => {
|
||||
const result = engine.searchSkills('量子计算');
|
||||
expect(result.results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('ranks exact trigger match higher', () => {
|
||||
const result = engine.searchSkills('git');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBe('git');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestSkills', () => {
|
||||
it('suggests skills based on conversation content', async () => {
|
||||
const conversations = [
|
||||
{ role: 'user', content: '帮我审查一下这段代码的安全性' },
|
||||
{ role: 'assistant', content: '好的,我来检查...' },
|
||||
{ role: 'user', content: '还需要做一下API测试' },
|
||||
];
|
||||
|
||||
const suggestions = await engine.suggestSkills(conversations, 'agent-1');
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
// Should suggest security or code review related skills
|
||||
const ids = suggestions.map(s => s.skill.id);
|
||||
expect(ids.some(id => ['code-review', 'security-engineer', 'api-tester'].includes(id))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty for unrelated conversations', async () => {
|
||||
const conversations = [
|
||||
{ role: 'user', content: '今天天气真好' },
|
||||
{ role: 'assistant', content: '是的' },
|
||||
];
|
||||
|
||||
const suggestions = await engine.suggestSkills(conversations, 'agent-1');
|
||||
// May or may not have suggestions, but shouldn't crash
|
||||
expect(Array.isArray(suggestions)).toBe(true);
|
||||
});
|
||||
|
||||
it('limits results to specified count', async () => {
|
||||
const conversations = [
|
||||
{ role: 'user', content: '帮我做代码审查、数据分析、API测试、安全检查、前端开发、写文章' },
|
||||
];
|
||||
|
||||
const suggestions = await engine.suggestSkills(conversations, 'agent-1', 3);
|
||||
expect(suggestions.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('includes confidence score and reason', async () => {
|
||||
const conversations = [
|
||||
{ role: 'user', content: '帮我审查代码' },
|
||||
];
|
||||
|
||||
const suggestions = await engine.suggestSkills(conversations, 'agent-1');
|
||||
if (suggestions.length > 0) {
|
||||
expect(suggestions[0].confidence).toBeGreaterThan(0);
|
||||
expect(suggestions[0].confidence).toBeLessThanOrEqual(1);
|
||||
expect(suggestions[0].reason.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].matchedPatterns.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill management', () => {
|
||||
it('gets all skills', () => {
|
||||
const skills = engine.getAllSkills();
|
||||
expect(skills.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('filters by category', () => {
|
||||
const devSkills = engine.getSkillsByCategory('development');
|
||||
expect(devSkills.length).toBeGreaterThan(0);
|
||||
expect(devSkills.every(s => s.category === 'development')).toBe(true);
|
||||
});
|
||||
|
||||
it('lists categories', () => {
|
||||
const categories = engine.getCategories();
|
||||
expect(categories.length).toBeGreaterThan(0);
|
||||
expect(categories).toContain('development');
|
||||
});
|
||||
|
||||
it('registers a new skill', () => {
|
||||
const countBefore = engine.getAllSkills().length;
|
||||
engine.registerSkill({
|
||||
id: 'custom-skill',
|
||||
name: 'Custom Skill',
|
||||
description: 'A custom skill',
|
||||
triggers: ['custom'],
|
||||
capabilities: ['custom-work'],
|
||||
toolDeps: [],
|
||||
installed: false,
|
||||
category: 'custom',
|
||||
});
|
||||
expect(engine.getAllSkills().length).toBe(countBefore + 1);
|
||||
});
|
||||
|
||||
it('updates existing skill on re-register', () => {
|
||||
engine.registerSkill({
|
||||
id: 'code-review',
|
||||
name: 'Code Review Pro',
|
||||
description: 'Enhanced code review',
|
||||
triggers: ['审查代码'],
|
||||
capabilities: ['深度分析'],
|
||||
toolDeps: ['read'],
|
||||
installed: true,
|
||||
category: 'development',
|
||||
});
|
||||
const skill = engine.getAllSkills().find(s => s.id === 'code-review');
|
||||
expect(skill!.name).toBe('Code Review Pro');
|
||||
});
|
||||
|
||||
it('toggles install status', () => {
|
||||
engine.setSkillInstalled('code-review', false);
|
||||
const skill = engine.getAllSkills().find(s => s.id === 'code-review');
|
||||
expect(skill!.installed).toBe(false);
|
||||
|
||||
engine.setSkillInstalled('code-review', true);
|
||||
const skill2 = engine.getAllSkills().find(s => s.id === 'code-review');
|
||||
expect(skill2!.installed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('persists skills to localStorage', () => {
|
||||
engine.registerSkill({
|
||||
id: 'persist-test',
|
||||
name: 'Persist Test',
|
||||
description: 'test',
|
||||
triggers: [],
|
||||
capabilities: [],
|
||||
toolDeps: [],
|
||||
installed: false,
|
||||
});
|
||||
|
||||
const engine2 = new SkillDiscoveryEngine();
|
||||
const skill = engine2.getAllSkills().find(s => s.id === 'persist-test');
|
||||
expect(skill).toBeDefined();
|
||||
});
|
||||
|
||||
it('caches suggestions', async () => {
|
||||
const conversations = [
|
||||
{ role: 'user', content: '帮我审查代码' },
|
||||
];
|
||||
await engine.suggestSkills(conversations, 'agent-1');
|
||||
|
||||
const cached = engine.getLastSuggestions();
|
||||
expect(Array.isArray(cached)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('singleton', () => {
|
||||
it('returns same instance', () => {
|
||||
const a = getSkillDiscovery();
|
||||
const b = getSkillDiscovery();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('resets singleton', () => {
|
||||
const a = getSkillDiscovery();
|
||||
resetSkillDiscovery();
|
||||
const b = getSkillDiscovery();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user