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:
imfozilbek
2025-12-01 00:10:11 +05:00
parent b3e04a411c
commit 68f927d906
12 changed files with 2594 additions and 1 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -1,3 +1,4 @@
// Infrastructure layer exports
export * from "./storage/index.js"
export * from "./indexer/index.js"
export * from "./llm/index.js"

View 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)
}
}

View 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,
}
}

View 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"

View 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)`
}

View 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 []
}
}

View File

@@ -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()
})
})
})

View File

@@ -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)
})
})
})

View 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")
})
})
})

View 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([])
})
})
})