Files
tolaria/src/lib/aiAgentStreamCallbacks.ts

144 lines
4.5 KiB
TypeScript
Raw Normal View History

2026-04-13 19:37:59 +02:00
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
import { detectFileOperation, type AgentFileCallbacks } from './aiAgentFileOperations'
2026-04-13 19:37:59 +02:00
import {
markReasoningDone,
updateMessage,
updateToolAction,
type ToolInvocation,
} from './aiAgentMessageState'
2026-04-28 03:41:04 +02:00
import { getAiAgentDefinition, type AiAgentId } from './aiAgents'
import {
trackAiAgentResponseCompleted,
trackAiAgentResponseFailed,
} from './productAnalytics'
2026-04-13 19:37:59 +02:00
export interface StreamMutationContext {
2026-04-28 03:41:04 +02:00
agent: AiAgentId
2026-04-13 19:37:59 +02:00
messageId: string
vaultPath: string
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>
setStatus: Dispatch<SetStateAction<AgentStatus>>
abortRef: MutableRefObject<{ aborted: boolean }>
responseAccRef: MutableRefObject<string>
toolInputMapRef: MutableRefObject<Map<string, ToolInvocation>>
fileCallbacksRef: MutableRefObject<AgentFileCallbacks | undefined>
}
2026-04-28 03:41:04 +02:00
function finalResponseText(response: string, agent: AiAgentId): string {
2026-05-02 19:24:52 +02:00
if (response.trim()) return response
if (agent === 'opencode') {
return [
'OpenCode returned no assistant text.',
'Check the selected provider/model context limit or retry the request.',
'For large active notes, Tolaria sends a compact note snapshot and OpenCode can read the full file with get_note(path).',
].join(' ')
}
return `${getAiAgentDefinition(agent).label} finished without returning a reply.`
2026-04-23 14:15:15 +02:00
}
2026-04-13 19:37:59 +02:00
export function createStreamCallbacks(context: StreamMutationContext) {
const {
messageId,
2026-04-28 03:41:04 +02:00
agent,
2026-04-13 19:37:59 +02:00
vaultPath,
setMessages,
setStatus,
abortRef,
responseAccRef,
toolInputMapRef,
fileCallbacksRef,
} = context
let failureTracked = false
2026-04-30 21:01:09 +02:00
let streamFailed = false
2026-04-13 19:37:59 +02:00
return {
onThinking: (chunk: string) => {
if (abortRef.current.aborted) return
updateMessage(setMessages, messageId, (message) => ({
...message,
reasoning: (message.reasoning ?? '') + chunk,
}))
},
onText: (chunk: string) => {
if (abortRef.current.aborted) return
markReasoningDone(setMessages, messageId)
responseAccRef.current += chunk
},
onToolStart: (toolName: string, toolId: string, input?: string) => {
if (abortRef.current.aborted) return
markReasoningDone(setMessages, messageId)
setStatus('tool-executing')
const previous = toolInputMapRef.current.get(toolId)
toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? previous?.input })
updateMessage(setMessages, messageId, (message) => updateToolAction(message, toolName, toolId, input))
},
onToolDone: (toolId: string, output?: string) => {
if (abortRef.current.aborted) return
const info = toolInputMapRef.current.get(toolId)
if (info) {
detectFileOperation({
toolName: info.tool,
input: info.input,
vaultPath,
callbacks: fileCallbacksRef.current,
})
2026-04-13 19:37:59 +02:00
}
updateMessage(setMessages, messageId, (message) => ({
...message,
actions: message.actions.map((action) => (
action.toolId === toolId ? { ...action, status: 'done' as const, output } : action
)),
}))
},
onError: (error: string) => {
if (abortRef.current.aborted) return
setStatus('error')
2026-04-30 21:01:09 +02:00
streamFailed = true
2026-04-13 19:37:59 +02:00
const partial = responseAccRef.current
failureTracked = true
trackAiAgentResponseFailed(agent, partial, toolInputMapRef.current.size)
2026-04-13 19:37:59 +02:00
updateMessage(setMessages, messageId, (message) => ({
...message,
isStreaming: false,
reasoningDone: true,
response: partial ? `${partial}\n\nError: ${error}` : `Error: ${error}`,
actions: message.actions.map((action) => (
action.status === 'pending' ? { ...action, status: 'error' as const } : action
)),
}))
},
onDone: () => {
if (abortRef.current.aborted) return
2026-04-30 21:01:09 +02:00
if (streamFailed) return
2026-04-13 19:37:59 +02:00
setStatus('done')
2026-04-28 03:41:04 +02:00
const finalResponse = finalResponseText(responseAccRef.current, agent)
trackAiAgentResponseCompleted(agent, responseAccRef.current, toolInputMapRef.current.size, failureTracked)
2026-04-13 19:37:59 +02:00
updateMessage(setMessages, messageId, (message) => ({
...message,
isStreaming: false,
reasoningDone: true,
response: finalResponse,
actions: message.actions.map((action) => (
action.status === 'pending' ? { ...action, status: 'done' as const } : action
)),
}))
fileCallbacksRef.current?.onVaultChanged?.()
},
}
}