char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
0afb805b1
char/aliceai-t5-moe
1import { 2ATTACHMENT_SAVED_REGEX , 3MARKDOWN , 4NEWLINE , 5REASONING_TAGS , 6SEARCH_SUMMARY , 7TOOL_RESULT_JSON_OPEN_REGEX 8} from '$lib/constants' ; 9import { 10AgenticSectionType , 11AttachmentType , 12ContinueIntentKind , 13MessageRole , 14ToolResultKind 15} from '$lib/enums' ; 16import type { AgenticSection , ContinueIntent , ToolResultLine } from '$lib/types/agentic' ; 17import type { ApiChatCompletionToolCall } from '$lib/types/api' ; 18import type { 19DatabaseMessage , 20DatabaseMessageExtra , 21DatabaseMessageExtraImageFile 22} from '$lib/types/database' ; 23 24/** 25* Derives display sections from a single assistant message and its direct tool results. 26* 27* @param message - The assistant message 28* @param toolMessages - Tool result messages for this assistant's tool_calls 29* @param streamingToolCalls - Partial tool calls during streaming (not yet persisted) 30*/ 31function deriveSingleTurnSections ( 32message :DatabaseMessage , 33toolMessages :DatabaseMessage [] = [], 34streamingToolCalls :ApiChatCompletionToolCall [] = [], 35isStreaming :boolean = false 36) :AgenticSection [] { 37const sections :AgenticSection [] = []; 38 39// 1. Reasoning content (from dedicated field) 40if ( message . reasoningContent ) { 41const toolCalls = parseToolCalls ( message . toolCalls ); 42const hasContentAfterReasoning = 43!! message . content ?. trim () || toolCalls . length > 0 || streamingToolCalls . length > 0 ; 44const isPending = isStreaming && ! hasContentAfterReasoning ; 45 46sections . push ({ 47content :message . reasoningContent , 48type :isPending ?AgenticSectionType . REASONING_PENDING :AgenticSectionType . REASONING , 49wasInterrupted :! isStreaming && ! hasContentAfterReasoning 50}); 51} 52 53// 2. Text content 54if ( message . content ?. trim ()) { 55sections . push ({ 56content :message . content , 57type :AgenticSectionType . TEXT 58}); 59} 60 61// 3. Persisted tool calls (from message.toolCalls field) 62const toolCalls = parseToolCalls ( message . toolCalls ); 63// Index tool messages by toolCallId for O(1) lookup instead of O(n) find() 64const toolMsgById = new Map < string , DatabaseMessage >(); 65 66for ( const tm of toolMessages ) { 67if ( tm . toolCallId && ! toolMsgById . has ( tm . toolCallId )) { 68toolMsgById . set ( tm . toolCallId , tm ); 69} 70} 71 72for ( const tc of toolCalls ) { 73const resultMsg = tc . id ?toolMsgById . get ( tc . id ) :undefined ; 74// Only show as pending/loading if we're actively streaming; otherwise it's just a tool call without result 75const type = resultMsg 76 ?AgenticSectionType . TOOL_CALL 77 :isStreaming 78 ?AgenticSectionType . TOOL_CALL_PENDING 79 :AgenticSectionType . TOOL_CALL ; 80 81sections . push ({ 82content :resultMsg ?. content || '' , 83toolArgs :tc . function ?. arguments , 84toolCallId :tc . id , 85toolCwd :resultMsg ?. toolCwd , 86toolName :tc . function ?. name , 87toolResult :resultMsg ?. content , 88toolResultExtras :resultMsg ?. extra , 89 type 90}); 91} 92 93// 4. Streaming tool calls (not yet persisted - currently being received) 94const persistedIds = new Set ( toolCalls . map (( t ) => t . id ). filter ( Boolean )); 95 96for ( const tc of streamingToolCalls ) { 97// Skip if already in persisted tool calls 98if ( tc . id && persistedIds . has ( tc . id )) continue ; 99 100sections . push ({ 101content :'' , 102toolArgs :tc . function ?. arguments , 103toolCallId :tc . id , 104toolName :tc . function ?. name , 105type :AgenticSectionType . TOOL_CALL_STREAMING 106}); 107} 108 109return sections ; 110} 111 112interface TurnSectionsCacheEntry { 113content :string | undefined ; 114extra :DatabaseMessageExtra [] | undefined ; 115reasoningContent :string | undefined ; 116toolCalls :string | undefined ; 117toolMessageContents :( string | undefined )[]; 118toolMessageExtras :( DatabaseMessageExtra [] | undefined )[]; 119toolMessages :DatabaseMessage []; 120sections :AgenticSection []; 121} 122 123const turnSectionsCache = new WeakMap < DatabaseMessage , TurnSectionsCacheEntry >(); 124 125function isTurnCacheValid ( 126entry :TurnSectionsCacheEntry , 127message :DatabaseMessage , 128toolMessages :DatabaseMessage [] 129) :boolean { 130if ( 131entry . content !== message . content || 132entry . reasoningContent !== message . reasoningContent || 133entry . toolCalls !== message . toolCalls || 134entry . extra !== message . extra 135) { 136return false ; 137} 138 139if ( entry . toolMessages . length !== toolMessages . length ) return false ; 140 141for ( let i = 0 ; i < toolMessages . length ; i ++ ) { 142if ( entry . toolMessages [ i ] !== toolMessages [ i ]) return false ; 143 144if ( entry . toolMessageContents [ i ] !== toolMessages [ i ]. content ) return false ; 145 146if ( entry . toolMessageExtras [ i ] !== toolMessages [ i ]. extra ) return false ; 147} 148 149return true ; 150} 151 152/** 153* deriveSingleTurnSections with structural reuse for completed turns. 154* 155* deriveAgenticSections runs in a $derived invalidated per streamed chunk, but 156* only the last turn actually changes. Messages mutate in place and are never 157* replaced, so a WeakMap keyed by the turn's assistant message plus reference 158* checks on every field deriveSingleTurnSections reads detects any change. A 159* cache hit also returns the same section objects, keeping downstream props 160* stable so tool blocks skip their per-chunk re-derive. The streaming turn 161* recomputes uncached on every chunk. 162*/ 163function deriveTurnSections ( 164message :DatabaseMessage , 165toolMessages :DatabaseMessage [], 166streamingToolCalls :ApiChatCompletionToolCall [], 167isStreaming :boolean 168) :AgenticSection [] { 169if ( isStreaming || streamingToolCalls . length > 0 ) { 170return deriveSingleTurnSections ( message , toolMessages , streamingToolCalls , isStreaming ); 171} 172 173const cached = turnSectionsCache . get ( message ); 174 175if ( cached && isTurnCacheValid ( cached , message , toolMessages )) { 176return cached . sections ; 177} 178 179const sections = deriveSingleTurnSections ( message , toolMessages , [], false ); 180 181turnSectionsCache . set ( message , { 182content :message . content , 183extra :message . extra , 184reasoningContent :message . reasoningContent , 185 sections, 186toolCalls :message . toolCalls , 187toolMessageContents :toolMessages . map (( tm ) => tm . content ), 188toolMessageExtras :toolMessages . map (( tm ) => tm . extra ), 189 toolMessages 190}); 191 192return sections ; 193} 194 195/** 196* Derives display sections from structured message data. 197* 198* Handles both single-turn (one assistant + its tool results) and multi-turn 199* agentic sessions (multiple assistant + tool messages grouped together). 200* 201* When `toolMessages` contains continuation assistant messages (from multi-turn 202* agentic flows), they are processed in order to produce sections across all turns. 203* 204* @param message - The first/anchor assistant message 205* @param toolMessages - Tool result messages and continuation assistant messages 206* @param streamingToolCalls - Partial tool calls during streaming (not yet persisted) 207* @param isStreaming - Whether the message is currently being streamed 208*/ 209export function deriveAgenticSections ( 210message :DatabaseMessage , 211toolMessages :DatabaseMessage [] = [], 212streamingToolCalls :ApiChatCompletionToolCall [] = [], 213isStreaming :boolean = false 214) :AgenticSection [] { 215const hasAssistantContinuations = toolMessages . some (( m ) => m . role === MessageRole . ASSISTANT ); 216 217if ( ! hasAssistantContinuations ) { 218return deriveTurnSections ( message , toolMessages , streamingToolCalls , isStreaming ); 219} 220 221const sections :AgenticSection [] = []; 222const firstTurnToolMsgs = collectToolMessages ( toolMessages , 0 ); 223 224sections . push ( ...deriveTurnSections ( message , firstTurnToolMsgs , [], false )); 225 226let i = firstTurnToolMsgs . length ; 227 228while ( i < toolMessages . length ) { 229const msg = toolMessages [ i ]; 230 231if ( msg . role === MessageRole . ASSISTANT ) { 232const turnToolMsgs = collectToolMessages ( toolMessages , i + 1 ); 233const isLastTurn = i + 1 + turnToolMsgs . length >= toolMessages . length ; 234 235sections . push ( 236 ...deriveTurnSections ( 237msg , 238turnToolMsgs , 239isLastTurn ?streamingToolCalls :[], 240isLastTurn && isStreaming 241) 242); 243 244i += 1 + turnToolMsgs . length ; 245} else { 246i ++ ; 247} 248} 249 250return sections ; 251} 252 253/** 254* Build the raw text representation shown in the "raw output" view of an 255* assistant message. Each section is formatted as it would appear in the 256* model-facing transcript, joined by blank lines. 257*/ 258export function buildAssistantRawOutput ( sections :AgenticSection []) :string { 259const parts :string [] = []; 260 261for ( const section of sections ) { 262switch ( section . type ) { 263case AgenticSectionType . REASONING : 264case AgenticSectionType . REASONING_PENDING : 265parts . push ( ` ${ REASONING_TAGS . START } ${ NEWLINE } ${ section . content } ${ REASONING_TAGS . END } ` ); 266 267break ; 268 269case AgenticSectionType . TEXT : 270parts . push ( section . content ); 271 272break ; 273 274case AgenticSectionType . TOOL_CALL : 275case AgenticSectionType . TOOL_CALL_PENDING : 276case AgenticSectionType . TOOL_CALL_STREAMING :{ 277const callObj :Record < string , unknown > = { name :section . toolName }; 278 279if ( section . toolArgs ) { 280try { 281callObj . arguments = JSON . parse ( section . toolArgs ); 282} catch { 283callObj . arguments = section . toolArgs ; 284} 285} 286 287parts . push ( JSON . stringify ( callObj , null , 2 )); 288 289if ( section . toolResult ) { 290parts . push ( ` ${ NEWLINE } ${ section . toolResult } ` ); 291} 292 293break ; 294} 295} 296} 297 298return parts . join ( ` ${ NEWLINE } ${ NEWLINE } ` ); 299} 300 301/** 302* Collect consecutive tool messages starting at `startIndex`. 303*/ 304function collectToolMessages ( messages :DatabaseMessage [], startIndex :number ) :DatabaseMessage [] { 305const result :DatabaseMessage [] = []; 306 307for ( let i = startIndex ; i < messages . length ; i ++ ) { 308if ( messages [ i ]. role === MessageRole . TOOL ) { 309result . push ( messages [ i ]); 310} else { 311break ; 312} 313} 314 315return result ; 316} 317 318/** 319* Split a tool-result blob into a list and an optional "Total matches: N" 320* summary. Both file-glob and grep tools emit this format on the server: 321* 322* <matches> 323* --- 324* Total matches: 42 325* 326* Returns the lines and exposes a callback for capturing the total so each 327* caller can stash it on its own meta type without taking a return-tuple. 328*/ 329export function splitSearchSummaryList ( 330text :string , 331captureTotal :( n :number ) => void 332) :{ lines :string [] } { 333const separatorIndex = text . indexOf ( SEARCH_SUMMARY . SEPARATOR ); 334const matchesText = separatorIndex === - 1 ?text :text . slice ( 0 , separatorIndex ); 335const summaryText = 336separatorIndex === - 1 ?'' :text . slice ( separatorIndex + SEARCH_SUMMARY . SEPARATOR . length ); 337const totalMatch = summaryText . match ( SEARCH_SUMMARY . TOTAL_REGEX ); 338 339if ( totalMatch ) { 340captureTotal ( parseInt ( totalMatch [ 1 ], 10 )); 341} 342 343const lines = matchesText 344. split ( NEWLINE ) 345. map (( line ) => line . trim ()) 346. filter (( line ) => line . length > 0 ); 347 348return { lines}; 349} 350 351/** Bounded cache for parseToolResultWithMedia results. */ 352const TOOL_RESULT_LINES_CACHE_MAX_SIZE = 32 ; 353const toolResultLinesCache = new Map < string , ToolResultLine []>(); 354 355/** 356* Parse tool result text into lines, matching media attachments (images and audio) by name. 357* Memoized: called per render during streaming on unchanged tool result 358* strings with unchanged extras. 359*/ 360export function parseToolResultWithMedia ( 361toolResult :string , 362extras ?:DatabaseMessageExtra [] 363) :ToolResultLine [] { 364// Cache key includes image attachment names so we recompute when 365// attachments change, even if the count stays the same. 366const imageNames = ( extras ?? []) 367. filter (( e ) :e isDatabaseMessageExtraImageFile => e . type === AttachmentType . IMAGE ) 368. map (( e ) => e . name ) 369. join ( NEWLINE ); 370const cacheKey = ` ${ imageNames } : ${ toolResult } ` ; 371const cached = toolResultLinesCache . get ( cacheKey ); 372 373if ( cached !== undefined ) return cached ; 374 375const lines = toolResult . split ( NEWLINE ); 376const result = lines . map (( line ) => { 377const match = line . match ( ATTACHMENT_SAVED_REGEX ); 378 379if ( ! match || ! extras ) return { text :line }; 380 381const attachmentName = match [ 1 ]; 382const media = extras . find ( 383( e ) :e isDatabaseMessageExtraImageFile | DatabaseMessageExtraAudioFile => 384( e . type === AttachmentType . IMAGE || e . type === AttachmentType . AUDIO ) && 385e . name === attachmentName 386); 387 388return { media, text :line }; 389}); 390 391if ( toolResultLinesCache . size >= TOOL_RESULT_LINES_CACHE_MAX_SIZE ) { 392toolResultLinesCache . delete ( toolResultLinesCache . keys (). next (). value ! ); 393} 394 395toolResultLinesCache . set ( cacheKey , result ); 396 397return result ; 398} 399 400/** Bounded cache for classifyToolResult results. */ 401const CLASSIFY_CACHE_MAX_SIZE = 32 ; 402const classifyCache = new Map < string , ToolResultKind >(); 403 404/** 405* Pick a renderer tier for a tool's result content. 406* 407* json - trimmed content starts with `{` or `[` and parses cleanly. 408* markdown - content shows structural markdown markers (headers, code 409* fences, links, lists, blockquotes, tables) and should render 410* through MarkdownContent for proper formatting. 411* text - everything else, rendered as plain text lines (with image 412* attachment resolution as a side effect). 413* Memoized: called per render during streaming on unchanged content. 414*/ 415export function classifyToolResult ( content :string | undefined ) :ToolResultKind { 416if ( ! content ) return ToolResultKind . TEXT ; 417 418const cached = classifyCache . get ( content ); 419 420if ( cached !== undefined ) return cached ; 421 422const trimmed = content . trim (); 423 424if ( ! trimmed ) return ToolResultKind . TEXT ; 425 426let result :ToolResultKind = ToolResultKind . TEXT ; 427 428// Strongest signal: JSON object/array round-trips through JSON.parse. 429if ( TOOL_RESULT_JSON_OPEN_REGEX . test ( trimmed )) { 430try { 431JSON . parse ( trimmed ); 432result = ToolResultKind . JSON ; 433} catch ( error ) { 434console . error ( '[agentic] tool result looked like JSON but failed to parse:' , error ); 435} 436} 437 438if ( result === ToolResultKind . TEXT && looksLikeMarkdown ( trimmed )) { 439result = ToolResultKind . MARKDOWN ; 440} 441 442if ( classifyCache . size >= CLASSIFY_CACHE_MAX_SIZE ) { 443classifyCache . delete ( classifyCache . keys (). next (). value ! ); 444} 445 446classifyCache . set ( content , result ); 447 448return result ; 449} 450 451/** 452* Heuristic detector for "is this content a markdown document rather than 453* plain text?". True when at least one well-known structural marker shows 454* up - headers, code fences, links, bold, lists, blockquotes, tables. 455* Each marker is specific enough that plain tool-output prose rarely 456* trips it, but plain text starting with `# 5` will - acceptable false 457* positive for the gain in formatting for tool results like search 458* summaries that come back already-mardown. 459*/ 460function looksLikeMarkdown ( content :string ) :boolean { 461// Code fences are unambiguous - triple backticks or tildes at line start. 462if ( MARKDOWN . CODE_FENCE_REGEX . test ( content )) return true ; 463 464const lines = content . split ( NEWLINE ); 465 466for ( const line of lines ) { 467if ( MARKDOWN . ATX_HEADING_REGEX . test ( line )) return true ; 468 469if ( MARKDOWN . BLOCKQUOTE_REGEX . test ( line )) return true ; 470 471if ( MARKDOWN . LIST_BULLET_REGEX . test ( line )) return true ; 472 473if ( MARKDOWN . LIST_NUMBERED_REGEX . test ( line )) return true ; 474} 475 476// Inline structural markers anywhere in the body. 477if ( MARKDOWN . LINK_REGEX . test ( content )) return true ; 478 479if ( MARKDOWN . BOLD_REGEX . test ( content )) return true ; 480 481// Tables: a pipe-bearing header line followed by a separator row. 482if ( lines . length >= 2 ) { 483const head = lines [ 0 ]; 484const sep = lines [ 1 ]; 485 486if ( head . includes ( '|' ) && MARKDOWN . TABLE_SEPARATOR_REGEX . test ( sep )) return true ; 487} 488 489return false ; 490} 491 492/** Bounded cache for parsed tool-call JSON blobs. */ 493const TOOL_CALLS_CACHE_MAX_SIZE = 64 ; 494const toolCallsParseCache = new Map < string , ApiChatCompletionToolCall []>(); 495 496/** 497* Safely parse the toolCalls JSON string from a DatabaseMessage. 498* Memoized: the same JSON string is re-parsed on every render during 499* streaming, which is wasted CPU since tool calls don't change mid-stream. 500*/ 501function parseToolCalls ( toolCallsJson ?:string ) :ApiChatCompletionToolCall [] { 502if ( ! toolCallsJson ) return []; 503 504const cached = toolCallsParseCache . get ( toolCallsJson ); 505 506if ( cached ) return cached ; 507 508let result :ApiChatCompletionToolCall []; 509 510try { 511const parsed = JSON . parse ( toolCallsJson ); 512 513result = Array . isArray ( parsed ) ?parsed :[]; 514} catch { 515result = []; 516} 517 518if ( toolCallsParseCache . size >= TOOL_CALLS_CACHE_MAX_SIZE ) { 519toolCallsParseCache . delete ( toolCallsParseCache . keys (). next (). value ! ); 520} 521 522toolCallsParseCache . set ( toolCallsJson , result ); 523 524return result ; 525} 526 527/** 528* Check if a message has agentic content (tool calls or is part of an agentic flow). 529*/ 530export function hasAgenticContent ( 531message :DatabaseMessage , 532toolMessages :DatabaseMessage [] = [] 533) :boolean { 534if ( message . toolCalls ) { 535const tc = parseToolCalls ( message . toolCalls ); 536 537if ( tc . length > 0 ) return true ; 538} 539 540return toolMessages . length > 0 ; 541} 542 543/** 544* Decide how a Continue click on messages[idx] should resume generation. 545* Pure function over the persisted history snapshot. 546*/ 547export function classifyContinueIntent ( messages :DatabaseMessage [], idx :number ) :ContinueIntent { 548const target = messages [ idx ]; 549 550// Defensive default: callers already filter by role, stay deterministic. 551if ( ! target || target . role !== MessageRole . ASSISTANT ) { 552return { kind :ContinueIntentKind . APPEND_TEXT }; 553} 554 555const hasToolCalls = parseToolCalls ( target . toolCalls ). length > 0 ; 556 557if ( ! hasToolCalls ) { 558return { kind :ContinueIntentKind . APPEND_TEXT }; 559} 560 561// Walk consecutive trailing tool results. The agentic loop only emits tool 562// messages directly after the assistant turn that owns them, so the first 563// non tool message marks the boundary. 564let lastTrailingTool = idx ; 565 566for ( let i = idx + 1 ; i < messages . length ; i ++ ) { 567if ( messages [ i ]. role === MessageRole . TOOL ) { 568lastTrailingTool = i ; 569} else { 570break ; 571} 572} 573 574if ( lastTrailingTool > idx ) { 575return { kind :ContinueIntentKind . NEXT_TURN , truncateAfter :lastTrailingTool }; 576} 577 578return { kind :ContinueIntentKind . RERUN_TURN , truncateAfter :idx - 1 }; 579}