group conversations with models and local files
0

Configure Feed

Select the types of activity you want to include in your feed.

hezo / src / lib / claudeAgent.ts
5.9 kB 208 lines
1// Agent loop. Runs in the browser using the Anthropic SDK with 2// dangerouslyAllowBrowser. Streams text + tool_use rounds until end_turn, 3// invoking the caller's `toolHandler` for each tool_use. 4// 5// Ported from the Electron version's src/main/claudeApi.ts. Same shape; the 6// tool handler now talks to the server over the WebSocket instead of 7// mutating an Automerge doc. 8 9import Anthropic from '@anthropic-ai/sdk' 10import type { Turn } from '../shared/schema' 11 12export interface ToolCall { 13 id: string 14 name: string 15 input: Record<string, unknown> 16} 17 18export interface ToolResult { 19 toolUseId: string 20 content: string 21 isError?: boolean 22} 23 24export type ToolHandler = (call: ToolCall) => Promise<ToolResult> 25 26export interface AgentTurnOpts { 27 apiKey: string 28 model: string 29 // Re-evaluated each round so tool effects (file edits) feed into the 30 // next round's system prompt. 31 getSystemPrompt: () => string 32 // The full chain of past turns leading up to (and including) the user 33 // turn we're responding to. 34 history: Turn[] 35 tools: Anthropic.Tool[] 36 toolHandler: ToolHandler 37 annotateToolResult?: (call: ToolCall, result: ToolResult) => string 38} 39 40export interface AgentTurnCallbacks { 41 onTextSnapshot: (text: string) => void 42 onFinal: (result: { 43 text: string 44 inputTokens: number 45 outputTokens: number 46 }) => void 47 onError: (err: Error) => void 48} 49 50const MAX_ROUNDS = 10 51 52export function runAgentTurn( 53 opts: AgentTurnOpts, 54 cb: AgentTurnCallbacks, 55): { abort: () => void } { 56 const client = new Anthropic({ 57 apiKey: opts.apiKey, 58 dangerouslyAllowBrowser: true, 59 }) 60 let aborted = false 61 let currentStream: ReturnType<typeof client.messages.stream> | null = null 62 63 const run = async (): Promise<void> => { 64 let messages: Anthropic.MessageParam[] = opts.history 65 .filter((t) => t.role === 'user' || !t.status || t.status === 'complete') 66 .map((t) => ({ role: t.role, content: t.text })) 67 let textSoFar = '' 68 let inputTokens = 0 69 let outputTokens = 0 70 71 for (let round = 0; round < MAX_ROUNDS; round++) { 72 if (aborted) return 73 const baseBeforeRound = textSoFar 74 const system = opts.getSystemPrompt() 75 76 const stream = client.messages.stream({ 77 model: opts.model, 78 max_tokens: 4096, 79 system: system || undefined, 80 tools: opts.tools, 81 messages, 82 }) 83 currentStream = stream 84 85 stream.on('text', (_delta: string, snapshot: string) => { 86 cb.onTextSnapshot(baseBeforeRound + snapshot) 87 }) 88 89 let final: Anthropic.Message 90 try { 91 final = await stream.finalMessage() 92 } catch (err) { 93 cb.onError(err instanceof Error ? err : new Error(String(err))) 94 return 95 } 96 currentStream = null 97 98 inputTokens += final.usage.input_tokens 99 outputTokens += final.usage.output_tokens 100 101 const roundText = final.content 102 .filter((b): b is Anthropic.TextBlock => b.type === 'text') 103 .map((b) => b.text) 104 .join('') 105 textSoFar = baseBeforeRound + roundText 106 cb.onTextSnapshot(textSoFar) 107 108 const toolUses = final.content.filter( 109 (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use', 110 ) 111 112 if (final.stop_reason !== 'tool_use' || toolUses.length === 0) { 113 cb.onFinal({ text: textSoFar, inputTokens, outputTokens }) 114 return 115 } 116 117 const toolResultBlocks: Anthropic.ToolResultBlockParam[] = [] 118 for (const tu of toolUses) { 119 const call: ToolCall = { 120 id: tu.id, 121 name: tu.name, 122 input: tu.input as Record<string, unknown>, 123 } 124 let result: ToolResult 125 try { 126 result = await opts.toolHandler(call) 127 } catch (err) { 128 const msg = err instanceof Error ? err.message : String(err) 129 result = { 130 toolUseId: tu.id, 131 content: `Error: ${msg}`, 132 isError: true, 133 } 134 } 135 toolResultBlocks.push({ 136 type: 'tool_result', 137 tool_use_id: result.toolUseId, 138 content: result.content, 139 is_error: result.isError, 140 }) 141 const annotation = opts.annotateToolResult?.(call, result) 142 if (annotation) { 143 textSoFar += annotation 144 cb.onTextSnapshot(textSoFar) 145 } 146 } 147 148 messages = [ 149 ...messages, 150 { role: 'assistant', content: final.content }, 151 { role: 'user', content: toolResultBlocks }, 152 ] 153 } 154 155 cb.onError(new Error(`agent tool loop exceeded ${MAX_ROUNDS} rounds`)) 156 } 157 158 void run() 159 160 return { 161 abort: (): void => { 162 aborted = true 163 currentStream?.abort() 164 }, 165 } 166} 167 168// Walk parentId chain from leaf back to root; return chronological order. 169export function linearizeFromLeaf( 170 turns: Map<string, Turn>, 171 leafId: string, 172): Turn[] { 173 const chain: Turn[] = [] 174 let cur: string | null = leafId 175 while (cur) { 176 const t = turns.get(cur) 177 if (!t) break 178 chain.push(t) 179 cur = t.parentId 180 } 181 return chain.reverse() 182} 183 184export function latestLeafId(turns: Turn[]): string | null { 185 if (turns.length === 0) return null 186 const hasChild = new Set<string>() 187 for (const t of turns) if (t.parentId) hasChild.add(t.parentId) 188 const leaves = turns 189 .filter((t) => !hasChild.has(t.id)) 190 .sort((a, b) => b.createdAt - a.createdAt) 191 return leaves[0]?.id ?? null 192} 193 194export function childrenOf(turns: Turn[], parentId: string): Turn[] { 195 return turns 196 .filter((t) => t.parentId === parentId) 197 .sort((a, b) => a.createdAt - b.createdAt) 198} 199 200export function latestLeafInSubtree(turns: Turn[], rootId: string): string { 201 let cur = rootId 202 while (true) { 203 const kids = childrenOf(turns, cur) 204 if (kids.length === 0) return cur 205 const latest = kids.reduce((a, b) => (a.createdAt > b.createdAt ? a : b)) 206 cur = latest.id 207 } 208}