mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
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
This commit is contained in:
@@ -5,6 +5,58 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.4.0] - 2025-11-30 - LLM Integration
|
||||
|
||||
### Added
|
||||
|
||||
- **OllamaClient (0.4.1)**
|
||||
- Full `ILLMClient` implementation for Ollama SDK
|
||||
- Chat completion with tool/function calling support
|
||||
- Token counting via estimation (Ollama has no tokenizer API)
|
||||
- Model management: `pullModel()`, `hasModel()`, `listModels()`
|
||||
- Connection status check: `isAvailable()`
|
||||
- Request abortion support: `abort()`
|
||||
- Error handling with `IpuaroError` for connection and model errors
|
||||
- 21 unit tests
|
||||
|
||||
- **System Prompt & Context Builder (0.4.2)**
|
||||
- `SYSTEM_PROMPT`: Comprehensive agent instructions with tool descriptions
|
||||
- `buildInitialContext()`: Generates compact project overview from structure and ASTs
|
||||
- `buildFileContext()`: Detailed file context with imports, exports, functions, classes
|
||||
- `truncateContext()`: Token-aware context truncation
|
||||
- Hub/entry point/complexity flags in file summaries
|
||||
- 17 unit tests
|
||||
|
||||
- **Tool Definitions (0.4.3)**
|
||||
- 18 tool definitions across 6 categories:
|
||||
- Read: `get_lines`, `get_function`, `get_class`, `get_structure`
|
||||
- Edit: `edit_lines`, `create_file`, `delete_file`
|
||||
- Search: `find_references`, `find_definition`
|
||||
- Analysis: `get_dependencies`, `get_dependents`, `get_complexity`, `get_todos`
|
||||
- Git: `git_status`, `git_diff`, `git_commit`
|
||||
- Run: `run_command`, `run_tests`
|
||||
- Category groupings: `READ_TOOLS`, `EDIT_TOOLS`, etc.
|
||||
- `CONFIRMATION_TOOLS` set for tools requiring user approval
|
||||
- Helper functions: `requiresConfirmation()`, `getToolDef()`, `getToolsByCategory()`
|
||||
- 39 unit tests
|
||||
|
||||
- **Response Parser (0.4.4)**
|
||||
- XML tool call parsing: `<tool_call name="...">...</tool_call>`
|
||||
- Parameter extraction from XML elements
|
||||
- Type coercion: boolean, number, null, JSON arrays/objects
|
||||
- `extractThinking()`: Extracts `<thinking>...</thinking>` blocks
|
||||
- `hasToolCalls()`: Quick check for tool call presence
|
||||
- `validateToolCallParams()`: Parameter validation against required list
|
||||
- `formatToolCallsAsXml()`: Tool calls to XML for prompt injection
|
||||
- 21 unit tests
|
||||
|
||||
### Changed
|
||||
|
||||
- Total tests: 419 (was 321)
|
||||
- Coverage: 96.38%
|
||||
|
||||
---
|
||||
|
||||
## [0.3.1] - 2025-11-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@samiyev/ipuaro",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"description": "Local AI agent for codebase operations with infinite context feeling",
|
||||
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Infrastructure layer exports
|
||||
export * from "./storage/index.js"
|
||||
export * from "./indexer/index.js"
|
||||
export * from "./llm/index.js"
|
||||
|
||||
302
packages/ipuaro/src/infrastructure/llm/OllamaClient.ts
Normal file
302
packages/ipuaro/src/infrastructure/llm/OllamaClient.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { type Message, Ollama, type Tool } from "ollama"
|
||||
import type {
|
||||
ILLMClient,
|
||||
LLMResponse,
|
||||
ToolDef,
|
||||
ToolParameter,
|
||||
} from "../../domain/services/ILLMClient.js"
|
||||
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
|
||||
import { createToolCall, type ToolCall } from "../../domain/value-objects/ToolCall.js"
|
||||
import type { LLMConfig } from "../../shared/constants/config.js"
|
||||
import { IpuaroError } from "../../shared/errors/IpuaroError.js"
|
||||
import { estimateTokens } from "../../shared/utils/tokens.js"
|
||||
|
||||
/**
|
||||
* Ollama LLM client implementation.
|
||||
* Wraps the Ollama SDK for chat completions with tool support.
|
||||
*/
|
||||
export class OllamaClient implements ILLMClient {
|
||||
private readonly client: Ollama
|
||||
private readonly host: string
|
||||
private readonly model: string
|
||||
private readonly contextWindow: number
|
||||
private readonly temperature: number
|
||||
private readonly timeout: number
|
||||
private abortController: AbortController | null = null
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
this.host = config.host
|
||||
this.client = new Ollama({ host: this.host })
|
||||
this.model = config.model
|
||||
this.contextWindow = config.contextWindow
|
||||
this.temperature = config.temperature
|
||||
this.timeout = config.timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* Send messages to LLM and get response.
|
||||
*/
|
||||
async chat(messages: ChatMessage[], tools?: ToolDef[]): Promise<LLMResponse> {
|
||||
const startTime = Date.now()
|
||||
this.abortController = new AbortController()
|
||||
|
||||
try {
|
||||
const ollamaMessages = this.convertMessages(messages)
|
||||
const ollamaTools = tools ? this.convertTools(tools) : undefined
|
||||
|
||||
const response = await this.client.chat({
|
||||
model: this.model,
|
||||
messages: ollamaMessages,
|
||||
tools: ollamaTools,
|
||||
options: {
|
||||
temperature: this.temperature,
|
||||
},
|
||||
stream: false,
|
||||
})
|
||||
|
||||
const timeMs = Date.now() - startTime
|
||||
const toolCalls = this.extractToolCalls(response.message)
|
||||
|
||||
return {
|
||||
content: response.message.content,
|
||||
toolCalls,
|
||||
tokens: response.eval_count ?? estimateTokens(response.message.content),
|
||||
timeMs,
|
||||
truncated: false,
|
||||
stopReason: this.determineStopReason(response, toolCalls),
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw IpuaroError.llm("Request was aborted")
|
||||
}
|
||||
throw this.handleError(error)
|
||||
} finally {
|
||||
this.abortController = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tokens in text.
|
||||
* Uses estimation since Ollama doesn't provide a tokenizer endpoint.
|
||||
*/
|
||||
async countTokens(text: string): Promise<number> {
|
||||
return Promise.resolve(estimateTokens(text))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if LLM service is available.
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await this.client.list()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current model name.
|
||||
*/
|
||||
getModelName(): string {
|
||||
return this.model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context window size.
|
||||
*/
|
||||
getContextWindowSize(): number {
|
||||
return this.contextWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull/download model if not available locally.
|
||||
*/
|
||||
async pullModel(model: string): Promise<void> {
|
||||
try {
|
||||
await this.client.pull({ model, stream: false })
|
||||
} catch (error) {
|
||||
throw this.handleError(error, `Failed to pull model: ${model}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific model is available locally.
|
||||
*/
|
||||
async hasModel(model: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.client.list()
|
||||
return result.models.some((m) => m.name === model || m.name.startsWith(`${model}:`))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available models.
|
||||
*/
|
||||
async listModels(): Promise<string[]> {
|
||||
try {
|
||||
const result = await this.client.list()
|
||||
return result.models.map((m) => m.name)
|
||||
} catch (error) {
|
||||
throw this.handleError(error, "Failed to list models")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort current generation.
|
||||
*/
|
||||
abort(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ChatMessage array to Ollama Message format.
|
||||
*/
|
||||
private convertMessages(messages: ChatMessage[]): Message[] {
|
||||
return messages.map((msg): Message => {
|
||||
const role = this.convertRole(msg.role)
|
||||
|
||||
if (msg.role === "tool" && msg.toolResults) {
|
||||
return {
|
||||
role: "tool",
|
||||
content: msg.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.role === "assistant" && msg.toolCalls && msg.toolCalls.length > 0) {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: msg.content,
|
||||
tool_calls: msg.toolCalls.map((tc) => ({
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: tc.params,
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
role,
|
||||
content: msg.content,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert message role to Ollama role.
|
||||
*/
|
||||
private convertRole(role: ChatMessage["role"]): "user" | "assistant" | "system" | "tool" {
|
||||
switch (role) {
|
||||
case "user":
|
||||
return "user"
|
||||
case "assistant":
|
||||
return "assistant"
|
||||
case "system":
|
||||
return "system"
|
||||
case "tool":
|
||||
return "tool"
|
||||
default:
|
||||
return "user"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ToolDef array to Ollama Tool format.
|
||||
*/
|
||||
private convertTools(tools: ToolDef[]): Tool[] {
|
||||
return tools.map(
|
||||
(tool): Tool => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: this.convertParameters(tool.parameters),
|
||||
required: tool.parameters.filter((p) => p.required).map((p) => p.name),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ToolParameter array to JSON Schema properties.
|
||||
*/
|
||||
private convertParameters(
|
||||
params: ToolParameter[],
|
||||
): Record<string, { type: string; description: string; enum?: string[] }> {
|
||||
const properties: Record<string, { type: string; description: string; enum?: string[] }> =
|
||||
{}
|
||||
|
||||
for (const param of params) {
|
||||
properties[param.name] = {
|
||||
type: param.type,
|
||||
description: param.description,
|
||||
...(param.enum && { enum: param.enum }),
|
||||
}
|
||||
}
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool calls from Ollama response message.
|
||||
*/
|
||||
private extractToolCalls(message: Message): ToolCall[] {
|
||||
if (!message.tool_calls || message.tool_calls.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return message.tool_calls.map((tc, index) =>
|
||||
createToolCall(
|
||||
`call_${String(Date.now())}_${String(index)}`,
|
||||
tc.function.name,
|
||||
tc.function.arguments,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine stop reason from response.
|
||||
*/
|
||||
private determineStopReason(
|
||||
response: { done_reason?: string },
|
||||
toolCalls: ToolCall[],
|
||||
): "end" | "length" | "tool_use" {
|
||||
if (toolCalls.length > 0) {
|
||||
return "tool_use"
|
||||
}
|
||||
|
||||
if (response.done_reason === "length") {
|
||||
return "length"
|
||||
}
|
||||
|
||||
return "end"
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle and wrap errors.
|
||||
*/
|
||||
private handleError(error: unknown, context?: string): IpuaroError {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const fullMessage = context ? `${context}: ${message}` : message
|
||||
|
||||
if (message.includes("ECONNREFUSED") || message.includes("fetch failed")) {
|
||||
return IpuaroError.llm(`Cannot connect to Ollama at ${this.host}`)
|
||||
}
|
||||
|
||||
if (message.includes("model") && message.includes("not found")) {
|
||||
return IpuaroError.llm(
|
||||
`Model "${this.model}" not found. Run: ollama pull ${this.model}`,
|
||||
)
|
||||
}
|
||||
|
||||
return IpuaroError.llm(fullMessage)
|
||||
}
|
||||
}
|
||||
220
packages/ipuaro/src/infrastructure/llm/ResponseParser.ts
Normal file
220
packages/ipuaro/src/infrastructure/llm/ResponseParser.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
48
packages/ipuaro/src/infrastructure/llm/index.ts
Normal file
48
packages/ipuaro/src/infrastructure/llm/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// LLM infrastructure exports
|
||||
export { OllamaClient } from "./OllamaClient.js"
|
||||
export {
|
||||
SYSTEM_PROMPT,
|
||||
buildInitialContext,
|
||||
buildFileContext,
|
||||
truncateContext,
|
||||
type ProjectStructure,
|
||||
} from "./prompts.js"
|
||||
export {
|
||||
ALL_TOOLS,
|
||||
READ_TOOLS,
|
||||
EDIT_TOOLS,
|
||||
SEARCH_TOOLS,
|
||||
ANALYSIS_TOOLS,
|
||||
GIT_TOOLS,
|
||||
RUN_TOOLS,
|
||||
CONFIRMATION_TOOLS,
|
||||
requiresConfirmation,
|
||||
getToolDef,
|
||||
getToolsByCategory,
|
||||
GET_LINES_TOOL,
|
||||
GET_FUNCTION_TOOL,
|
||||
GET_CLASS_TOOL,
|
||||
GET_STRUCTURE_TOOL,
|
||||
EDIT_LINES_TOOL,
|
||||
CREATE_FILE_TOOL,
|
||||
DELETE_FILE_TOOL,
|
||||
FIND_REFERENCES_TOOL,
|
||||
FIND_DEFINITION_TOOL,
|
||||
GET_DEPENDENCIES_TOOL,
|
||||
GET_DEPENDENTS_TOOL,
|
||||
GET_COMPLEXITY_TOOL,
|
||||
GET_TODOS_TOOL,
|
||||
GIT_STATUS_TOOL,
|
||||
GIT_DIFF_TOOL,
|
||||
GIT_COMMIT_TOOL,
|
||||
RUN_COMMAND_TOOL,
|
||||
RUN_TESTS_TOOL,
|
||||
} from "./toolDefs.js"
|
||||
export {
|
||||
parseToolCalls,
|
||||
formatToolCallsAsXml,
|
||||
extractThinking,
|
||||
hasToolCalls,
|
||||
validateToolCallParams,
|
||||
type ParsedResponse,
|
||||
} from "./ResponseParser.js"
|
||||
335
packages/ipuaro/src/infrastructure/llm/prompts.ts
Normal file
335
packages/ipuaro/src/infrastructure/llm/prompts.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { FileAST } from "../../domain/value-objects/FileAST.js"
|
||||
import type { FileMeta } from "../../domain/value-objects/FileMeta.js"
|
||||
|
||||
/**
|
||||
* Project structure for context building.
|
||||
*/
|
||||
export interface ProjectStructure {
|
||||
name: string
|
||||
rootPath: string
|
||||
files: string[]
|
||||
directories: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt for the ipuaro AI agent.
|
||||
*/
|
||||
export const SYSTEM_PROMPT = `You are ipuaro, a local AI code assistant specialized in helping developers understand and modify their codebase. You operate within a single project directory and have access to powerful tools for reading, searching, analyzing, and editing code.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Lazy Loading**: You don't have the full code in context. Use tools to fetch exactly what you need.
|
||||
2. **Precision**: Always verify file paths and line numbers before making changes.
|
||||
3. **Safety**: Confirm destructive operations. Never execute dangerous commands.
|
||||
4. **Efficiency**: Minimize context usage. Request only necessary code sections.
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Reading Tools
|
||||
- \`get_lines\`: Get specific lines from a file
|
||||
- \`get_function\`: Get a function by name
|
||||
- \`get_class\`: Get a class by name
|
||||
- \`get_structure\`: Get project directory structure
|
||||
|
||||
### Editing Tools (require confirmation)
|
||||
- \`edit_lines\`: Replace specific lines in a file
|
||||
- \`create_file\`: Create a new file
|
||||
- \`delete_file\`: Delete a file
|
||||
|
||||
### Search Tools
|
||||
- \`find_references\`: Find all usages of a symbol
|
||||
- \`find_definition\`: Find where a symbol is defined
|
||||
|
||||
### Analysis Tools
|
||||
- \`get_dependencies\`: Get files this file imports
|
||||
- \`get_dependents\`: Get files that import this file
|
||||
- \`get_complexity\`: Get complexity metrics
|
||||
- \`get_todos\`: Find TODO/FIXME comments
|
||||
|
||||
### Git Tools
|
||||
- \`git_status\`: Get repository status
|
||||
- \`git_diff\`: Get uncommitted changes
|
||||
- \`git_commit\`: Create a commit (requires confirmation)
|
||||
|
||||
### Run Tools
|
||||
- \`run_command\`: Execute a shell command (security checked)
|
||||
- \`run_tests\`: Run the test suite
|
||||
|
||||
## Response Guidelines
|
||||
|
||||
1. **Be concise**: Don't repeat information already in context.
|
||||
2. **Show your work**: Explain what tools you're using and why.
|
||||
3. **Verify before editing**: Always read the target code before modifying it.
|
||||
4. **Handle errors gracefully**: If a tool fails, explain what went wrong and suggest alternatives.
|
||||
|
||||
## Code Editing Rules
|
||||
|
||||
1. Always use \`get_lines\` or \`get_function\` before \`edit_lines\`.
|
||||
2. Provide exact line numbers for edits.
|
||||
3. For large changes, break into multiple small edits.
|
||||
4. After editing, suggest running tests if available.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
1. Never execute commands that could harm the system.
|
||||
2. Never expose sensitive data (API keys, passwords).
|
||||
3. Always confirm file deletions and destructive git operations.
|
||||
4. Stay within the project directory.
|
||||
|
||||
When you need to perform an action, use the appropriate tool. Think step by step about what information you need and which tools will provide it most efficiently.`
|
||||
|
||||
/**
|
||||
* Build initial context from project structure and AST metadata.
|
||||
* Returns a compact representation without actual code.
|
||||
*/
|
||||
export function buildInitialContext(
|
||||
structure: ProjectStructure,
|
||||
asts: Map<string, FileAST>,
|
||||
metas?: Map<string, FileMeta>,
|
||||
): string {
|
||||
const sections: string[] = []
|
||||
|
||||
sections.push(formatProjectHeader(structure))
|
||||
sections.push(formatDirectoryTree(structure))
|
||||
sections.push(formatFileOverview(asts, metas))
|
||||
|
||||
return sections.join("\n\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format project header section.
|
||||
*/
|
||||
function formatProjectHeader(structure: ProjectStructure): string {
|
||||
const fileCount = String(structure.files.length)
|
||||
const dirCount = String(structure.directories.length)
|
||||
return `# Project: ${structure.name}
|
||||
Root: ${structure.rootPath}
|
||||
Files: ${fileCount} | Directories: ${dirCount}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format directory tree.
|
||||
*/
|
||||
function formatDirectoryTree(structure: ProjectStructure): string {
|
||||
const lines: string[] = ["## Structure", ""]
|
||||
|
||||
const sortedDirs = [...structure.directories].sort()
|
||||
for (const dir of sortedDirs) {
|
||||
const depth = dir.split("/").length - 1
|
||||
const indent = " ".repeat(depth)
|
||||
const name = dir.split("/").pop() ?? dir
|
||||
lines.push(`${indent}${name}/`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file overview with AST summaries.
|
||||
*/
|
||||
function formatFileOverview(asts: Map<string, FileAST>, metas?: Map<string, FileMeta>): string {
|
||||
const lines: string[] = ["## Files", ""]
|
||||
|
||||
const sortedPaths = [...asts.keys()].sort()
|
||||
for (const path of sortedPaths) {
|
||||
const ast = asts.get(path)
|
||||
if (!ast) {
|
||||
continue
|
||||
}
|
||||
|
||||
const meta = metas?.get(path)
|
||||
lines.push(formatFileSummary(path, ast, meta))
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single file's AST summary.
|
||||
*/
|
||||
function formatFileSummary(path: string, ast: FileAST, meta?: FileMeta): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (ast.functions.length > 0) {
|
||||
const names = ast.functions.map((f) => f.name).join(", ")
|
||||
parts.push(`fn: ${names}`)
|
||||
}
|
||||
|
||||
if (ast.classes.length > 0) {
|
||||
const names = ast.classes.map((c) => c.name).join(", ")
|
||||
parts.push(`class: ${names}`)
|
||||
}
|
||||
|
||||
if (ast.interfaces.length > 0) {
|
||||
const names = ast.interfaces.map((i) => i.name).join(", ")
|
||||
parts.push(`interface: ${names}`)
|
||||
}
|
||||
|
||||
if (ast.typeAliases.length > 0) {
|
||||
const names = ast.typeAliases.map((t) => t.name).join(", ")
|
||||
parts.push(`type: ${names}`)
|
||||
}
|
||||
|
||||
const summary = parts.length > 0 ? ` [${parts.join(" | ")}]` : ""
|
||||
const flags = formatFileFlags(meta)
|
||||
|
||||
return `- ${path}${summary}${flags}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file metadata flags.
|
||||
*/
|
||||
function formatFileFlags(meta?: FileMeta): string {
|
||||
if (!meta) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const flags: string[] = []
|
||||
|
||||
if (meta.isHub) {
|
||||
flags.push("hub")
|
||||
}
|
||||
|
||||
if (meta.isEntryPoint) {
|
||||
flags.push("entry")
|
||||
}
|
||||
|
||||
if (meta.complexity.score > 70) {
|
||||
flags.push("complex")
|
||||
}
|
||||
|
||||
return flags.length > 0 ? ` (${flags.join(", ")})` : ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Format line range for display.
|
||||
*/
|
||||
function formatLineRange(start: number, end: number): string {
|
||||
return `[${String(start)}-${String(end)}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format imports section.
|
||||
*/
|
||||
function formatImportsSection(ast: FileAST): string[] {
|
||||
if (ast.imports.length === 0) {
|
||||
return []
|
||||
}
|
||||
const lines = ["### Imports"]
|
||||
for (const imp of ast.imports) {
|
||||
lines.push(`- ${imp.name} from "${imp.from}" (${imp.type})`)
|
||||
}
|
||||
lines.push("")
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Format exports section.
|
||||
*/
|
||||
function formatExportsSection(ast: FileAST): string[] {
|
||||
if (ast.exports.length === 0) {
|
||||
return []
|
||||
}
|
||||
const lines = ["### Exports"]
|
||||
for (const exp of ast.exports) {
|
||||
const defaultMark = exp.isDefault ? " (default)" : ""
|
||||
lines.push(`- ${exp.kind} ${exp.name}${defaultMark}`)
|
||||
}
|
||||
lines.push("")
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Format functions section.
|
||||
*/
|
||||
function formatFunctionsSection(ast: FileAST): string[] {
|
||||
if (ast.functions.length === 0) {
|
||||
return []
|
||||
}
|
||||
const lines = ["### Functions"]
|
||||
for (const fn of ast.functions) {
|
||||
const params = fn.params.map((p) => p.name).join(", ")
|
||||
const asyncMark = fn.isAsync ? "async " : ""
|
||||
const range = formatLineRange(fn.lineStart, fn.lineEnd)
|
||||
lines.push(`- ${asyncMark}${fn.name}(${params}) ${range}`)
|
||||
}
|
||||
lines.push("")
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Format classes section.
|
||||
*/
|
||||
function formatClassesSection(ast: FileAST): string[] {
|
||||
if (ast.classes.length === 0) {
|
||||
return []
|
||||
}
|
||||
const lines = ["### Classes"]
|
||||
for (const cls of ast.classes) {
|
||||
const ext = cls.extends ? ` extends ${cls.extends}` : ""
|
||||
const impl = cls.implements.length > 0 ? ` implements ${cls.implements.join(", ")}` : ""
|
||||
const range = formatLineRange(cls.lineStart, cls.lineEnd)
|
||||
lines.push(`- ${cls.name}${ext}${impl} ${range}`)
|
||||
|
||||
for (const method of cls.methods) {
|
||||
const vis = method.visibility === "public" ? "" : `${method.visibility} `
|
||||
const methodRange = formatLineRange(method.lineStart, method.lineEnd)
|
||||
lines.push(` - ${vis}${method.name}() ${methodRange}`)
|
||||
}
|
||||
}
|
||||
lines.push("")
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Format metadata section.
|
||||
*/
|
||||
function formatMetadataSection(meta: FileMeta): string[] {
|
||||
const loc = String(meta.complexity.loc)
|
||||
const score = String(meta.complexity.score)
|
||||
const deps = String(meta.dependencies.length)
|
||||
const dependents = String(meta.dependents.length)
|
||||
return [
|
||||
"### Metadata",
|
||||
`- LOC: ${loc}`,
|
||||
`- Complexity: ${score}/100`,
|
||||
`- Dependencies: ${deps}`,
|
||||
`- Dependents: ${dependents}`,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context for a specific file request.
|
||||
*/
|
||||
export function buildFileContext(path: string, ast: FileAST, meta?: FileMeta): string {
|
||||
const lines: string[] = [`## ${path}`, ""]
|
||||
|
||||
lines.push(...formatImportsSection(ast))
|
||||
lines.push(...formatExportsSection(ast))
|
||||
lines.push(...formatFunctionsSection(ast))
|
||||
lines.push(...formatClassesSection(ast))
|
||||
|
||||
if (meta) {
|
||||
lines.push(...formatMetadataSection(meta))
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate context to fit within token budget.
|
||||
*/
|
||||
export function truncateContext(context: string, maxTokens: number): string {
|
||||
const charsPerToken = 4
|
||||
const maxChars = maxTokens * charsPerToken
|
||||
|
||||
if (context.length <= maxChars) {
|
||||
return context
|
||||
}
|
||||
|
||||
const truncated = context.slice(0, maxChars - 100)
|
||||
const lastNewline = truncated.lastIndexOf("\n")
|
||||
const remaining = String(context.length - lastNewline)
|
||||
|
||||
return `${truncated.slice(0, lastNewline)}\n\n... (truncated, ${remaining} chars remaining)`
|
||||
}
|
||||
511
packages/ipuaro/src/infrastructure/llm/toolDefs.ts
Normal file
511
packages/ipuaro/src/infrastructure/llm/toolDefs.ts
Normal file
@@ -0,0 +1,511 @@
|
||||
import type { ToolDef } from "../../domain/services/ILLMClient.js"
|
||||
|
||||
/**
|
||||
* Tool definitions for ipuaro LLM.
|
||||
* 18 tools across 6 categories: read, edit, search, analysis, git, run.
|
||||
*/
|
||||
|
||||
/*
|
||||
* =============================================================================
|
||||
* Read Tools (4)
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
export const GET_LINES_TOOL: ToolDef = {
|
||||
name: "get_lines",
|
||||
description:
|
||||
"Get specific lines from a file. Returns the content with line numbers. " +
|
||||
"If no range is specified, returns the entire file.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "start",
|
||||
type: "number",
|
||||
description: "Start line number (1-based, inclusive)",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "end",
|
||||
type: "number",
|
||||
description: "End line number (1-based, inclusive)",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const GET_FUNCTION_TOOL: ToolDef = {
|
||||
name: "get_function",
|
||||
description:
|
||||
"Get a function's source code by name. Uses AST to find exact line range. " +
|
||||
"Returns the function code with line numbers.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
type: "string",
|
||||
description: "Function name to retrieve",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const GET_CLASS_TOOL: ToolDef = {
|
||||
name: "get_class",
|
||||
description:
|
||||
"Get a class's source code by name. Uses AST to find exact line range. " +
|
||||
"Returns the class code with line numbers.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
type: "string",
|
||||
description: "Class name to retrieve",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const GET_STRUCTURE_TOOL: ToolDef = {
|
||||
name: "get_structure",
|
||||
description:
|
||||
"Get project directory structure as a tree. " +
|
||||
"If path is specified, shows structure of that subdirectory only.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "Subdirectory path relative to project root (optional, defaults to root)",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "depth",
|
||||
type: "number",
|
||||
description: "Maximum depth to traverse (default: unlimited)",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/*
|
||||
* =============================================================================
|
||||
* Edit Tools (3) - All require confirmation
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
export const EDIT_LINES_TOOL: ToolDef = {
|
||||
name: "edit_lines",
|
||||
description:
|
||||
"Replace lines in a file with new content. Requires reading the file first. " +
|
||||
"Will show diff and ask for confirmation before applying.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "start",
|
||||
type: "number",
|
||||
description: "Start line number (1-based, inclusive) to replace",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "end",
|
||||
type: "number",
|
||||
description: "End line number (1-based, inclusive) to replace",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "string",
|
||||
description: "New content to insert (can be multiple lines)",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const CREATE_FILE_TOOL: ToolDef = {
|
||||
name: "create_file",
|
||||
description:
|
||||
"Create a new file with specified content. " +
|
||||
"Will fail if file already exists. Will ask for confirmation.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "string",
|
||||
description: "File content",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const DELETE_FILE_TOOL: ToolDef = {
|
||||
name: "delete_file",
|
||||
description:
|
||||
"Delete a file from the project. " +
|
||||
"Will ask for confirmation. Previous content is saved to undo stack.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/*
|
||||
* =============================================================================
|
||||
* Search Tools (2)
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
export const FIND_REFERENCES_TOOL: ToolDef = {
|
||||
name: "find_references",
|
||||
description:
|
||||
"Find all usages of a symbol across the codebase. " +
|
||||
"Returns list of file paths, line numbers, and context.",
|
||||
parameters: [
|
||||
{
|
||||
name: "symbol",
|
||||
type: "string",
|
||||
description: "Symbol name to search for (function, class, variable, etc.)",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "Limit search to specific file or directory",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const FIND_DEFINITION_TOOL: ToolDef = {
|
||||
name: "find_definition",
|
||||
description:
|
||||
"Find where a symbol is defined. " + "Returns file path, line number, and symbol type.",
|
||||
parameters: [
|
||||
{
|
||||
name: "symbol",
|
||||
type: "string",
|
||||
description: "Symbol name to find definition for",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/*
|
||||
* =============================================================================
|
||||
* Analysis Tools (4)
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
export const GET_DEPENDENCIES_TOOL: ToolDef = {
|
||||
name: "get_dependencies",
|
||||
description:
|
||||
"Get files that this file imports (internal dependencies). " +
|
||||
"Returns list of imported file paths.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const GET_DEPENDENTS_TOOL: ToolDef = {
|
||||
name: "get_dependents",
|
||||
description:
|
||||
"Get files that import this file (reverse dependencies). " +
|
||||
"Returns list of file paths that depend on this file.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path relative to project root",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const GET_COMPLEXITY_TOOL: ToolDef = {
|
||||
name: "get_complexity",
|
||||
description:
|
||||
"Get complexity metrics for a file or the entire project. " +
|
||||
"Returns LOC, nesting depth, cyclomatic complexity, and overall score.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "File path (optional, defaults to all files sorted by complexity)",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "limit",
|
||||
type: "number",
|
||||
description: "Max files to return when showing all (default: 10)",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const GET_TODOS_TOOL: ToolDef = {
|
||||
name: "get_todos",
|
||||
description:
|
||||
"Find TODO, FIXME, HACK, and XXX comments in the codebase. " +
|
||||
"Returns list with file paths, line numbers, and comment text.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "Limit search to specific file or directory",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "string",
|
||||
description: "Filter by comment type",
|
||||
required: false,
|
||||
enum: ["TODO", "FIXME", "HACK", "XXX"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/*
|
||||
* =============================================================================
|
||||
* Git Tools (3)
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
export const GIT_STATUS_TOOL: ToolDef = {
|
||||
name: "git_status",
|
||||
description:
|
||||
"Get current git repository status. " +
|
||||
"Returns branch name, staged files, modified files, and untracked files.",
|
||||
parameters: [],
|
||||
}
|
||||
|
||||
export const GIT_DIFF_TOOL: ToolDef = {
|
||||
name: "git_diff",
|
||||
description:
|
||||
"Get uncommitted changes (diff). " + "Shows what has changed but not yet committed.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "Limit diff to specific file or directory",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "staged",
|
||||
type: "boolean",
|
||||
description: "Show only staged changes (default: false, shows all)",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const GIT_COMMIT_TOOL: ToolDef = {
|
||||
name: "git_commit",
|
||||
description:
|
||||
"Create a git commit with the specified message. " +
|
||||
"Will ask for confirmation. Optionally stage specific files first.",
|
||||
parameters: [
|
||||
{
|
||||
name: "message",
|
||||
type: "string",
|
||||
description: "Commit message",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "files",
|
||||
type: "array",
|
||||
description: "Files to stage before commit (optional, defaults to all staged)",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/*
|
||||
* =============================================================================
|
||||
* Run Tools (2)
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
export const RUN_COMMAND_TOOL: ToolDef = {
|
||||
name: "run_command",
|
||||
description:
|
||||
"Execute a shell command in the project directory. " +
|
||||
"Commands are checked against blacklist/whitelist for security. " +
|
||||
"Unknown commands require user confirmation.",
|
||||
parameters: [
|
||||
{
|
||||
name: "command",
|
||||
type: "string",
|
||||
description: "Shell command to execute",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
type: "number",
|
||||
description: "Timeout in milliseconds (default: 30000)",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const RUN_TESTS_TOOL: ToolDef = {
|
||||
name: "run_tests",
|
||||
description:
|
||||
"Run the project's test suite. Auto-detects test runner (vitest, jest, npm test). " +
|
||||
"Returns test results summary.",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "Run tests for specific file or directory",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "filter",
|
||||
type: "string",
|
||||
description: "Filter tests by name pattern",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "watch",
|
||||
type: "boolean",
|
||||
description: "Run in watch mode (default: false)",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/*
|
||||
* =============================================================================
|
||||
* Tool Collection
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* All read tools (no confirmation required).
|
||||
*/
|
||||
export const READ_TOOLS: ToolDef[] = [
|
||||
GET_LINES_TOOL,
|
||||
GET_FUNCTION_TOOL,
|
||||
GET_CLASS_TOOL,
|
||||
GET_STRUCTURE_TOOL,
|
||||
]
|
||||
|
||||
/**
|
||||
* All edit tools (require confirmation).
|
||||
*/
|
||||
export const EDIT_TOOLS: ToolDef[] = [EDIT_LINES_TOOL, CREATE_FILE_TOOL, DELETE_FILE_TOOL]
|
||||
|
||||
/**
|
||||
* All search tools (no confirmation required).
|
||||
*/
|
||||
export const SEARCH_TOOLS: ToolDef[] = [FIND_REFERENCES_TOOL, FIND_DEFINITION_TOOL]
|
||||
|
||||
/**
|
||||
* All analysis tools (no confirmation required).
|
||||
*/
|
||||
export const ANALYSIS_TOOLS: ToolDef[] = [
|
||||
GET_DEPENDENCIES_TOOL,
|
||||
GET_DEPENDENTS_TOOL,
|
||||
GET_COMPLEXITY_TOOL,
|
||||
GET_TODOS_TOOL,
|
||||
]
|
||||
|
||||
/**
|
||||
* All git tools (git_commit requires confirmation).
|
||||
*/
|
||||
export const GIT_TOOLS: ToolDef[] = [GIT_STATUS_TOOL, GIT_DIFF_TOOL, GIT_COMMIT_TOOL]
|
||||
|
||||
/**
|
||||
* All run tools (run_command may require confirmation).
|
||||
*/
|
||||
export const RUN_TOOLS: ToolDef[] = [RUN_COMMAND_TOOL, RUN_TESTS_TOOL]
|
||||
|
||||
/**
|
||||
* All 18 tool definitions.
|
||||
*/
|
||||
export const ALL_TOOLS: ToolDef[] = [
|
||||
...READ_TOOLS,
|
||||
...EDIT_TOOLS,
|
||||
...SEARCH_TOOLS,
|
||||
...ANALYSIS_TOOLS,
|
||||
...GIT_TOOLS,
|
||||
...RUN_TOOLS,
|
||||
]
|
||||
|
||||
/**
|
||||
* Tools that require user confirmation before execution.
|
||||
*/
|
||||
export const CONFIRMATION_TOOLS = new Set([
|
||||
"edit_lines",
|
||||
"create_file",
|
||||
"delete_file",
|
||||
"git_commit",
|
||||
])
|
||||
|
||||
/**
|
||||
* Check if a tool requires confirmation.
|
||||
*/
|
||||
export function requiresConfirmation(toolName: string): boolean {
|
||||
return CONFIRMATION_TOOLS.has(toolName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool definition by name.
|
||||
*/
|
||||
export function getToolDef(name: string): ToolDef | undefined {
|
||||
return ALL_TOOLS.find((t) => t.name === name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool definitions by category.
|
||||
*/
|
||||
export function getToolsByCategory(category: string): ToolDef[] {
|
||||
switch (category) {
|
||||
case "read":
|
||||
return READ_TOOLS
|
||||
case "edit":
|
||||
return EDIT_TOOLS
|
||||
case "search":
|
||||
return SEARCH_TOOLS
|
||||
case "analysis":
|
||||
return ANALYSIS_TOOLS
|
||||
case "git":
|
||||
return GIT_TOOLS
|
||||
case "run":
|
||||
return RUN_TOOLS
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import type { LLMConfig } from "../../../../src/shared/constants/config.js"
|
||||
import { IpuaroError } from "../../../../src/shared/errors/IpuaroError.js"
|
||||
import { createUserMessage } from "../../../../src/domain/value-objects/ChatMessage.js"
|
||||
|
||||
const mockChatResponse = {
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "This is a test response.",
|
||||
tool_calls: undefined,
|
||||
},
|
||||
eval_count: 50,
|
||||
done_reason: "stop",
|
||||
}
|
||||
|
||||
const mockListResponse = {
|
||||
models: [
|
||||
{ name: "qwen2.5-coder:7b-instruct", size: 4000000000 },
|
||||
{ name: "llama2:latest", size: 3500000000 },
|
||||
],
|
||||
}
|
||||
|
||||
const mockOllamaInstance = {
|
||||
chat: vi.fn(),
|
||||
list: vi.fn(),
|
||||
pull: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock("ollama", () => {
|
||||
return {
|
||||
Ollama: vi.fn(() => mockOllamaInstance),
|
||||
}
|
||||
})
|
||||
|
||||
const { OllamaClient } = await import("../../../../src/infrastructure/llm/OllamaClient.js")
|
||||
|
||||
describe("OllamaClient", () => {
|
||||
const defaultConfig: LLMConfig = {
|
||||
model: "qwen2.5-coder:7b-instruct",
|
||||
contextWindow: 128000,
|
||||
temperature: 0.1,
|
||||
host: "http://localhost:11434",
|
||||
timeout: 120000,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockOllamaInstance.chat.mockResolvedValue(mockChatResponse)
|
||||
mockOllamaInstance.list.mockResolvedValue(mockListResponse)
|
||||
mockOllamaInstance.pull.mockResolvedValue({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create instance with config", () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
expect(client).toBeDefined()
|
||||
expect(client.getModelName()).toBe("qwen2.5-coder:7b-instruct")
|
||||
expect(client.getContextWindowSize()).toBe(128000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("chat", () => {
|
||||
it("should send messages and return response", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [createUserMessage("Hello, world!")]
|
||||
|
||||
const response = await client.chat(messages)
|
||||
|
||||
expect(response.content).toBe("This is a test response.")
|
||||
expect(response.tokens).toBe(50)
|
||||
expect(response.stopReason).toBe("end")
|
||||
expect(response.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it("should convert messages to Ollama format", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [createUserMessage("Hello")]
|
||||
|
||||
await client.chat(messages)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "qwen2.5-coder:7b-instruct",
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should pass tools when provided", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [createUserMessage("Read file")]
|
||||
const tools = [
|
||||
{
|
||||
name: "get_lines",
|
||||
description: "Get lines from file",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
type: "string" as const,
|
||||
description: "File path",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
await client.chat(messages, tools)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "function",
|
||||
function: expect.objectContaining({
|
||||
name: "get_lines",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should extract tool calls from response", async () => {
|
||||
mockOllamaInstance.chat.mockResolvedValue({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "get_lines",
|
||||
arguments: { path: "src/index.ts" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
eval_count: 30,
|
||||
})
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const response = await client.chat([createUserMessage("Read file")])
|
||||
|
||||
expect(response.toolCalls).toHaveLength(1)
|
||||
expect(response.toolCalls[0].name).toBe("get_lines")
|
||||
expect(response.toolCalls[0].params).toEqual({ path: "src/index.ts" })
|
||||
expect(response.stopReason).toBe("tool_use")
|
||||
})
|
||||
|
||||
it("should handle connection errors", async () => {
|
||||
mockOllamaInstance.chat.mockRejectedValue(new Error("fetch failed"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(IpuaroError)
|
||||
})
|
||||
|
||||
it("should handle model not found errors", async () => {
|
||||
mockOllamaInstance.chat.mockRejectedValue(new Error("model not found"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(/not found/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("countTokens", () => {
|
||||
it("should estimate tokens for text", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const count = await client.countTokens("Hello, world!")
|
||||
|
||||
expect(count).toBeGreaterThan(0)
|
||||
expect(typeof count).toBe("number")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAvailable", () => {
|
||||
it("should return true when Ollama is available", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const available = await client.isAvailable()
|
||||
|
||||
expect(available).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when Ollama is not available", async () => {
|
||||
mockOllamaInstance.list.mockRejectedValue(new Error("Connection refused"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const available = await client.isAvailable()
|
||||
|
||||
expect(available).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModelName", () => {
|
||||
it("should return configured model name", () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
expect(client.getModelName()).toBe("qwen2.5-coder:7b-instruct")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getContextWindowSize", () => {
|
||||
it("should return configured context window size", () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
expect(client.getContextWindowSize()).toBe(128000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pullModel", () => {
|
||||
it("should pull model successfully", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.pullModel("llama2")).resolves.toBeUndefined()
|
||||
expect(mockOllamaInstance.pull).toHaveBeenCalledWith({
|
||||
model: "llama2",
|
||||
stream: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw on pull failure", async () => {
|
||||
mockOllamaInstance.pull.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.pullModel("llama2")).rejects.toThrow(IpuaroError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasModel", () => {
|
||||
it("should return true for available model", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const has = await client.hasModel("qwen2.5-coder:7b-instruct")
|
||||
|
||||
expect(has).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for model prefix", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const has = await client.hasModel("llama2")
|
||||
|
||||
expect(has).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for missing model", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const has = await client.hasModel("unknown-model")
|
||||
|
||||
expect(has).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when list fails", async () => {
|
||||
mockOllamaInstance.list.mockRejectedValue(new Error("Error"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const has = await client.hasModel("any-model")
|
||||
|
||||
expect(has).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listModels", () => {
|
||||
it("should return list of model names", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
const models = await client.listModels()
|
||||
|
||||
expect(models).toContain("qwen2.5-coder:7b-instruct")
|
||||
expect(models).toContain("llama2:latest")
|
||||
})
|
||||
|
||||
it("should throw on list failure", async () => {
|
||||
mockOllamaInstance.list.mockRejectedValue(new Error("Network error"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.listModels()).rejects.toThrow(IpuaroError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("abort", () => {
|
||||
it("should not throw when no request is in progress", () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
expect(() => client.abort()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
parseToolCalls,
|
||||
formatToolCallsAsXml,
|
||||
extractThinking,
|
||||
hasToolCalls,
|
||||
validateToolCallParams,
|
||||
} from "../../../../src/infrastructure/llm/ResponseParser.js"
|
||||
import { createToolCall } from "../../../../src/domain/value-objects/ToolCall.js"
|
||||
|
||||
describe("ResponseParser", () => {
|
||||
describe("parseToolCalls", () => {
|
||||
it("should parse a single tool call", () => {
|
||||
const response = `<tool_call name="get_lines">
|
||||
<path>src/index.ts</path>
|
||||
<start>1</start>
|
||||
<end>10</end>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls).toHaveLength(1)
|
||||
expect(result.toolCalls[0].name).toBe("get_lines")
|
||||
expect(result.toolCalls[0].params).toEqual({
|
||||
path: "src/index.ts",
|
||||
start: 1,
|
||||
end: 10,
|
||||
})
|
||||
expect(result.hasParseErrors).toBe(false)
|
||||
})
|
||||
|
||||
it("should parse multiple tool calls", () => {
|
||||
const response = `
|
||||
<tool_call name="get_lines">
|
||||
<path>src/a.ts</path>
|
||||
</tool_call>
|
||||
<tool_call name="get_function">
|
||||
<path>src/b.ts</path>
|
||||
<name>myFunc</name>
|
||||
</tool_call>
|
||||
`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls).toHaveLength(2)
|
||||
expect(result.toolCalls[0].name).toBe("get_lines")
|
||||
expect(result.toolCalls[1].name).toBe("get_function")
|
||||
})
|
||||
|
||||
it("should extract text content without tool calls", () => {
|
||||
const response = `Let me check the file.
|
||||
<tool_call name="get_lines">
|
||||
<path>src/index.ts</path>
|
||||
</tool_call>
|
||||
Here's what I found.`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.content).toContain("Let me check the file.")
|
||||
expect(result.content).toContain("Here's what I found.")
|
||||
expect(result.content).not.toContain("tool_call")
|
||||
})
|
||||
|
||||
it("should parse boolean values", () => {
|
||||
const response = `<tool_call name="git_diff">
|
||||
<staged>true</staged>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls[0].params.staged).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse null values", () => {
|
||||
const response = `<tool_call name="test">
|
||||
<value>null</value>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls[0].params.value).toBe(null)
|
||||
})
|
||||
|
||||
it("should parse JSON arrays", () => {
|
||||
const response = `<tool_call name="git_commit">
|
||||
<files>["a.ts", "b.ts"]</files>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls[0].params.files).toEqual(["a.ts", "b.ts"])
|
||||
})
|
||||
|
||||
it("should parse JSON objects", () => {
|
||||
const response = `<tool_call name="test">
|
||||
<config>{"key": "value"}</config>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls[0].params.config).toEqual({ key: "value" })
|
||||
})
|
||||
|
||||
it("should return empty array for response without tool calls", () => {
|
||||
const response = "This is just a regular response."
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls).toHaveLength(0)
|
||||
expect(result.content).toBe(response)
|
||||
})
|
||||
|
||||
it("should handle named param syntax", () => {
|
||||
const response = `<tool_call name="get_lines">
|
||||
<param name="path">src/index.ts</param>
|
||||
<param name="start">5</param>
|
||||
</tool_call>`
|
||||
|
||||
const result = parseToolCalls(response)
|
||||
|
||||
expect(result.toolCalls[0].params).toEqual({
|
||||
path: "src/index.ts",
|
||||
start: 5,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatToolCallsAsXml", () => {
|
||||
it("should format tool calls as XML", () => {
|
||||
const toolCalls = [
|
||||
createToolCall("1", "get_lines", { path: "src/index.ts", start: 1 }),
|
||||
]
|
||||
|
||||
const xml = formatToolCallsAsXml(toolCalls)
|
||||
|
||||
expect(xml).toContain('<tool_call name="get_lines">')
|
||||
expect(xml).toContain("<path>src/index.ts</path>")
|
||||
expect(xml).toContain("<start>1</start>")
|
||||
expect(xml).toContain("</tool_call>")
|
||||
})
|
||||
|
||||
it("should format multiple tool calls", () => {
|
||||
const toolCalls = [
|
||||
createToolCall("1", "get_lines", { path: "a.ts" }),
|
||||
createToolCall("2", "get_function", { path: "b.ts", name: "foo" }),
|
||||
]
|
||||
|
||||
const xml = formatToolCallsAsXml(toolCalls)
|
||||
|
||||
expect(xml).toContain('<tool_call name="get_lines">')
|
||||
expect(xml).toContain('<tool_call name="get_function">')
|
||||
})
|
||||
|
||||
it("should handle object values as JSON", () => {
|
||||
const toolCalls = [
|
||||
createToolCall("1", "test", { data: { key: "value" } }),
|
||||
]
|
||||
|
||||
const xml = formatToolCallsAsXml(toolCalls)
|
||||
|
||||
expect(xml).toContain('{"key":"value"}')
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractThinking", () => {
|
||||
it("should extract thinking content", () => {
|
||||
const response = `<thinking>Let me analyze this.</thinking>
|
||||
Here is the answer.`
|
||||
|
||||
const result = extractThinking(response)
|
||||
|
||||
expect(result.thinking).toBe("Let me analyze this.")
|
||||
expect(result.content).toContain("Here is the answer.")
|
||||
expect(result.content).not.toContain("thinking")
|
||||
})
|
||||
|
||||
it("should handle multiple thinking blocks", () => {
|
||||
const response = `<thinking>First thought.</thinking>
|
||||
Some content.
|
||||
<thinking>Second thought.</thinking>
|
||||
More content.`
|
||||
|
||||
const result = extractThinking(response)
|
||||
|
||||
expect(result.thinking).toContain("First thought.")
|
||||
expect(result.thinking).toContain("Second thought.")
|
||||
})
|
||||
|
||||
it("should return original content if no thinking", () => {
|
||||
const response = "Just a regular response."
|
||||
|
||||
const result = extractThinking(response)
|
||||
|
||||
expect(result.thinking).toBe("")
|
||||
expect(result.content).toBe(response)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasToolCalls", () => {
|
||||
it("should return true if response has tool calls", () => {
|
||||
const response = `<tool_call name="get_lines"><path>a.ts</path></tool_call>`
|
||||
|
||||
expect(hasToolCalls(response)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false if response has no tool calls", () => {
|
||||
const response = "Just text without tool calls."
|
||||
|
||||
expect(hasToolCalls(response)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateToolCallParams", () => {
|
||||
it("should return valid for complete params", () => {
|
||||
const params = { path: "src/index.ts", start: 1, end: 10 }
|
||||
const required = ["path", "start", "end"]
|
||||
|
||||
const result = validateToolCallParams("get_lines", params, required)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should return errors for missing required params", () => {
|
||||
const params = { path: "src/index.ts" }
|
||||
const required = ["path", "start", "end"]
|
||||
|
||||
const result = validateToolCallParams("get_lines", params, required)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toHaveLength(2)
|
||||
expect(result.errors).toContain("Missing required parameter: start")
|
||||
expect(result.errors).toContain("Missing required parameter: end")
|
||||
})
|
||||
|
||||
it("should treat null and undefined as missing", () => {
|
||||
const params = { path: null, start: undefined }
|
||||
const required = ["path", "start"]
|
||||
|
||||
const result = validateToolCallParams("test", params, required)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should accept empty required array", () => {
|
||||
const params = {}
|
||||
const required: string[] = []
|
||||
|
||||
const result = validateToolCallParams("git_status", params, required)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
278
packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts
Normal file
278
packages/ipuaro/tests/unit/infrastructure/llm/prompts.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
SYSTEM_PROMPT,
|
||||
buildInitialContext,
|
||||
buildFileContext,
|
||||
truncateContext,
|
||||
type ProjectStructure,
|
||||
} from "../../../../src/infrastructure/llm/prompts.js"
|
||||
import type { FileAST } from "../../../../src/domain/value-objects/FileAST.js"
|
||||
import type { FileMeta } from "../../../../src/domain/value-objects/FileMeta.js"
|
||||
|
||||
describe("prompts", () => {
|
||||
describe("SYSTEM_PROMPT", () => {
|
||||
it("should be a non-empty string", () => {
|
||||
expect(typeof SYSTEM_PROMPT).toBe("string")
|
||||
expect(SYSTEM_PROMPT.length).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
it("should contain core principles", () => {
|
||||
expect(SYSTEM_PROMPT).toContain("Lazy Loading")
|
||||
expect(SYSTEM_PROMPT).toContain("Precision")
|
||||
expect(SYSTEM_PROMPT).toContain("Safety")
|
||||
})
|
||||
|
||||
it("should list available tools", () => {
|
||||
expect(SYSTEM_PROMPT).toContain("get_lines")
|
||||
expect(SYSTEM_PROMPT).toContain("edit_lines")
|
||||
expect(SYSTEM_PROMPT).toContain("find_references")
|
||||
expect(SYSTEM_PROMPT).toContain("git_status")
|
||||
expect(SYSTEM_PROMPT).toContain("run_command")
|
||||
})
|
||||
|
||||
it("should include safety rules", () => {
|
||||
expect(SYSTEM_PROMPT).toContain("Safety Rules")
|
||||
expect(SYSTEM_PROMPT).toContain("Never execute commands that could harm")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildInitialContext", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "my-project",
|
||||
rootPath: "/home/user/my-project",
|
||||
files: ["src/index.ts", "src/utils.ts", "package.json"],
|
||||
directories: ["src", "tests"],
|
||||
}
|
||||
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"src/index.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [
|
||||
{
|
||||
name: "main",
|
||||
lineStart: 1,
|
||||
lineEnd: 10,
|
||||
params: [],
|
||||
isAsync: false,
|
||||
isExported: true,
|
||||
},
|
||||
],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"src/utils.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [
|
||||
{
|
||||
name: "Helper",
|
||||
lineStart: 1,
|
||||
lineEnd: 20,
|
||||
methods: [],
|
||||
properties: [],
|
||||
implements: [],
|
||||
isExported: true,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
it("should include project header", () => {
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("# Project: my-project")
|
||||
expect(context).toContain("Root: /home/user/my-project")
|
||||
expect(context).toContain("Files: 3")
|
||||
expect(context).toContain("Directories: 2")
|
||||
})
|
||||
|
||||
it("should include directory structure", () => {
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("## Structure")
|
||||
expect(context).toContain("src/")
|
||||
expect(context).toContain("tests/")
|
||||
})
|
||||
|
||||
it("should include file overview with AST summaries", () => {
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("## Files")
|
||||
expect(context).toContain("src/index.ts")
|
||||
expect(context).toContain("fn: main")
|
||||
expect(context).toContain("src/utils.ts")
|
||||
expect(context).toContain("class: Helper")
|
||||
})
|
||||
|
||||
it("should include file flags from metadata", () => {
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"src/index.ts",
|
||||
{
|
||||
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 75 },
|
||||
dependencies: [],
|
||||
dependents: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"],
|
||||
isHub: true,
|
||||
isEntryPoint: true,
|
||||
fileType: "source",
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts, metas)
|
||||
|
||||
expect(context).toContain("(hub, entry, complex)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildFileContext", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [
|
||||
{ name: "fs", from: "node:fs", line: 1, type: "builtin", isDefault: false },
|
||||
{ name: "helper", from: "./helper", line: 2, type: "internal", isDefault: true },
|
||||
],
|
||||
exports: [
|
||||
{ name: "main", line: 10, isDefault: false, kind: "function" },
|
||||
{ name: "Config", line: 20, isDefault: true, kind: "class" },
|
||||
],
|
||||
functions: [
|
||||
{
|
||||
name: "main",
|
||||
lineStart: 10,
|
||||
lineEnd: 30,
|
||||
params: [
|
||||
{ name: "args", optional: false, hasDefault: false },
|
||||
{ name: "options", optional: true, hasDefault: false },
|
||||
],
|
||||
isAsync: true,
|
||||
isExported: true,
|
||||
},
|
||||
],
|
||||
classes: [
|
||||
{
|
||||
name: "Config",
|
||||
lineStart: 40,
|
||||
lineEnd: 80,
|
||||
methods: [
|
||||
{
|
||||
name: "load",
|
||||
lineStart: 50,
|
||||
lineEnd: 60,
|
||||
params: [],
|
||||
isAsync: false,
|
||||
visibility: "public",
|
||||
isStatic: false,
|
||||
},
|
||||
],
|
||||
properties: [],
|
||||
extends: "BaseConfig",
|
||||
implements: ["IConfig"],
|
||||
isExported: true,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
it("should include file path header", () => {
|
||||
const context = buildFileContext("src/index.ts", ast)
|
||||
|
||||
expect(context).toContain("## src/index.ts")
|
||||
})
|
||||
|
||||
it("should include imports section", () => {
|
||||
const context = buildFileContext("src/index.ts", ast)
|
||||
|
||||
expect(context).toContain("### Imports")
|
||||
expect(context).toContain('fs from "node:fs" (builtin)')
|
||||
expect(context).toContain('helper from "./helper" (internal)')
|
||||
})
|
||||
|
||||
it("should include exports section", () => {
|
||||
const context = buildFileContext("src/index.ts", ast)
|
||||
|
||||
expect(context).toContain("### Exports")
|
||||
expect(context).toContain("function main")
|
||||
expect(context).toContain("class Config (default)")
|
||||
})
|
||||
|
||||
it("should include functions section", () => {
|
||||
const context = buildFileContext("src/index.ts", ast)
|
||||
|
||||
expect(context).toContain("### Functions")
|
||||
expect(context).toContain("async main(args, options)")
|
||||
expect(context).toContain("[10-30]")
|
||||
})
|
||||
|
||||
it("should include classes section with methods", () => {
|
||||
const context = buildFileContext("src/index.ts", ast)
|
||||
|
||||
expect(context).toContain("### Classes")
|
||||
expect(context).toContain("Config extends BaseConfig implements IConfig")
|
||||
expect(context).toContain("[40-80]")
|
||||
expect(context).toContain("load()")
|
||||
})
|
||||
|
||||
it("should include metadata section when provided", () => {
|
||||
const meta: FileMeta = {
|
||||
complexity: { loc: 100, nesting: 3, cyclomaticComplexity: 10, score: 65 },
|
||||
dependencies: ["a.ts", "b.ts"],
|
||||
dependents: ["c.ts"],
|
||||
isHub: false,
|
||||
isEntryPoint: true,
|
||||
fileType: "source",
|
||||
}
|
||||
|
||||
const context = buildFileContext("src/index.ts", ast, meta)
|
||||
|
||||
expect(context).toContain("### Metadata")
|
||||
expect(context).toContain("LOC: 100")
|
||||
expect(context).toContain("Complexity: 65/100")
|
||||
expect(context).toContain("Dependencies: 2")
|
||||
expect(context).toContain("Dependents: 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("truncateContext", () => {
|
||||
it("should return original context if within limit", () => {
|
||||
const context = "Short context"
|
||||
|
||||
const result = truncateContext(context, 1000)
|
||||
|
||||
expect(result).toBe(context)
|
||||
})
|
||||
|
||||
it("should truncate long context", () => {
|
||||
const context = "a".repeat(1000)
|
||||
|
||||
const result = truncateContext(context, 100)
|
||||
|
||||
expect(result.length).toBeLessThan(500)
|
||||
expect(result).toContain("truncated")
|
||||
})
|
||||
|
||||
it("should break at newline boundary", () => {
|
||||
const context = "Line 1\nLine 2\nLine 3\n" + "a".repeat(1000)
|
||||
|
||||
const result = truncateContext(context, 50)
|
||||
|
||||
expect(result).toContain("truncated")
|
||||
})
|
||||
})
|
||||
})
|
||||
287
packages/ipuaro/tests/unit/infrastructure/llm/toolDefs.test.ts
Normal file
287
packages/ipuaro/tests/unit/infrastructure/llm/toolDefs.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
ALL_TOOLS,
|
||||
READ_TOOLS,
|
||||
EDIT_TOOLS,
|
||||
SEARCH_TOOLS,
|
||||
ANALYSIS_TOOLS,
|
||||
GIT_TOOLS,
|
||||
RUN_TOOLS,
|
||||
CONFIRMATION_TOOLS,
|
||||
requiresConfirmation,
|
||||
getToolDef,
|
||||
getToolsByCategory,
|
||||
GET_LINES_TOOL,
|
||||
GET_FUNCTION_TOOL,
|
||||
GET_CLASS_TOOL,
|
||||
GET_STRUCTURE_TOOL,
|
||||
EDIT_LINES_TOOL,
|
||||
CREATE_FILE_TOOL,
|
||||
DELETE_FILE_TOOL,
|
||||
FIND_REFERENCES_TOOL,
|
||||
FIND_DEFINITION_TOOL,
|
||||
GET_DEPENDENCIES_TOOL,
|
||||
GET_DEPENDENTS_TOOL,
|
||||
GET_COMPLEXITY_TOOL,
|
||||
GET_TODOS_TOOL,
|
||||
GIT_STATUS_TOOL,
|
||||
GIT_DIFF_TOOL,
|
||||
GIT_COMMIT_TOOL,
|
||||
RUN_COMMAND_TOOL,
|
||||
RUN_TESTS_TOOL,
|
||||
} from "../../../../src/infrastructure/llm/toolDefs.js"
|
||||
|
||||
describe("toolDefs", () => {
|
||||
describe("ALL_TOOLS", () => {
|
||||
it("should contain exactly 18 tools", () => {
|
||||
expect(ALL_TOOLS).toHaveLength(18)
|
||||
})
|
||||
|
||||
it("should have unique tool names", () => {
|
||||
const names = ALL_TOOLS.map((t) => t.name)
|
||||
const uniqueNames = new Set(names)
|
||||
expect(uniqueNames.size).toBe(18)
|
||||
})
|
||||
|
||||
it("should have valid structure for all tools", () => {
|
||||
for (const tool of ALL_TOOLS) {
|
||||
expect(tool.name).toBeDefined()
|
||||
expect(typeof tool.name).toBe("string")
|
||||
expect(tool.description).toBeDefined()
|
||||
expect(typeof tool.description).toBe("string")
|
||||
expect(Array.isArray(tool.parameters)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("READ_TOOLS", () => {
|
||||
it("should contain 4 read tools", () => {
|
||||
expect(READ_TOOLS).toHaveLength(4)
|
||||
})
|
||||
|
||||
it("should include all read tools", () => {
|
||||
expect(READ_TOOLS).toContain(GET_LINES_TOOL)
|
||||
expect(READ_TOOLS).toContain(GET_FUNCTION_TOOL)
|
||||
expect(READ_TOOLS).toContain(GET_CLASS_TOOL)
|
||||
expect(READ_TOOLS).toContain(GET_STRUCTURE_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("EDIT_TOOLS", () => {
|
||||
it("should contain 3 edit tools", () => {
|
||||
expect(EDIT_TOOLS).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should include all edit tools", () => {
|
||||
expect(EDIT_TOOLS).toContain(EDIT_LINES_TOOL)
|
||||
expect(EDIT_TOOLS).toContain(CREATE_FILE_TOOL)
|
||||
expect(EDIT_TOOLS).toContain(DELETE_FILE_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SEARCH_TOOLS", () => {
|
||||
it("should contain 2 search tools", () => {
|
||||
expect(SEARCH_TOOLS).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should include all search tools", () => {
|
||||
expect(SEARCH_TOOLS).toContain(FIND_REFERENCES_TOOL)
|
||||
expect(SEARCH_TOOLS).toContain(FIND_DEFINITION_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ANALYSIS_TOOLS", () => {
|
||||
it("should contain 4 analysis tools", () => {
|
||||
expect(ANALYSIS_TOOLS).toHaveLength(4)
|
||||
})
|
||||
|
||||
it("should include all analysis tools", () => {
|
||||
expect(ANALYSIS_TOOLS).toContain(GET_DEPENDENCIES_TOOL)
|
||||
expect(ANALYSIS_TOOLS).toContain(GET_DEPENDENTS_TOOL)
|
||||
expect(ANALYSIS_TOOLS).toContain(GET_COMPLEXITY_TOOL)
|
||||
expect(ANALYSIS_TOOLS).toContain(GET_TODOS_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GIT_TOOLS", () => {
|
||||
it("should contain 3 git tools", () => {
|
||||
expect(GIT_TOOLS).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should include all git tools", () => {
|
||||
expect(GIT_TOOLS).toContain(GIT_STATUS_TOOL)
|
||||
expect(GIT_TOOLS).toContain(GIT_DIFF_TOOL)
|
||||
expect(GIT_TOOLS).toContain(GIT_COMMIT_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("RUN_TOOLS", () => {
|
||||
it("should contain 2 run tools", () => {
|
||||
expect(RUN_TOOLS).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should include all run tools", () => {
|
||||
expect(RUN_TOOLS).toContain(RUN_COMMAND_TOOL)
|
||||
expect(RUN_TOOLS).toContain(RUN_TESTS_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe("individual tool definitions", () => {
|
||||
describe("GET_LINES_TOOL", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(GET_LINES_TOOL.name).toBe("get_lines")
|
||||
})
|
||||
|
||||
it("should have required path parameter", () => {
|
||||
const pathParam = GET_LINES_TOOL.parameters.find((p) => p.name === "path")
|
||||
expect(pathParam).toBeDefined()
|
||||
expect(pathParam?.required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have optional start and end parameters", () => {
|
||||
const startParam = GET_LINES_TOOL.parameters.find((p) => p.name === "start")
|
||||
const endParam = GET_LINES_TOOL.parameters.find((p) => p.name === "end")
|
||||
expect(startParam?.required).toBe(false)
|
||||
expect(endParam?.required).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("EDIT_LINES_TOOL", () => {
|
||||
it("should have all required parameters", () => {
|
||||
const requiredParams = EDIT_LINES_TOOL.parameters.filter((p) => p.required)
|
||||
const names = requiredParams.map((p) => p.name)
|
||||
expect(names).toContain("path")
|
||||
expect(names).toContain("start")
|
||||
expect(names).toContain("end")
|
||||
expect(names).toContain("content")
|
||||
})
|
||||
})
|
||||
|
||||
describe("GIT_STATUS_TOOL", () => {
|
||||
it("should have no required parameters", () => {
|
||||
expect(GIT_STATUS_TOOL.parameters).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET_TODOS_TOOL", () => {
|
||||
it("should have enum for type parameter", () => {
|
||||
const typeParam = GET_TODOS_TOOL.parameters.find((p) => p.name === "type")
|
||||
expect(typeParam?.enum).toEqual(["TODO", "FIXME", "HACK", "XXX"])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("CONFIRMATION_TOOLS", () => {
|
||||
it("should be a Set", () => {
|
||||
expect(CONFIRMATION_TOOLS instanceof Set).toBe(true)
|
||||
})
|
||||
|
||||
it("should contain edit and git_commit tools", () => {
|
||||
expect(CONFIRMATION_TOOLS.has("edit_lines")).toBe(true)
|
||||
expect(CONFIRMATION_TOOLS.has("create_file")).toBe(true)
|
||||
expect(CONFIRMATION_TOOLS.has("delete_file")).toBe(true)
|
||||
expect(CONFIRMATION_TOOLS.has("git_commit")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not contain read tools", () => {
|
||||
expect(CONFIRMATION_TOOLS.has("get_lines")).toBe(false)
|
||||
expect(CONFIRMATION_TOOLS.has("get_function")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("requiresConfirmation", () => {
|
||||
it("should return true for edit tools", () => {
|
||||
expect(requiresConfirmation("edit_lines")).toBe(true)
|
||||
expect(requiresConfirmation("create_file")).toBe(true)
|
||||
expect(requiresConfirmation("delete_file")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true for git_commit", () => {
|
||||
expect(requiresConfirmation("git_commit")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for read tools", () => {
|
||||
expect(requiresConfirmation("get_lines")).toBe(false)
|
||||
expect(requiresConfirmation("get_function")).toBe(false)
|
||||
expect(requiresConfirmation("get_structure")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for analysis tools", () => {
|
||||
expect(requiresConfirmation("get_dependencies")).toBe(false)
|
||||
expect(requiresConfirmation("get_complexity")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for unknown tools", () => {
|
||||
expect(requiresConfirmation("unknown_tool")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getToolDef", () => {
|
||||
it("should return tool definition by name", () => {
|
||||
const tool = getToolDef("get_lines")
|
||||
expect(tool).toBe(GET_LINES_TOOL)
|
||||
})
|
||||
|
||||
it("should return undefined for unknown tool", () => {
|
||||
const tool = getToolDef("unknown_tool")
|
||||
expect(tool).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should find all 18 tools", () => {
|
||||
const names = [
|
||||
"get_lines",
|
||||
"get_function",
|
||||
"get_class",
|
||||
"get_structure",
|
||||
"edit_lines",
|
||||
"create_file",
|
||||
"delete_file",
|
||||
"find_references",
|
||||
"find_definition",
|
||||
"get_dependencies",
|
||||
"get_dependents",
|
||||
"get_complexity",
|
||||
"get_todos",
|
||||
"git_status",
|
||||
"git_diff",
|
||||
"git_commit",
|
||||
"run_command",
|
||||
"run_tests",
|
||||
]
|
||||
|
||||
for (const name of names) {
|
||||
expect(getToolDef(name)).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getToolsByCategory", () => {
|
||||
it("should return read tools", () => {
|
||||
expect(getToolsByCategory("read")).toBe(READ_TOOLS)
|
||||
})
|
||||
|
||||
it("should return edit tools", () => {
|
||||
expect(getToolsByCategory("edit")).toBe(EDIT_TOOLS)
|
||||
})
|
||||
|
||||
it("should return search tools", () => {
|
||||
expect(getToolsByCategory("search")).toBe(SEARCH_TOOLS)
|
||||
})
|
||||
|
||||
it("should return analysis tools", () => {
|
||||
expect(getToolsByCategory("analysis")).toBe(ANALYSIS_TOOLS)
|
||||
})
|
||||
|
||||
it("should return git tools", () => {
|
||||
expect(getToolsByCategory("git")).toBe(GIT_TOOLS)
|
||||
})
|
||||
|
||||
it("should return run tools", () => {
|
||||
expect(getToolsByCategory("run")).toBe(RUN_TOOLS)
|
||||
})
|
||||
|
||||
it("should return empty array for unknown category", () => {
|
||||
expect(getToolsByCategory("unknown")).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user