/** * useAgent — concurrent multi-stream agent hook. * * Multiple agents can stream in parallel. Each send() spawns a fresh * Agent whose .messages points at a single shared list. Each in-flight * stream tracks its own: * - AbortController * - streaming text buffer * - tool calls (via BeforeToolCallEvent / ToolResultEvent lookalike: toolResultEvent + beforeToolCallEvent hooks aren't always streamed — we use contentBlockEvent + toolResultEvent from AgentStreamEvent) * - inline UI panels rendered via `render_ui` during that turn * - user-message index where its bubble gets inserted * * Inline rendering: the bubble sits at insertIndex, so multiple concurrent * streams naturally interleave with their corresponding user messages. */ import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { Agent, Tool } from '@strands-agents/sdk' import type { MessageData, ContentBlockData } from '@strands-agents/sdk' import { get, set, del } from 'idb-keyval' import type { Settings, DisplayMessage, AgentStatus, Metrics, Attachment, ToolCallInfo, RenderedUIPanel, } from '../types/index' import { buildTools, buildCustomTools } from '../tools/index' import { buildSystemPrompt, type DynamicContext } from '../lib/system-prompt' import { getSelfPromptAdditions } from '../tools/self-modify' import { mesh } from '../lib/mesh' import { toDisplayMessages, serializeMessages } from '../lib/messages' import { estimateTotalTokens, shouldSummarize } from '../lib/rolling-summary' import { fireBusEvent } from '../lib/event-bus' import { createModel } from '../providers/factory' import { appendTurn, readTurnsSync, formatTurnsForPrompt } from '../lib/turn-log' /** MessageData augmented with our private metadata stashed on agent.messages entries */ type StoredMessage = MessageData & { _toolCalls?: ToolCallInfo[] _uiPanels?: RenderedUIPanel[] _attachments?: Attachment[] _sid?: string } const DEFAULT_MESSAGES_KEY = 'careless-v2-messages' interface LiveStream { id: string text: string /** Index in messagesRef.current where THIS stream's user msg was pushed */ userIndex: number /** Tool calls emitted during this turn */ toolCalls: Map /** UI panels rendered during this turn (via render_ui tool) */ uiPanels: RenderedUIPanel[] /** Attachments the user sent with this turn (for display) */ attachments: Attachment[] /** User message text (for display; echoes input) */ userText: string } export interface UseAgentReturn { send: (input: string, opts?: { attachments?: Attachment[] }) => Promise<{ text: string }> cancel: () => void clear: () => void regenerate: (assistantMsgIdx?: number) => Promise exportMd: () => void messages: DisplayMessage[] streamText: string status: AgentStatus metrics: Metrics | null liveStreamCount: number customToolNames: string[] } /** Detect context-window / token-limit errors from any provider. */ function isContextOverflowError(err: unknown): boolean { const msg = String((err as { message?: string } | null)?.message || err || '').toLowerCase() return ( msg.includes('context window') || msg.includes('context length') || msg.includes('too many tokens') || msg.includes('input is too long') || msg.includes('maximum context') || msg.includes('token limit') || msg.includes('prompt is too long') || msg.includes('reduce the length') || msg.includes('trim conversation') ) } /** Detect auth/rate-limit errors for user-friendly messaging. */ function classifyProviderError(err: unknown): { kind: 'auth' | 'rate' | 'network' | 'unknown'; hint: string } { const e = err as { message?: string; status?: number; statusCode?: number } | null const msg = String(e?.message || err || '').toLowerCase() const status = e?.status || e?.statusCode || 0 if (status === 401 || status === 403 || msg.includes('unauthorized') || msg.includes('invalid api key') || msg.includes('authentication')) { return { kind: 'auth', hint: 'Check your API key in Settings (⌘,).' } } if (status === 429 || msg.includes('rate limit') || msg.includes('quota') || msg.includes('too many requests')) { return { kind: 'rate', hint: 'Rate limited — wait a moment and try again.' } } if (msg.includes('fetch') || msg.includes('network') || msg.includes('connection')) { return { kind: 'network', hint: 'Network issue — check your connection.' } } return { kind: 'unknown', hint: '' } } /** Drop the older half of conversation messages (keep system/tool pairs intact). */ function dropOlderHalf(messages: StoredMessage[]): StoredMessage[] { if (messages.length <= 2) return messages // Keep the last half, but never break a toolUse→toolResult pair. let cut = Math.floor(messages.length / 2) // Walk forward until we're not in the middle of a tool chain. while (cut < messages.length) { const m = messages[cut] const hasToolResult = Array.isArray(m?.content) && m.content.some((b: ContentBlockData) => 'toolResult' in b) if (!hasToolResult) break cut++ } return messages.slice(cut) } /** Fire a toast notification via window event for App to display. */ function fireToast(text: string, kind: 'info' | 'warn' | 'error' = 'info') { try { window.dispatchEvent(new CustomEvent('careless:toast', { detail: { text, kind } })) } catch {} } export function useAgent(settings: Settings, dynamicCtx?: DynamicContext, storageKey?: string | null): UseAgentReturn { const MESSAGES_KEY = storageKey || DEFAULT_MESSAGES_KEY const messagesRef = useRef([]) const abortControllersRef = useRef>(new Map()) const hydratedRef = useRef(false) const [status, setStatus] = useState('ready') const [metrics, setMetrics] = useState(null) const [messageVersion, setMessageVersion] = useState(0) const [liveStreams, setLiveStreams] = useState>(new Map()) const liveStreamsRef = useRef>(new Map()) // Keep ref in sync for use inside stale closures (finally block) useEffect(() => { liveStreamsRef.current = liveStreams }, [liveStreams]) const baseTools = useMemo(() => buildTools(settings), [ settings.enableTools, settings.enableVision, settings.enableMesh, settings.enableMemory, settings.disabledTools, ]) const [customTools, setCustomTools] = useState([]) useEffect(() => { let alive = true const refresh = async () => { const t = await buildCustomTools() if (alive) setCustomTools(t) } refresh() window.addEventListener('careless:custom-tools-changed', refresh) return () => { alive = false; window.removeEventListener('careless:custom-tools-changed', refresh) } }, []) const tools = useMemo(() => [...baseTools, ...customTools], [baseTools, customTools]) const customToolNames = useMemo( () => customTools.map((t) => (t as Tool & { name?: string; config?: { name?: string } })?.name || (t as Tool & { config?: { name?: string } })?.config?.name || 'unnamed').filter(Boolean), [customTools] ) const [selfAdditions, setSelfAdditions] = useState('') useEffect(() => { const refresh = async () => setSelfAdditions(await getSelfPromptAdditions()) refresh() window.addEventListener('careless:system-prompt-changed', refresh) return () => window.removeEventListener('careless:system-prompt-changed', refresh) }, []) // Derive threadId from storageKey (e.g. "careless-thread-t-abc123" → "t-abc123") const threadId = useMemo(() => { if (!storageKey) return null const m = storageKey.match(/^careless-thread-(.+)$/) return m ? m[1] : null }, [storageKey]) // Turn-log version — bumped after each append so system prompt re-renders const [turnLogVersion, setTurnLogVersion] = useState(0) const systemPrompt = useMemo(() => { // Inject IDB-persisted turn transcript (last 200 turns, cross-thread) const recentTurns = readTurnsSync(200) const turnTranscript = formatTurnsForPrompt(recentTurns, { activeThreadId: threadId, maxChars: 12000 }) const base = buildSystemPrompt(settings, { ...(dynamicCtx || {}), turnTranscript }) return base + (selfAdditions || '') }, [settings, dynamicCtx, selfAdditions, threadId, turnLogVersion]) useEffect(() => { // Re-hydrate when storageKey changes hydratedRef.current = false messagesRef.current = [] setMessageVersion(v => v + 1) get(MESSAGES_KEY).then(stored => { if (Array.isArray(stored) && stored.length > 0) { messagesRef.current = stored setMessageVersion(v => v + 1) } hydratedRef.current = true }).catch(() => { hydratedRef.current = true }) }, [MESSAGES_KEY]) // External mutations via manage_messages tool → re-hydrate from IDB. useEffect(() => { const onExternal = (e: Event) => { const ev = e as CustomEvent<{ key: string; count: number }> if (ev.detail?.key && ev.detail.key !== MESSAGES_KEY) return get(MESSAGES_KEY).then(stored => { messagesRef.current = Array.isArray(stored) ? stored : [] setMessageVersion(v => v + 1) }).catch(() => {}) } window.addEventListener('careless:messages-changed', onExternal) return () => window.removeEventListener('careless:messages-changed', onExternal) }, [MESSAGES_KEY]) useEffect(() => { mesh.init() mesh.setPageLabel('careless') }, []) const patchStream = useCallback((id: string, patch: (s: LiveStream) => LiveStream) => { setLiveStreams(prev => { const cur = prev.get(id) if (!cur) return prev const next = new Map(prev) next.set(id, patch(cur)) return next }) }, []) // Listen for render_ui events — route to the most recent active stream. // The render_ui tool fires a careless:ui-render event; we attach it to the // stream whose insertIndex is greatest (i.e. most-recently started) since // that's the one whose tool just fired. This is best-effort but works well // in practice because tool calls happen synchronously within a single turn. useEffect(() => { const handler = (e: Event) => { const panel: RenderedUIPanel = (e as CustomEvent).detail setLiveStreams(prev => { if (prev.size === 0) return prev // Pick the most recently started stream (largest userIndex). let targetId: string | null = null let maxIdx = -1 for (const [id, s] of prev) { if (s.userIndex > maxIdx) { maxIdx = s.userIndex; targetId = id } } if (!targetId) return prev const next = new Map(prev) const cur = next.get(targetId)! next.set(targetId, { ...cur, uiPanels: [...cur.uiPanels, panel] }) return next }) } window.addEventListener('careless:ui-render', handler) return () => window.removeEventListener('careless:ui-render', handler) }, []) const send = useCallback(async (input: string, opts: { attachments?: Attachment[] } = {}) => { const streamId = `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` setStatus('thinking') setMetrics(null) // 📜 Append user turn to persistent log (fire-and-forget) const userTurnText = input.trim() || (opts.attachments?.length ? `[attached ${opts.attachments.length} file(s)]` : '') if (userTurnText) { appendTurn({ threadId, role: 'user', text: userTurnText }) .then(() => setTurnLogVersion(v => v + 1)) .catch(err => console.warn('[turn-log] user append failed', err)) } // 📊 Proactive context-usage check — warn if approaching window try { const tokens = estimateTotalTokens(messagesRef.current) if (shouldSummarize(messagesRef.current, settings.model, 95)) { // Critical: proactively drop older half to avoid guaranteed overflow const before = messagesRef.current.length const kept = dropOlderHalf(messagesRef.current) const dropped = before - kept.length if (dropped > 0) { messagesRef.current = kept fireToast(`Context critical (~${tokens} tok) — auto-dropped ${dropped} older messages`, 'warn') fireBusEvent({ source: 'system', kind: 'completed', summary: `context truncation: dropped ${dropped} msgs (${tokens} tok)` }) } } else if (shouldSummarize(messagesRef.current, settings.model, 80)) { fireToast(`Context usage high (~${tokens} tok) — consider /clear or /export`, 'warn') } } catch {} // Build multimodal input blocks const textBlocks: any[] = [] const promptText = input.trim() || 'Have a look.' textBlocks.push({ text: promptText }) if (opts.attachments?.length) { for (const att of opts.attachments) { if (att.type === 'image' && att.base64) { textBlocks.push({ image: { format: att.format || 'jpeg', source: { bytes: att.base64 } } }) } else if (att.type === 'document' && att.base64 && att.format) { // Bedrock Converse document block — PDFs, Office docs, etc. // Document name must be alphanumeric + spaces/hyphens/parens/brackets (Bedrock requirement) const safeName = (att.name || 'document').replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9\s\-()\[\]]/g, '_').slice(0, 200) || 'document' textBlocks.push({ document: { name: safeName, format: att.format, source: { bytes: att.base64 } } }) } else if ((att.type === 'file' || att.type === 'document') && att.text) { textBlocks.push({ text: `\n\n--- Attached file: ${att.name} ---\n${att.text}\n--- end ---` }) } } } const agentInput: any = textBlocks // The user message will be pushed by Agent.stream at messagesRef.current.length const userIndex = messagesRef.current.length // Seed the live stream with user text + attachments for display setLiveStreams(prev => { const next = new Map(prev) next.set(streamId, { id: streamId, text: '', userIndex, toolCalls: new Map(), uiPanels: [], attachments: opts.attachments || [], userText: promptText, }) return next }) let agent: Agent try { const model = await createModel(settings) agent = new Agent({ model, tools, systemPrompt, messages: messagesRef.current, } as any) } catch (err: unknown) { console.error('[useAgent] createModel failed:', err) messagesRef.current.push({ role: 'assistant', content: [{ text: `⚠️ Failed to initialize model: ${err instanceof Error ? err.message : String(err)}` }], }) setStatus('error') setMessageVersion(v => v + 1) setLiveStreams(prev => { const n = new Map(prev); n.delete(streamId); return n }) return { text: '' } } const controller = new AbortController() abortControllersRef.current.set(streamId, controller) let text = '' let errored = false try { for await (const event of agent.stream(agentInput, { cancelSignal: controller.signal } as any)) { if (controller.signal.aborted) break const e = event as any switch (e.type) { case 'modelStreamUpdateEvent': { const inner = e.event if (inner?.type === 'modelContentBlockDeltaEvent' && inner.delta?.type === 'textDelta') { text += inner.delta.text patchStream(streamId, s => ({ ...s, text })) setStatus('streaming') } break } case 'beforeToolCallEvent': { const tu = e.toolUse if (!tu) break const info: ToolCallInfo = { id: `tc-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, toolUseId: tu.toolUseId, name: tu.name, input: tu.input, status: 'running', startedAt: Date.now(), } patchStream(streamId, s => { const tc = new Map(s.toolCalls) tc.set(tu.toolUseId, info) return { ...s, toolCalls: tc } }) fireBusEvent({ source: 'tool', kind: 'started', summary: `${tu.name}(${JSON.stringify(tu.input).slice(0, 60)})` }) break } case 'afterToolCallEvent': case 'toolResultEvent': { const result = e.result || e.toolResult const toolUseId = result?.toolUseId || e.toolUse?.toolUseId if (!toolUseId) break // Extract text from result content let resText = '' if (result?.content && Array.isArray(result.content)) { resText = result.content.map((c: any) => c.text || '').join('') } else if (typeof result === 'string') { resText = result } const ok = result?.status !== 'error' patchStream(streamId, s => { const tc = new Map(s.toolCalls) const existing = tc.get(toolUseId) if (existing) { tc.set(toolUseId, { ...existing, status: ok ? 'success' : 'error', result: resText.slice(0, 2000), endedAt: Date.now(), }) fireBusEvent({ source: 'tool', kind: ok ? 'completed' : 'error', summary: `${existing.name} → ${resText.slice(0, 80)}` }) } return { ...s, toolCalls: tc } }) break } case 'contentBlockEvent': case 'modelMessageEvent': case 'messageAddedEvent': setMessageVersion(v => v + 1) break case 'agentResultEvent': { const u = e.result?.metrics?.accumulatedUsage if (u) setMetrics({ input: u.inputTokens || 0, output: u.outputTokens || 0 }) break } } } } catch (err: unknown) { errored = true console.error('[agent.stream] error:', streamId, err) // 🩺 Self-heal: context overflow → drop older half + retry once if (isContextOverflowError(err) && messagesRef.current.length > 2) { const before = messagesRef.current.length messagesRef.current = dropOlderHalf(messagesRef.current) const after = messagesRef.current.length const tok = estimateTotalTokens(messagesRef.current) console.warn(`[useAgent] context overflow — trimmed ${before} → ${after} msgs (~${tok} tok), retrying`) fireToast(`Context full — trimmed ${before - after} old messages, retrying…`, 'warn') try { // Rebuild agent with trimmed messages and retry the stream once const model2 = await createModel(settings) const agent2 = new Agent({ model: model2, tools, systemPrompt, messages: messagesRef.current, } as any) text = '' patchStream(streamId, sx => ({ ...sx, text: '' })) for await (const event of agent2.stream(agentInput, { cancelSignal: controller.signal } as any)) { if (controller.signal.aborted) break const e = event as any if (e.type === 'modelStreamUpdateEvent') { const inner = e.event if (inner?.type === 'modelContentBlockDeltaEvent' && inner.delta?.type === 'textDelta') { text += inner.delta.text patchStream(streamId, sx => ({ ...sx, text })) setStatus('streaming') } } else if (e.type === 'agentResultEvent') { const u = e.result?.metrics?.accumulatedUsage if (u) setMetrics({ input: u.inputTokens || 0, output: u.outputTokens || 0 }) } } // Use the retry agent as authoritative agent = agent2 errored = false } catch (retryErr: any) { console.error('[useAgent] retry after context-trim also failed:', retryErr) messagesRef.current.push({ role: 'assistant', content: [{ text: `⚠️ Context overflow — retry failed: ${retryErr?.message || retryErr}` }], }) setStatus('error') } } else { // Classify for friendly toast const cls = classifyProviderError(err) const pretty = cls.kind === 'auth' ? `⚠️ Authentication failed. ${cls.hint}` : cls.kind === 'rate' ? `⚠️ Rate limit hit. ${cls.hint}` : cls.kind === 'network' ? `⚠️ Network error. ${cls.hint}` : `⚠️ ${err instanceof Error ? err.message : String(err)}` if (cls.kind !== 'unknown') fireToast(pretty, cls.kind === 'auth' ? 'error' : 'warn') messagesRef.current.push({ role: 'assistant', content: [{ text: pretty }], }) setStatus('error') } } finally { abortControllersRef.current.delete(streamId) // CRITICAL: Sync agent.messages back into messagesRef.current. // The SDK's Agent constructor wraps config.messages in Message instances via // `(config?.messages ?? []).map(msg => Message.fromMessageData(msg))` — so // agent.messages is a NEW array, not our ref. All new user/assistant messages // the SDK appended during the turn live there, not in our ref. // // We serialize agent.messages back to plain objects and replace our ref, // preserving conversation continuity across turns. try { const authoritative = (agent as any).messages || [] const serialized = serializeMessages(authoritative) // CRITICAL: preserve our _-prefixed metadata (_toolCalls, _uiPanels, // _attachments) across resync. The SDK owns agent.messages, and // serializeMessages strips our hack fields. We re-attach them by // role-and-index alignment with the previous ref. const prev = messagesRef.current for (let i = 0; i < serialized.length && i < prev.length; i++) { const p = prev[i] as any const n = serialized[i] as any if (!p || !n || p.role !== n.role) continue if (p._toolCalls) n._toolCalls = p._toolCalls if (p._uiPanels) n._uiPanels = p._uiPanels if (p._attachments) n._attachments = p._attachments } messagesRef.current = serialized } catch (syncErr) { console.warn('[useAgent] failed to sync agent.messages:', syncErr) } // Persist tool calls + UI panels + attachments onto the messages we just synced. const cur = liveStreamsRef.current.get(streamId) if (cur) { const lastMsg = messagesRef.current[messagesRef.current.length - 1] if (lastMsg && lastMsg.role === 'assistant') { (lastMsg as any)._toolCalls = Array.from(cur.toolCalls.values()) ;(lastMsg as any)._uiPanels = cur.uiPanels } // Attach attachments to the user message at userIndex (our pre-turn index). const userMsg = messagesRef.current[cur.userIndex] if (userMsg && userMsg.role === 'user' && cur.attachments.length) { (userMsg as any)._attachments = cur.attachments } } setLiveStreams(prev => { const n = new Map(prev); n.delete(streamId); return n }) if (!errored) { const msgLen = text.length if (msgLen > 0) fireBusEvent({ source: 'agent', kind: 'completed', summary: `Response: ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}` }) // 📜 Append assistant turn with tool summary if (text.trim()) { const toolNames = cur ? Array.from(cur.toolCalls.values()).map(tc => tc.name) : [] const toolsSummary = toolNames.length ? `tools: ${toolNames.slice(0, 5).join(', ')}${toolNames.length > 5 ? '…' : ''}` : undefined appendTurn({ threadId, role: 'assistant', text, tools: toolsSummary }) .then(() => setTurnLogVersion(v => v + 1)) .catch(err => console.warn('[turn-log] assistant append failed', err)) } } if (abortControllersRef.current.size === 0 && !errored) setStatus('ready') else if (abortControllersRef.current.size > 0) setStatus('streaming') setMessageVersion(v => v + 1) if (messagesRef.current.length) { set(MESSAGES_KEY, serializeMessages(messagesRef.current)).catch(() => {}) } } return { text } }, [settings, tools, systemPrompt, patchStream]) const cancel = useCallback(() => { for (const c of abortControllersRef.current.values()) c.abort() abortControllersRef.current.clear() setLiveStreams(new Map()) setStatus('ready') }, []) /** * Regenerate the last (or specified) assistant message. * Splices off everything from that msg onward, then re-sends the previous user msg. */ const regenerate = useCallback(async (assistantMsgIdx?: number) => { const msgs = messagesRef.current let idx = assistantMsgIdx if (idx === undefined) { // Find last assistant msg for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].role === 'assistant') { idx = i; break } } } if (idx === undefined || idx < 1) return const userMsg = msgs[idx - 1] if (!userMsg || userMsg.role !== 'user') return const userText = Array.isArray(userMsg.content) ? userMsg.content.map((b: any) => b.text || '').filter(Boolean).join(' ') : String(userMsg.content || '') // Splice off the user + assistant (we'll resend the user) messagesRef.current = msgs.slice(0, idx - 1) setMessageVersion(v => v + 1) if (userText.trim()) await send(userText) }, []) const clear = useCallback(() => { cancel() messagesRef.current.length = 0 del(MESSAGES_KEY).catch(() => {}) setMetrics(null) setMessageVersion(v => v + 1) }, [cancel]) const exportMd = useCallback(() => { const msgs = messagesRef.current if (!msgs.length) return const display = toDisplayMessages(msgs as any) const md = display.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.text}`).join('\n\n---\n\n') const blob = new Blob([md], { type: 'text/markdown' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `careless-${new Date().toISOString().slice(0, 10)}.md` a.click() URL.revokeObjectURL(url) }, []) // Build interleaved messages. Persisted messages come first; each live stream // appends its own user+assistant pair at the end, inline with its position. const messages = useMemo(() => { const display = toDisplayMessages(messagesRef.current as any) // Attach metadata we stashed on raw messages for (let i = 0; i < display.length; i++) { const raw = messagesRef.current[i] if (!raw) continue if (raw._toolCalls) display[i].toolCalls = raw._toolCalls if (raw._uiPanels) display[i].uiPanels = raw._uiPanels if (raw._attachments) display[i].attachments = raw._attachments } // Now append each live stream: the user message for it has already been // pushed by agent.stream at userIndex (so it's in `display`), and we show // the streaming assistant bubble right after. const liveArr = Array.from(liveStreams.values()).sort((a, b) => a.userIndex - b.userIndex) for (const s of liveArr) { // If the user message isn't in display yet (stream just started, // Strands hasn't pushed it), synthesize a placeholder user bubble. const hasUser = display.some(d => d.role === 'user' && (d as any)._sid === s.id) if (!hasUser && s.userText) { // Find if a message at this userIndex already exists in display (Strands pushed it). const msgAtIdx = messagesRef.current[s.userIndex] if (!msgAtIdx) { display.push({ id: `live-user-${s.id}`, role: 'user', text: s.userText, attachments: s.attachments, }) } } display.push({ id: s.id, role: 'assistant', text: s.text || '', streaming: true, toolCalls: Array.from(s.toolCalls.values()), uiPanels: s.uiPanels, }) } return display }, [messageVersion, liveStreams]) const streamText = useMemo(() => { if (liveStreams.size === 0) return '' const arr = Array.from(liveStreams.values()) return arr[arr.length - 1]?.text || '' }, [liveStreams]) return { send, cancel, clear, regenerate, exportMd, messages, streamText, status, metrics, liveStreamCount: liveStreams.size, customToolNames, } }