Files
puaros/packages/ipuaro/src/infrastructure/llm/ResponseParser.ts
imfozilbek 68f927d906 feat(ipuaro): add LLM integration module
- OllamaClient: ILLMClient implementation with tool support
- System prompt and context builders for project overview
- 18 tool definitions across 6 categories (read, edit, search, analysis, git, run)
- XML response parser for tool call extraction
- 98 new tests (419 total), 96.38% coverage
2025-12-01 00:10:11 +05:00

221 lines
5.6 KiB
TypeScript

import { createToolCall, type ToolCall } from "../../domain/value-objects/ToolCall.js"
/**
* Parsed response from LLM.
*/
export interface ParsedResponse {
/** Text content (excluding tool calls) */
content: string
/** Extracted tool calls */
toolCalls: ToolCall[]
/** Whether parsing encountered issues */
hasParseErrors: boolean
/** Parse error messages */
parseErrors: string[]
}
/**
* XML tool call tag pattern.
* Matches: <tool_call name="tool_name">...</tool_call>
*/
const TOOL_CALL_REGEX = /<tool_call\s+name\s*=\s*"([^"]+)">([\s\S]*?)<\/tool_call>/gi
/**
* XML parameter tag pattern.
* Matches: <param name="param_name">value</param> or <param_name>value</param_name>
*/
const PARAM_REGEX_NAMED = /<param\s+name\s*=\s*"([^"]+)">([\s\S]*?)<\/param>/gi
const PARAM_REGEX_ELEMENT = /<([a-z_][a-z0-9_]*)>([\s\S]*?)<\/\1>/gi
/**
* Parse tool calls from LLM response text.
* Supports XML format: <tool_call name="get_lines"><path>src/index.ts</path></tool_call>
*/
export function parseToolCalls(response: string): ParsedResponse {
const toolCalls: ToolCall[] = []
const parseErrors: string[] = []
let content = response
const matches = [...response.matchAll(TOOL_CALL_REGEX)]
for (const match of matches) {
const [fullMatch, toolName, paramsXml] = match
try {
const params = parseParameters(paramsXml)
const toolCall = createToolCall(
`xml_${String(Date.now())}_${String(toolCalls.length)}`,
toolName,
params,
)
toolCalls.push(toolCall)
content = content.replace(fullMatch, "")
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
parseErrors.push(`Failed to parse tool call "${toolName}": ${errorMsg}`)
}
}
content = content.trim()
return {
content,
toolCalls,
hasParseErrors: parseErrors.length > 0,
parseErrors,
}
}
/**
* Parse parameters from XML content.
*/
function parseParameters(xml: string): Record<string, unknown> {
const params: Record<string, unknown> = {}
const namedMatches = [...xml.matchAll(PARAM_REGEX_NAMED)]
for (const match of namedMatches) {
const [, name, value] = match
params[name] = parseValue(value)
}
if (namedMatches.length === 0) {
const elementMatches = [...xml.matchAll(PARAM_REGEX_ELEMENT)]
for (const match of elementMatches) {
const [, name, value] = match
params[name] = parseValue(value)
}
}
return params
}
/**
* Parse a value string to appropriate type.
*/
function parseValue(value: string): unknown {
const trimmed = value.trim()
if (trimmed === "true") {
return true
}
if (trimmed === "false") {
return false
}
if (trimmed === "null") {
return null
}
const num = Number(trimmed)
if (!isNaN(num) && trimmed !== "") {
return num
}
if (
(trimmed.startsWith("[") && trimmed.endsWith("]")) ||
(trimmed.startsWith("{") && trimmed.endsWith("}"))
) {
try {
return JSON.parse(trimmed)
} catch {
return trimmed
}
}
return trimmed
}
/**
* Format tool calls to XML for prompt injection.
* Useful when you need to show the LLM the expected format.
*/
export function formatToolCallsAsXml(toolCalls: ToolCall[]): string {
return toolCalls
.map((tc) => {
const params = Object.entries(tc.params)
.map(([key, value]) => ` <${key}>${formatValueForXml(value)}</${key}>`)
.join("\n")
return `<tool_call name="${tc.name}">\n${params}\n</tool_call>`
})
.join("\n\n")
}
/**
* Format a value for XML output.
*/
function formatValueForXml(value: unknown): string {
if (value === null || value === undefined) {
return ""
}
if (typeof value === "object") {
return JSON.stringify(value)
}
if (typeof value === "string") {
return value
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value)
}
return JSON.stringify(value)
}
/**
* Extract thinking/reasoning from response.
* Matches content between <thinking>...</thinking> tags.
*/
export function extractThinking(response: string): { thinking: string; content: string } {
const thinkingRegex = /<thinking>([\s\S]*?)<\/thinking>/gi
const matches = [...response.matchAll(thinkingRegex)]
if (matches.length === 0) {
return { thinking: "", content: response }
}
let content = response
const thoughts: string[] = []
for (const match of matches) {
thoughts.push(match[1].trim())
content = content.replace(match[0], "")
}
return {
thinking: thoughts.join("\n\n"),
content: content.trim(),
}
}
/**
* Check if response contains tool calls.
*/
export function hasToolCalls(response: string): boolean {
return TOOL_CALL_REGEX.test(response)
}
/**
* Validate tool call parameters against expected schema.
*/
export function validateToolCallParams(
toolName: string,
params: Record<string, unknown>,
requiredParams: string[],
): { valid: boolean; errors: string[] } {
const errors: string[] = []
for (const param of requiredParams) {
if (!(param in params) || params[param] === undefined || params[param] === null) {
errors.push(`Missing required parameter: ${param}`)
}
}
return {
valid: errors.length === 0,
errors,
}
}