From 5095bc0dff6105dbc2c94ee8768656aea6e51ec7 Mon Sep 17 00:00:00 2001 From: Artur Do Lago Date: Sun, 11 Jan 2026 18:07:42 +0100 Subject: [PATCH] fix(interfaces): resolve 8 critical bugs across web/tiara/persona layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 Critical Fixes: - Fix import ./tiara → ./claude-flow in plugin/builtin/index.ts - Create AgentOrchestrator type bridge for council/tiara integration (src/tiara.ts) - Fix hardcoded state ID in memory-bridge.ts (was causing cross-persona data loss) P1 Core Functionality: - Add persona isolation to memory bridge namespace (personas now have private memory) - Load SKILL.md content into drone prompts (skills now available to drones) - Inject persona-specific tools from AGENT_CONFIGS into drone prompts P2 API Consistency: - Standardize error response format in server.ts (MCP & gateway endpoints) - WebSocket param validation already in place (verified) Co-Authored-By: Claude Opus 4.5 --- packages/agent-core/src/server/server.ts | 12 +- src/personas/continuity.ts | 4 +- src/personas/memory-bridge.ts | 88 +++++++- src/personas/persona.ts | 263 +++++++++++++++++++++++ src/personas/types.ts | 19 +- src/plugin/builtin/index.ts | 4 +- src/tiara.ts | 129 +++++++++++ 7 files changed, 495 insertions(+), 24 deletions(-) create mode 100644 src/tiara.ts diff --git a/packages/agent-core/src/server/server.ts b/packages/agent-core/src/server/server.ts index 7b776f7221..3fc8a28aba 100644 --- a/packages/agent-core/src/server/server.ts +++ b/packages/agent-core/src/server/server.ts @@ -240,10 +240,10 @@ export namespace Server { async (c) => { const gateway = WhatsAppGateway.getInstance() if (!gateway) { - return c.json({ success: false, error: "WhatsApp gateway not running" }, 400) + return c.json({ data: null, errors: [{ message: "WhatsApp gateway not running" }], success: false }, 400) } if (!gateway.isReady()) { - return c.json({ success: false, error: "WhatsApp not connected" }, 400) + return c.json({ data: null, errors: [{ message: "WhatsApp not connected" }], success: false }, 400) } const { phone, message } = c.req.valid("json") const chatId = `${phone}@c.us` @@ -272,10 +272,10 @@ export namespace Server { async (c) => { const gateway = WhatsAppGateway.getInstance() if (!gateway) { - return c.json({ success: false, error: "WhatsApp gateway not running" }, 400) + return c.json({ data: null, errors: [{ message: "WhatsApp gateway not running" }], success: false }, 400) } if (!gateway.isReady()) { - return c.json({ success: false, error: "WhatsApp not connected" }, 400) + return c.json({ data: null, errors: [{ message: "WhatsApp not connected" }], success: false }, 400) } const { chatJid, messageId, emoji } = c.req.valid("json") const success = await gateway.sendReaction(chatJid, messageId, emoji) @@ -3197,7 +3197,7 @@ export namespace Server { const name = c.req.param("name") const supportsOAuth = await MCP.supportsOAuth(name) if (!supportsOAuth) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + return c.json({ data: null, errors: [{ message: `MCP server ${name} does not support OAuth` }], success: false }, 400) } const result = await MCP.startAuth(name) return c.json(result) @@ -3257,7 +3257,7 @@ export namespace Server { const name = c.req.param("name") const supportsOAuth = await MCP.supportsOAuth(name) if (!supportsOAuth) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + return c.json({ data: null, errors: [{ message: `MCP server ${name} does not support OAuth` }], success: false }, 400) } const status = await MCP.authenticate(name) return c.json(status) diff --git a/src/personas/continuity.ts b/src/personas/continuity.ts index 8ca0a838e1..9a2a09bb7b 100644 --- a/src/personas/continuity.ts +++ b/src/personas/continuity.ts @@ -318,9 +318,9 @@ export class ContinuityManager { // Save to memory await this.memoryBridge.saveConversationState(this.currentState); - // Store individual facts as memories for semantic search + // Store individual facts as memories for semantic search (persona-isolated) if (newFacts.length > 0) { - await this.memoryBridge.storeKeyFacts(newFacts, this.currentState.sessionId); + await this.memoryBridge.storeKeyFacts(newFacts, this.currentState.sessionId, this.currentState.leadPersona); } return this.currentState; diff --git a/src/personas/memory-bridge.ts b/src/personas/memory-bridge.ts index e9240c396f..0ced0acd0a 100644 --- a/src/personas/memory-bridge.ts +++ b/src/personas/memory-bridge.ts @@ -68,9 +68,13 @@ export class QdrantMemoryBridge implements MemoryBridge { private embedder: EmbeddingProvider; private config: PersonasConfig["qdrant"]; private initialized = false; + private instanceId: string; - constructor(config: PersonasConfig["qdrant"], options?: { useMockEmbeddings?: boolean }) { + constructor(config: PersonasConfig["qdrant"], options?: { useMockEmbeddings?: boolean; instanceId?: string }) { this.config = config; + // Generate a stable instance ID based on machine identity or use provided one + // This prevents state collision when multiple orchestrators run + this.instanceId = options?.instanceId ?? this.generateInstanceId(); // Initialize embedding provider // Use mock if no API key or explicitly requested @@ -116,6 +120,24 @@ export class QdrantMemoryBridge implements MemoryBridge { this.initialized = true; } + /** + * Generate a stable instance ID for this machine/user + */ + private generateInstanceId(): string { + // Use hostname + username for a stable per-machine ID + const os = require("os"); + const hostname = os.hostname() || "unknown"; + const username = os.userInfo().username || "user"; + return stringToUUID(`personas-${hostname}-${username}`); + } + + /** + * Get the state ID for this instance + */ + private getStateId(): string { + return stringToUUID(`state-${this.instanceId}`); + } + /** * Save Personas state to Qdrant */ @@ -123,8 +145,8 @@ export class QdrantMemoryBridge implements MemoryBridge { await this.init(); const stateJson = JSON.stringify(state); - // Use a fixed UUID for the current state (deterministic) - const stateId = "00000000-0000-0000-0000-000000000001"; + // Use instance-specific UUID for state (prevents multi-instance collision) + const stateId = this.getStateId(); // Generate embedding for the state summary const stateSummary = this.generateStateSummary(state); @@ -157,7 +179,7 @@ export class QdrantMemoryBridge implements MemoryBridge { async loadState(): Promise { await this.init(); - const stateId = "00000000-0000-0000-0000-000000000001"; + const stateId = this.getStateId(); const results = await this.storage.get([stateId]); const result = results[0]; @@ -295,6 +317,7 @@ export class QdrantMemoryBridge implements MemoryBridge { /** * Store a memory entry for semantic retrieval + * Memories are isolated by persona namespace for privacy */ async storeMemory( content: string, @@ -302,12 +325,21 @@ export class QdrantMemoryBridge implements MemoryBridge { ): Promise { await this.init(); + // Require persona for proper isolation + const persona = metadata.persona as string; + if (!persona) { + throw new Error("Memory storage requires persona in metadata for isolation"); + } + + // Use persona-specific namespace for isolation + const namespace = `personas:${persona}`; + const memory = await this.memoryStore.save({ content, category: "context", source: "agent", - senderId: (metadata.persona as string) ?? "personas", - namespace: "personas", + senderId: persona, + namespace, metadata, }); @@ -316,16 +348,24 @@ export class QdrantMemoryBridge implements MemoryBridge { /** * Search memories by semantic similarity + * Can search within a specific persona's namespace or across all personas */ async searchMemories( query: string, - limit = 10 + limit = 10, + options?: { persona?: string; includeShared?: boolean } ): Promise> { await this.init(); + // If persona specified, search only that persona's namespace + // Otherwise search the shared namespace + const namespace = options?.persona + ? `personas:${options.persona}` + : "personas:shared"; + const results = await this.memoryStore.search(query, { limit, - namespace: "personas", + namespace, }); return results.map((r) => ({ @@ -335,6 +375,28 @@ export class QdrantMemoryBridge implements MemoryBridge { })); } + /** + * Search memories across all personas (for cross-persona context) + */ + async searchAllPersonaMemories( + query: string, + limit = 10 + ): Promise> { + await this.init(); + + // Search without namespace filter to get all memories + const results = await this.memoryStore.search(query, { + limit, + }); + + return results.map((r) => ({ + id: r.id, + content: r.content, + score: r.score, + persona: r.senderId, + })); + } + /** * Get memories by IDs */ @@ -355,13 +417,14 @@ export class QdrantMemoryBridge implements MemoryBridge { /** * Store key facts extracted from conversation */ - async storeKeyFacts(facts: string[], sessionId: string): Promise { + async storeKeyFacts(facts: string[], sessionId: string, persona: string): Promise { await this.init(); for (const fact of facts) { await this.storeMemory(fact, { type: "key_fact", sessionId, + persona, extractedAt: Date.now(), }); } @@ -376,6 +439,7 @@ export class QdrantMemoryBridge implements MemoryBridge { limit?: number; includeKeyFacts?: boolean; sessionId?: string; + persona?: string; } ): Promise<{ relevantMemories: Array<{ content: string; score: number }>; @@ -385,8 +449,10 @@ export class QdrantMemoryBridge implements MemoryBridge { const limit = options?.limit ?? 5; - // Search for relevant memories - const memories = await this.searchMemories(taskDescription, limit); + // Search for relevant memories (persona-specific if provided) + const memories = await this.searchMemories(taskDescription, limit, { + persona: options?.persona, + }); // Load conversation state if session ID provided let conversationState: ConversationState | undefined; diff --git a/src/personas/persona.ts b/src/personas/persona.ts index f3f8a3a17e..669ab09357 100644 --- a/src/personas/persona.ts +++ b/src/personas/persona.ts @@ -7,6 +7,207 @@ import type { PersonaId, OrchestrationPersona } from "./types"; import { ORCHESTRATION_PERSONAS } from "./types"; +import { existsSync, readFileSync } from "fs"; +import { join, dirname } from "path"; + +// Import AGENT_CONFIGS for tool injection +import { AGENT_CONFIGS } from "../agent/personas"; + +// ============================================================================= +// Skill Loading +// ============================================================================= + +interface SkillFrontmatter { + name: string; + description?: string; + includes?: string[]; +} + +interface LoadedSkill { + frontmatter: SkillFrontmatter; + content: string; +} + +// Cache for loaded skills +const skillCache = new Map(); + +/** + * Find the skills directory (supports both dev and installed paths) + */ +function findSkillsDir(): string { + // Try relative paths from common locations + const candidates = [ + join(process.cwd(), ".claude", "skills"), + join(dirname(process.execPath), "..", "..", ".claude", "skills"), + join(process.env.HOME || "", ".local", "src", "agent-core", ".claude", "skills"), + ]; + + for (const dir of candidates) { + if (existsSync(dir)) { + return dir; + } + } + + // Fallback to cwd + return join(process.cwd(), ".claude", "skills"); +} + +/** + * Parse YAML frontmatter from markdown content + */ +function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string } { + const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) { + return { frontmatter: { name: "unknown" }, body: content }; + } + + const yamlContent = match[1]; + const body = match[2]; + + // Simple YAML parsing for our use case + const frontmatter: SkillFrontmatter = { name: "unknown" }; + const lines = yamlContent.split("\n"); + let currentKey = ""; + let inArray = false; + const arrayValues: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Check for array item + if (trimmed.startsWith("- ") && inArray) { + arrayValues.push(trimmed.slice(2).trim()); + continue; + } + + // If we were collecting an array, save it + if (inArray && currentKey === "includes") { + frontmatter.includes = [...arrayValues]; + arrayValues.length = 0; + inArray = false; + } + + // Check for key: value + const colonIndex = trimmed.indexOf(":"); + if (colonIndex > 0) { + currentKey = trimmed.slice(0, colonIndex).trim(); + const value = trimmed.slice(colonIndex + 1).trim(); + + if (value) { + // Simple value + if (currentKey === "name") frontmatter.name = value; + else if (currentKey === "description") frontmatter.description = value; + } else { + // Start of array or empty + if (currentKey === "includes") { + inArray = true; + } + } + } + } + + // Handle case where array is at the end + if (inArray && currentKey === "includes" && arrayValues.length > 0) { + frontmatter.includes = [...arrayValues]; + } + + return { frontmatter, body }; +} + +/** + * Load a skill by name (handles includes recursively) + */ +function loadSkill(skillName: string, skillsDir: string, loaded = new Set()): LoadedSkill | null { + // Prevent infinite loops + if (loaded.has(skillName)) { + return null; + } + loaded.add(skillName); + + // Check cache + if (skillCache.has(skillName)) { + return skillCache.get(skillName)!; + } + + // Try to load the skill file + const skillPath = join(skillsDir, skillName, "SKILL.md"); + if (!existsSync(skillPath)) { + return null; + } + + try { + const content = readFileSync(skillPath, "utf-8"); + const { frontmatter, body } = parseFrontmatter(content); + + // Load included skills + let combinedContent = body; + if (frontmatter.includes?.length) { + const includedParts: string[] = []; + for (const includeName of frontmatter.includes) { + const included = loadSkill(includeName, skillsDir, loaded); + if (included) { + includedParts.push(`\n\n${included.content}`); + } + } + if (includedParts.length > 0) { + combinedContent = body + "\n" + includedParts.join("\n"); + } + } + + const skill: LoadedSkill = { + frontmatter, + content: combinedContent, + }; + + skillCache.set(skillName, skill); + return skill; + } catch { + return null; + } +} + +/** + * Get loaded skill content for a persona + */ +export function getPersonaSkillContent(persona: PersonaId): string | null { + const skillsDir = findSkillsDir(); + const skill = loadSkill(persona, skillsDir); + return skill?.content ?? null; +} + +/** + * Clear the skill cache (useful for reloading) + */ +export function clearSkillCache(): void { + skillCache.clear(); +} + +/** + * Get persona-specific tools from AGENT_CONFIGS + */ +export function getPersonaTools(persona: PersonaId): string[] { + const config = AGENT_CONFIGS[persona]; + if (!config?.tools) return []; + + // Extract domain-specific tools (those with persona prefix) + const domainTools: string[] = []; + for (const [tool, enabled] of Object.entries(config.tools)) { + if (enabled && tool.includes(":")) { + domainTools.push(tool); + } + } + + return domainTools; +} + +/** + * Get full tool configuration for a persona + */ +export function getPersonaToolConfig(persona: PersonaId): Record { + const config = AGENT_CONFIGS[persona]; + return config?.tools ?? {}; +} // Import tiara types // Note: These are from the vendor submodule @@ -154,6 +355,7 @@ export function selectDroneType( /** * Generate a system prompt for a persona drone. + * Includes skill content from SKILL.md files. */ export function generateDronePrompt( persona: PersonaId, @@ -162,6 +364,7 @@ export function generateDronePrompt( plan?: string; objectives?: string[]; keyFacts?: string[]; + includeSkills?: boolean; } ): string { const config = getPersonaConfig(persona); @@ -180,6 +383,30 @@ export function generateDronePrompt( }); parts.push(``); + // Load and include skill content (if enabled, default true) + const includeSkills = context?.includeSkills !== false; + if (includeSkills) { + parts.push(`# Capabilities and Tools\n`); + + // Include domain-specific tools from AGENT_CONFIGS + const domainTools = getPersonaTools(persona); + if (domainTools.length > 0) { + parts.push(`## Available Domain Tools`); + for (const tool of domainTools) { + parts.push(`- \`${tool}\``); + } + parts.push(``); + } + + // Include condensed skill content from SKILL.md + const skillContent = getPersonaSkillContent(persona); + if (skillContent) { + const condensed = condenseSkillContent(skillContent, persona); + parts.push(condensed); + parts.push(``); + } + } + // Context from queen if (context?.plan) { parts.push(`# Current Plan`); @@ -218,6 +445,42 @@ export function generateDronePrompt( return parts.join("\n"); } +/** + * Condense skill content for drone prompts (extract key sections) + */ +function condenseSkillContent(content: string, persona: PersonaId): string { + const parts: string[] = []; + + // Extract Domain Tools section if present + const toolsMatch = content.match(/## Domain Tools[\s\S]*?\n\n(?=##|$)/); + if (toolsMatch) { + parts.push(toolsMatch[0].trim()); + } + + // Extract Core Capabilities section if present + const capsMatch = content.match(/## Core Capabilities[\s\S]*?\n\n(?=##|$)/); + if (capsMatch) { + // Just get the headers, not all the code blocks + const capsContent = capsMatch[0]; + const headers = capsContent.match(/### [^\n]+/g); + if (headers) { + parts.push(`Available capabilities: ${headers.map(h => h.replace("### ", "")).join(", ")}`); + } + } + + // If no structured content found, provide a summary based on persona + if (parts.length === 0) { + const summaries: Record = { + zee: "Memory storage/search, messaging (WhatsApp/Telegram), calendar, contacts, notifications", + stanley: "Market data, portfolio management, SEC filings, research, NautilusTrader backtesting", + johny: "Knowledge graph, spaced repetition, concept mapping, practice problems, learning progress", + }; + parts.push(`Tools: ${summaries[persona]}`); + } + + return parts.join("\n\n"); +} + /** * Get persona config by ID */ diff --git a/src/personas/types.ts b/src/personas/types.ts index 24eb1c2032..1bee98af28 100644 --- a/src/personas/types.ts +++ b/src/personas/types.ts @@ -476,12 +476,25 @@ export interface MemoryBridge { /** Load state from Qdrant */ loadState(): Promise; - /** Store a memory for continuity */ + /** Store a memory for continuity (requires persona in metadata for isolation) */ storeMemory(content: string, metadata: Record): Promise; - /** Search memories by query */ - searchMemories(query: string, limit?: number): Promise>; + /** Search memories by query (persona-isolated unless searching shared namespace) */ + searchMemories( + query: string, + limit?: number, + options?: { persona?: string; includeShared?: boolean } + ): Promise>; + + /** Search memories across all personas (for cross-persona context) */ + searchAllPersonaMemories?( + query: string, + limit?: number + ): Promise>; /** Get memories by IDs */ getMemories(ids: string[]): Promise>; + + /** Store key facts (requires persona for isolation) */ + storeKeyFacts?(facts: string[], sessionId: string, persona: string): Promise; } \ No newline at end of file diff --git a/src/plugin/builtin/index.ts b/src/plugin/builtin/index.ts index 3b72bf4763..d040683bed 100644 --- a/src/plugin/builtin/index.ts +++ b/src/plugin/builtin/index.ts @@ -4,7 +4,7 @@ * Exports all built-in plugins for easy registration. */ -export { ClaudeFlowPlugin } from './tiara'; +export { ClaudeFlowPlugin } from './claude-flow'; export { AnthropicAuthPlugin } from './anthropic-auth'; export { CopilotAuthPlugin } from './copilot-auth'; export { MemoryPersistencePlugin } from './memory-persistence'; @@ -13,7 +13,7 @@ export { MemoryPersistencePlugin } from './memory-persistence'; export { StanleyFinancePlugin } from './domains/stanley-finance'; export { ZeeMessagingPlugin } from './domains/zee-messaging'; -import { ClaudeFlowPlugin } from './tiara'; +import { ClaudeFlowPlugin } from './claude-flow'; import { AnthropicAuthPlugin } from './anthropic-auth'; import { CopilotAuthPlugin } from './copilot-auth'; import { MemoryPersistencePlugin } from './memory-persistence'; diff --git a/src/tiara.ts b/src/tiara.ts new file mode 100644 index 0000000000..58d80c8f16 --- /dev/null +++ b/src/tiara.ts @@ -0,0 +1,129 @@ +/** + * Tiara - Agent Orchestration Interface + * + * This module bridges the Council deliberation system with the Personas orchestrator. + * It exports the AgentOrchestrator interface expected by the council stages. + */ + +import type { PersonaId } from "./personas/types"; + +/** + * Result from spawning an agent + */ +export interface AgentSpawnResult { + result?: unknown; + error?: string; + durationMs?: number; +} + +/** + * Options for spawning an agent + */ +export interface AgentSpawnOptions { + agentType: string; + action: string; + params: Record; + context?: string; +} + +/** + * AgentOrchestrator interface expected by the Council deliberation system. + * Provides agent spawning capabilities for multi-agent coordination. + */ +export interface AgentOrchestrator { + /** + * Spawn an agent to perform a specific action + */ + spawnAgent(options: AgentSpawnOptions): Promise; +} + +/** + * Map council agent types to personas + */ +function mapAgentTypeToPersona(agentType: string): PersonaId { + switch (agentType.toLowerCase()) { + case "inbox_manager": + case "scheduler": + case "task_coordinator": + return "zee"; + case "research_assistant": + return "johny"; + case "market_analyst": + case "portfolio_manager": + return "stanley"; + default: + return "zee"; // Default to Zee for unknown types + } +} + +/** + * Create an AgentOrchestrator adapter from the Personas Orchestrator + */ +export function createAgentOrchestrator( + orchestrator: import("./personas/tiara").Orchestrator +): AgentOrchestrator { + return { + async spawnAgent(options: AgentSpawnOptions): Promise { + const startTime = Date.now(); + const persona = mapAgentTypeToPersona(options.agentType); + + try { + // Build prompt from action and params + const prompt = buildAgentPrompt(options); + + // Use spawnDroneWithWait to get the result + const result = await orchestrator.spawnDroneWithWait({ + persona, + task: `${options.agentType}: ${options.action}`, + prompt, + }); + + return { + result: result.result, + error: result.error, + durationMs: result.durationMs, + }; + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + durationMs: Date.now() - startTime, + }; + } + }, + }; +} + +/** + * Build a prompt for the agent from spawn options + */ +function buildAgentPrompt(options: AgentSpawnOptions): string { + const parts: string[] = []; + + parts.push(`## Agent Role: ${options.agentType}`); + parts.push(`## Action: ${options.action}`); + + if (options.context) { + parts.push(`\n## Context\n${options.context}`); + } + + if (options.params.query) { + parts.push(`\n## Query\n${options.params.query}`); + } + + // Add any other params + const otherParams = Object.entries(options.params).filter( + ([key]) => key !== "query" + ); + if (otherParams.length > 0) { + parts.push(`\n## Parameters`); + for (const [key, value] of otherParams) { + parts.push(`- ${key}: ${JSON.stringify(value)}`); + } + } + + return parts.join("\n"); +} + +// Re-export Orchestrator for convenience +export { Orchestrator } from "./personas/tiara"; +export type { PersonaId } from "./personas/types";