group conversations with models and local files
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}