TdAI / llama.cpp /tools /ui /src /lib /utils /agentic.ts
tda45's picture
Upload folder using huggingface_hub (part 8)
15c3607 verified
Raw
History Blame Contribute Delete
9.15 kB
import { AgenticSectionType, ContinueIntentKind, MessageRole } from '$lib/enums';
import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import type {
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraImageFile
} from '$lib/types/database';
import { AttachmentType } from '$lib/enums';
/**
* Represents a parsed section of agentic content for display
*/
export interface AgenticSection {
type: AgenticSectionType;
content: string;
toolName?: string;
toolArgs?: string;
toolResult?: string;
toolResultExtras?: DatabaseMessageExtra[];
wasInterrupted?: boolean;
}
/**
* Represents a tool result line that may reference an image attachment
*/
export type ToolResultLine = {
text: string;
image?: DatabaseMessageExtraImageFile;
};
/**
* Derives display sections from a single assistant message and its direct tool results.
*
* @param message - The assistant message
* @param toolMessages - Tool result messages for this assistant's tool_calls
* @param streamingToolCalls - Partial tool calls during streaming (not yet persisted)
*/
function deriveSingleTurnSections(
message: DatabaseMessage,
toolMessages: DatabaseMessage[] = [],
streamingToolCalls: ApiChatCompletionToolCall[] = [],
isStreaming: boolean = false
): AgenticSection[] {
const sections: AgenticSection[] = [];
// 1. Reasoning content (from dedicated field)
if (message.reasoningContent) {
const toolCalls = parseToolCalls(message.toolCalls);
const hasContentAfterReasoning =
!!message.content?.trim() || toolCalls.length > 0 || streamingToolCalls.length > 0;
const isPending = isStreaming && !hasContentAfterReasoning;
sections.push({
type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
content: message.reasoningContent,
wasInterrupted: !isStreaming && !hasContentAfterReasoning
});
}
// 2. Text content
if (message.content?.trim()) {
sections.push({
type: AgenticSectionType.TEXT,
content: message.content
});
}
// 3. Persisted tool calls (from message.toolCalls field)
const toolCalls = parseToolCalls(message.toolCalls);
for (const tc of toolCalls) {
const resultMsg = toolMessages.find((m) => m.toolCallId === tc.id);
// Only show as pending/loading if we're actively streaming; otherwise it's just a tool call without result
const type = resultMsg
? AgenticSectionType.TOOL_CALL
: isStreaming
? AgenticSectionType.TOOL_CALL_PENDING
: AgenticSectionType.TOOL_CALL;
sections.push({
type,
content: resultMsg?.content || '',
toolName: tc.function?.name,
toolArgs: tc.function?.arguments,
toolResult: resultMsg?.content,
toolResultExtras: resultMsg?.extra
});
}
// 4. Streaming tool calls (not yet persisted - currently being received)
for (const tc of streamingToolCalls) {
// Skip if already in persisted tool calls
if (tc.id && toolCalls.find((t) => t.id === tc.id)) continue;
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: tc.function?.name,
toolArgs: tc.function?.arguments
});
}
return sections;
}
/**
* Derives display sections from structured message data.
*
* Handles both single-turn (one assistant + its tool results) and multi-turn
* agentic sessions (multiple assistant + tool messages grouped together).
*
* When `toolMessages` contains continuation assistant messages (from multi-turn
* agentic flows), they are processed in order to produce sections across all turns.
*
* @param message - The first/anchor assistant message
* @param toolMessages - Tool result messages and continuation assistant messages
* @param streamingToolCalls - Partial tool calls during streaming (not yet persisted)
* @param isStreaming - Whether the message is currently being streamed
*/
export function deriveAgenticSections(
message: DatabaseMessage,
toolMessages: DatabaseMessage[] = [],
streamingToolCalls: ApiChatCompletionToolCall[] = [],
isStreaming: boolean = false
): AgenticSection[] {
const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT);
if (!hasAssistantContinuations) {
return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
}
const sections: AgenticSection[] = [];
const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
let i = firstTurnToolMsgs.length;
while (i < toolMessages.length) {
const msg = toolMessages[i];
if (msg.role === MessageRole.ASSISTANT) {
const turnToolMsgs = collectToolMessages(toolMessages, i + 1);
const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length;
sections.push(
...deriveSingleTurnSections(
msg,
turnToolMsgs,
isLastTurn ? streamingToolCalls : [],
isLastTurn && isStreaming
)
);
i += 1 + turnToolMsgs.length;
} else {
i++;
}
}
return sections;
}
/**
* Collect consecutive tool messages starting at `startIndex`.
*/
function collectToolMessages(messages: DatabaseMessage[], startIndex: number): DatabaseMessage[] {
const result: DatabaseMessage[] = [];
for (let i = startIndex; i < messages.length; i++) {
if (messages[i].role === MessageRole.TOOL) {
result.push(messages[i]);
} else {
break;
}
}
return result;
}
/**
* Parse tool result text into lines, matching image attachments by name.
*/
export function parseToolResultWithImages(
toolResult: string,
extras?: DatabaseMessageExtra[]
): ToolResultLine[] {
const lines = toolResult.split(NEWLINE_SEPARATOR);
return lines.map((line) => {
const match = line.match(ATTACHMENT_SAVED_REGEX);
if (!match || !extras) return { text: line };
const attachmentName = match[1];
const image = extras.find(
(e): e is DatabaseMessageExtraImageFile =>
e.type === AttachmentType.IMAGE && e.name === attachmentName
);
return { text: line, image };
});
}
/**
* Safely parse the toolCalls JSON string from a DatabaseMessage.
*/
function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] {
if (!toolCallsJson) return [];
try {
const parsed = JSON.parse(toolCallsJson);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/**
* Check if a message has agentic content (tool calls or is part of an agentic flow).
*/
export function hasAgenticContent(
message: DatabaseMessage,
toolMessages: DatabaseMessage[] = []
): boolean {
if (message.toolCalls) {
const tc = parseToolCalls(message.toolCalls);
if (tc.length > 0) return true;
}
return toolMessages.length > 0;
}
/**
* Classification of how a Continue click on an assistant message should resume
* generation. The caller dispatches the resume path based on this value.
*
* append_text -> the target is a plain text turn, resume with
* continue_final_message and rehydrate the persisted
* tool_calls and attachments through the regular DB to API
* message converter.
* rerun_turn -> the target carries tool_calls that were never resolved by
* tool result messages. The agentic stream was cut mid turn,
* so we drop the target and rerun the loop from the previous
* history. truncateAfter is the last kept index, inclusive.
* next_turn -> the target's tool_calls were already resolved by trailing
* tool results. Hand the history up to and including the
* last consecutive tool result back to the agentic loop so it
* starts the next turn naturally. truncateAfter points at
* that last tool result.
*/
export type ContinueIntent =
| { kind: ContinueIntentKind.APPEND_TEXT }
| { kind: ContinueIntentKind.RERUN_TURN; truncateAfter: number }
| { kind: ContinueIntentKind.NEXT_TURN; truncateAfter: number };
/**
* Decide how a Continue click on messages[idx] should resume generation.
* Pure function over the persisted history snapshot.
*/
export function classifyContinueIntent(messages: DatabaseMessage[], idx: number): ContinueIntent {
const target = messages[idx];
// Defensive default: callers already filter by role, stay deterministic.
if (!target || target.role !== MessageRole.ASSISTANT) {
return { kind: ContinueIntentKind.APPEND_TEXT };
}
const hasToolCalls = parseToolCalls(target.toolCalls).length > 0;
if (!hasToolCalls) {
return { kind: ContinueIntentKind.APPEND_TEXT };
}
// Walk consecutive trailing tool results. The agentic loop only emits tool
// messages directly after the assistant turn that owns them, so the first
// non tool message marks the boundary.
let lastTrailingTool = idx;
for (let i = idx + 1; i < messages.length; i++) {
if (messages[i].role === MessageRole.TOOL) {
lastTrailingTool = i;
} else {
break;
}
}
if (lastTrailingTool > idx) {
return { kind: ContinueIntentKind.NEXT_TURN, truncateAfter: lastTrailingTool };
}
return { kind: ContinueIntentKind.RERUN_TURN, truncateAfter: idx - 1 };
}