/** * useAgents — multi-agent registry hook. * * Holds N agent slots, each with its own config + Agent SDK instance. * Each slot can stream independently; streams are tracked per-agent. * * This is the careless equivalent of agi.html's `state.agents` Map, * but typed, reactive, and persistent. * * Each slot is saved to IDB: * - careless-agents-meta: [{ id, provider, model, systemPrompt, color, createdAt }] * - careless-agent-{id}: MessageData[] (reuses per-agent message store) */ import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { Agent } from '@strands-agents/sdk' import type { MessageData, Tool } from '@strands-agents/sdk' import { get, set, del } from 'idb-keyval' import type { Settings, AgentStatus } from '../types/index' import { createModel } from '../providers/factory' import { fireBusEvent } from '../lib/event-bus' import { buildSystemPrompt, type DynamicContext } from '../lib/system-prompt' const META_KEY = 'careless-agents-meta' const MSG_KEY_PREFIX = 'careless-agent-' /** Serialize all mutations to avoid stale-closure races on parallel calls. */ let writeLock: Promise = Promise.resolve() function serialize(fn: () => Promise): Promise { const next = writeLock.then(fn, fn) writeLock = next.then(() => undefined, () => undefined) return next } const AGENT_COLORS = [ '#00ff88', '#00aaff', '#ff88ff', '#ffaa00', '#ff6666', '#88ffff', '#ff8888', '#88ff88', ] export interface AgentConfig { id: string provider: Settings['provider'] model?: string apiKey?: string systemPrompt: string maxTokens?: number color: string createdAt: number lastUsedAt?: number /** Optional list of tool names. If set, only these tools (from the parent's tool set) are exposed to this sub-agent. If omitted → inherit ALL parent tools. */ tool_filter?: string[] /** How the slot's systemPrompt composes with the parent's base prompt: * - 'append' (default): use buildSystemPrompt(settings, ctx) as base, then append this slot's systemPrompt as "## Role/Specialization". * - 'replace': use ONLY this slot's systemPrompt verbatim (no base prompt, no dynamic context). Use for strict/minimal personas. */ prompt_mode?: 'append' | 'replace' } export interface AgentSlot extends AgentConfig { status: AgentStatus messages: MessageData[] error?: string } interface SpawnInput { id: string provider: Settings['provider'] model?: string apiKey?: string systemPrompt?: string maxTokens?: number tool_filter?: string[] prompt_mode?: 'append' | 'replace' } async function loadMeta(): Promise { try { const m = await get(META_KEY) return Array.isArray(m) ? m : [] } catch { return [] } } async function saveMeta(configs: AgentConfig[]): Promise { await set(META_KEY, configs) } async function loadAgentMessages(id: string): Promise { try { const m = await get(MSG_KEY_PREFIX + id) return Array.isArray(m) ? m : [] } catch { return [] } } async function saveAgentMessages(id: string, msgs: MessageData[]): Promise { await set(MSG_KEY_PREFIX + id, msgs) } /** Per-agent runtime state — SDK Agent instance + abort controller */ interface AgentRuntime { agent: Agent abort: AbortController | null } /** * useAgents — parent agent's tools and dynamic context propagate into every * sub-agent by default. Each slot can further restrict its tools via * config.tool_filter (string[] of tool names). * * @param parentSettings parent's Settings — model, provider, api keys, flags * @param parentTools parent's tool array (from buildTools(settings)) — * sub-agents INHERIT these unless tool_filter is set * @param dynamicCtx optional parent dynamic context — injected into the * base system prompt via buildSystemPrompt(). Pass the * same ctx useAgent uses so sub-agents see mesh peers, * time, active tasks, etc. */ export function useAgents( parentSettings: Settings, parentTools: Tool[] = [], dynamicCtx?: DynamicContext, ) { const [slots, setSlots] = useState>(new Map()) const [activeId, setActiveId] = useState(null) const [loaded, setLoaded] = useState(false) const runtimes = useRef>(new Map()) const colorIndexRef = useRef(0) /** Tool-name → Tool lookup so tool_filter can resolve quickly */ const toolsByName = useMemo(() => { const m = new Map() for (const t of parentTools) { const name = (t as Tool & { name?: string; config?: { name?: string } })?.name ?? (t as Tool & { config?: { name?: string } })?.config?.name if (name) m.set(name, t) } return m }, [parentTools]) /** Resolve a slot's effective tool set: filtered subset, or inherit all */ const resolveTools = useCallback((slot: AgentSlot | AgentConfig): Tool[] => { const filter = slot.tool_filter if (!filter || filter.length === 0) return parentTools const out: Tool[] = [] for (const name of filter) { const t = toolsByName.get(name) if (t) out.push(t) } return out }, [parentTools, toolsByName]) /** Resolve a slot's effective system prompt. Append-mode composes with base. */ const resolveSystemPrompt = useCallback((slot: AgentSlot | AgentConfig): string => { if (slot.prompt_mode === 'replace') return slot.systemPrompt // 'append' (default): base prompt + live context + slot's specialization const base = buildSystemPrompt(parentSettings, dynamicCtx) if (!slot.systemPrompt?.trim()) return base return `${base}\n\n## Sub-agent role — ${slot.id}\n${slot.systemPrompt.trim()}` }, [parentSettings, dynamicCtx]) /** When parent tools or settings change, invalidate all runtime caches so * the next send() rebuilds each sub-agent with fresh tools/prompt. */ useEffect(() => { runtimes.current.clear() }, [parentTools, parentSettings.enableTools, parentSettings.enableVision, parentSettings.enableMesh, parentSettings.enableMemory, parentSettings.disabledTools]) // Hydrate on mount useEffect(() => { let cancelled = false ;(async () => { const configs = await loadMeta() if (cancelled) return const m = new Map() for (const cfg of configs) { const messages = await loadAgentMessages(cfg.id) m.set(cfg.id, { ...cfg, status: 'idle', messages }) } setSlots(m) if (m.size > 0) setActiveId(m.keys().next().value!) setLoaded(true) })() return () => { cancelled = true } }, []) /** Build or rebuild an agent's SDK instance with parent tools + resolved prompt */ const buildAgent = useCallback(async (slot: AgentSlot): Promise => { const modelSettings: Settings = { ...parentSettings, provider: slot.provider, model: slot.model || parentSettings.model, apiKey: slot.apiKey || parentSettings.apiKey, maxTokens: slot.maxTokens ?? parentSettings.maxTokens, } const model = await createModel(modelSettings) const tools = resolveTools(slot) const systemPrompt = resolveSystemPrompt(slot) const agent = new Agent({ model, tools, // ← now inherits parent's tools (optionally filtered) systemPrompt, // ← base prompt + dynamic ctx + slot specialization }) agent.messages = slot.messages as any return agent }, [parentSettings, resolveTools, resolveSystemPrompt]) const spawn = useCallback(async (input: SpawnInput): Promise => { return serialize(async () => { // Read fresh state inside the lock to avoid stale-closure races on parallel spawns. const configs = await loadMeta() if (configs.some(c => c.id === input.id)) { throw new Error(`agent '${input.id}' already exists`) } const color = AGENT_COLORS[colorIndexRef.current++ % AGENT_COLORS.length] const slot: AgentSlot = { id: input.id, provider: input.provider, model: input.model, apiKey: input.apiKey, systemPrompt: input.systemPrompt || '', maxTokens: input.maxTokens, tool_filter: input.tool_filter, prompt_mode: input.prompt_mode ?? 'append', color, createdAt: Date.now(), status: 'idle', messages: [], } // Persist new meta (fresh list + this new slot) const newConfigs: AgentConfig[] = [...configs, { id: slot.id, provider: slot.provider, model: slot.model, apiKey: slot.apiKey, systemPrompt: slot.systemPrompt, maxTokens: slot.maxTokens, tool_filter: slot.tool_filter, prompt_mode: slot.prompt_mode, color: slot.color, createdAt: slot.createdAt, }] await saveMeta(newConfigs) // Functional setState so concurrent adds don't clobber each other setSlots(prev => { if (prev.has(slot.id)) return prev const next = new Map(prev) next.set(slot.id, slot) return next }) setActiveId(prev => prev ?? slot.id) fireBusEvent({ source: 'agents', kind: 'spawned', summary: `agent '${slot.id}' (${slot.provider})` }) return slot }) }, []) const kill = useCallback(async (id: string): Promise => { return serialize(async () => { // Abort any in-flight stream const rt = runtimes.current.get(id) if (rt?.abort) rt.abort.abort() runtimes.current.delete(id) // Persist fresh list minus this id const configs = await loadMeta() const nextConfigs = configs.filter(c => c.id !== id) if (nextConfigs.length === configs.length) return // wasn't there await saveMeta(nextConfigs) await del(MSG_KEY_PREFIX + id) setSlots(prev => { if (!prev.has(id)) return prev const next = new Map(prev) next.delete(id) return next }) setActiveId(prev => prev === id ? (nextConfigs.length > 0 ? nextConfigs[0].id : null) : prev) fireBusEvent({ source: 'agents', kind: 'killed', summary: `agent '${id}'` }) }) }, []) const update = useCallback(async (id: string, patch: Partial): Promise => { return serialize(async () => { const configs = await loadMeta() const idx = configs.findIndex(c => c.id === id) if (idx < 0) return const next = [...configs] next[idx] = { ...next[idx], ...patch } as AgentConfig await saveMeta(next) // Rebuild runtime so new tools / prompt_mode / model take effect. runtimes.current.delete(id) setSlots(prev => { const existing = prev.get(id) if (!existing) return prev const n = new Map(prev) n.set(id, { ...existing, ...patch }) return n }) fireBusEvent({ source: 'agents', kind: 'updated', summary: `agent '${id}'` }) }) }, []) /** Send a message to one agent, stream response, persist. */ const send = useCallback(async ( id: string, text: string, opts?: { abort?: AbortSignal }, ): Promise => { const slot = slots.get(id) if (!slot) throw new Error(`agent '${id}' not found`) // Ensure runtime let rt = runtimes.current.get(id) if (!rt) { const agent = await buildAgent(slot) rt = { agent, abort: null } runtimes.current.set(id, rt) } // Mark running setSlots(prev => { const next = new Map(prev) const s = next.get(id) if (s) next.set(id, { ...s, status: 'streaming' }) return next }) const abort = new AbortController() rt.abort = abort if (opts?.abort) opts.abort.addEventListener('abort', () => abort.abort()) let resultText = '' try { for await (const event of rt.agent.stream(text, { cancelSignal: abort.signal } as any)) { if (abort.signal.aborted) break const ev = event as any // Match useAgent's event handling — SDK wraps text deltas in modelStreamUpdateEvent.event.delta.text if (ev?.type === 'modelStreamUpdateEvent') { const inner = ev.event if (inner?.type === 'modelContentBlockDeltaEvent' && inner.delta?.type === 'textDelta') { resultText += inner.delta.text || '' // Live-update status to reflect streaming text count (optional — keeps slot.messages fresh during stream) } } // Agent SDK also emits messageAddedEvent / contentBlockEvent — these signal message list changes. // We'll read rt.agent.messages at the end for the final state. // Capture metrics if available (parity with useAgent.ts) if (ev?.type === 'agentResultEvent') { // noop — metrics not surfaced per-agent yet (future) } } // Persist messages from SDK const finalMessages = [...(rt.agent.messages as any as MessageData[])] await saveAgentMessages(id, finalMessages) // Bump lastUsedAt (sort order in UI) try { const configs = await loadMeta() const idx = configs.findIndex(c => c.id === id) if (idx >= 0) { configs[idx].lastUsedAt = Date.now() await saveMeta(configs) } } catch {} setSlots(prev => { const next = new Map(prev) const s = next.get(id) if (s) next.set(id, { ...s, status: 'idle', messages: finalMessages }) return next }) fireBusEvent({ source: 'agents', kind: 'sent', summary: `${id} ← "${text.slice(0, 40)}"` }) return resultText } catch (err: any) { setSlots(prev => { const next = new Map(prev) const s = next.get(id) if (s) next.set(id, { ...s, status: 'error', error: err?.message }) return next }) throw err } finally { rt.abort = null } }, [slots, buildAgent]) /** Broadcast to all agents in parallel. Returns map of id → response. */ const broadcast = useCallback(async ( text: string, excludeId?: string, ): Promise> => { const results = new Map() const targets = [...slots.keys()].filter(id => id !== excludeId) await Promise.all(targets.map(async (id) => { try { const r = await send(id, text) results.set(id, r) } catch (e: any) { results.set(id, e instanceof Error ? e : new Error(String(e))) } })) return results }, [slots, send]) const cancel = useCallback((id: string) => { const rt = runtimes.current.get(id) if (rt?.abort) rt.abort.abort() }, []) const clear = useCallback(async (id: string) => { const slot = slots.get(id) if (!slot) return const rt = runtimes.current.get(id) if (rt) rt.agent.messages = [] as any await del(MSG_KEY_PREFIX + id) setSlots(prev => { const next = new Map(prev) const s = next.get(id) if (s) next.set(id, { ...s, messages: [] }) return next }) }, [slots]) return { slots, activeId, setActiveId, loaded, spawn, kill, update, send, broadcast, cancel, clear, } } export type UseAgentsReturn = ReturnType