update
This commit is contained in:
@@ -2,6 +2,16 @@ import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import type { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* Options for configuring the DriverAgent
|
||||
*/
|
||||
export interface IDriverAgentOptions {
|
||||
/** Custom system message for the driver */
|
||||
systemMessage?: string;
|
||||
/** Maximum history messages to pass to API (default: 20). Set to 0 for unlimited. */
|
||||
maxHistoryMessages?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DriverAgent - Executes tasks by reasoning and proposing tool calls
|
||||
* Works in conjunction with GuardianAgent for approval
|
||||
@@ -9,15 +19,24 @@ import type { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
export class DriverAgent {
|
||||
private provider: plugins.smartai.MultiModalModel;
|
||||
private systemMessage: string;
|
||||
private maxHistoryMessages: number;
|
||||
private messageHistory: plugins.smartai.ChatMessage[] = [];
|
||||
private tools: Map<string, BaseToolWrapper> = new Map();
|
||||
|
||||
constructor(
|
||||
provider: plugins.smartai.MultiModalModel,
|
||||
systemMessage?: string
|
||||
options?: IDriverAgentOptions | string
|
||||
) {
|
||||
this.provider = provider;
|
||||
this.systemMessage = systemMessage || this.getDefaultSystemMessage();
|
||||
|
||||
// Support both legacy string systemMessage and new options object
|
||||
if (typeof options === 'string') {
|
||||
this.systemMessage = options || this.getDefaultSystemMessage();
|
||||
this.maxHistoryMessages = 20;
|
||||
} else {
|
||||
this.systemMessage = options?.systemMessage || this.getDefaultSystemMessage();
|
||||
this.maxHistoryMessages = options?.maxHistoryMessages ?? 20;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,8 +124,20 @@ export class DriverAgent {
|
||||
fullSystemMessage = this.getNoToolsSystemMessage();
|
||||
}
|
||||
|
||||
// Get response from provider (pass all but last user message as history)
|
||||
const historyForChat = this.messageHistory.slice(0, -1);
|
||||
// Get response from provider with history windowing
|
||||
// Keep original task and most recent messages to avoid token explosion
|
||||
let historyForChat: plugins.smartai.ChatMessage[];
|
||||
const fullHistory = this.messageHistory.slice(0, -1); // Exclude the just-added message
|
||||
|
||||
if (this.maxHistoryMessages > 0 && fullHistory.length > this.maxHistoryMessages) {
|
||||
// Keep the original task (first message) and most recent messages
|
||||
historyForChat = [
|
||||
fullHistory[0], // Original task
|
||||
...fullHistory.slice(-(this.maxHistoryMessages - 1)), // Recent messages
|
||||
];
|
||||
} else {
|
||||
historyForChat = fullHistory;
|
||||
}
|
||||
|
||||
const response = await this.provider.chat({
|
||||
systemMessage: fullSystemMessage,
|
||||
|
||||
@@ -30,6 +30,8 @@ export class DualAgentOrchestrator {
|
||||
maxIterations: 20,
|
||||
maxConsecutiveRejections: 3,
|
||||
defaultProvider: 'openai',
|
||||
maxResultChars: 15000,
|
||||
maxHistoryMessages: 20,
|
||||
...options,
|
||||
};
|
||||
|
||||
@@ -260,10 +262,29 @@ export class DualAgentOrchestrator {
|
||||
try {
|
||||
const result = await tool.execute(proposal.action, proposal.params);
|
||||
|
||||
// Send result to driver
|
||||
const resultMessage = result.success
|
||||
? `TOOL RESULT (${proposal.toolName}.${proposal.action}):\n${JSON.stringify(result.result, null, 2)}`
|
||||
: `TOOL ERROR (${proposal.toolName}.${proposal.action}):\n${result.error}`;
|
||||
// Build result message (prefer summary if provided, otherwise stringify result)
|
||||
let resultMessage: string;
|
||||
if (result.success) {
|
||||
if (result.summary) {
|
||||
// Use tool-provided summary
|
||||
resultMessage = `TOOL RESULT (${proposal.toolName}.${proposal.action}):\n${result.summary}`;
|
||||
} else {
|
||||
// Stringify and potentially truncate
|
||||
const resultStr = JSON.stringify(result.result, null, 2);
|
||||
const maxChars = this.options.maxResultChars ?? 15000;
|
||||
|
||||
if (maxChars > 0 && resultStr.length > maxChars) {
|
||||
// Truncate the result
|
||||
const truncated = resultStr.substring(0, maxChars);
|
||||
const omittedTokens = Math.round((resultStr.length - maxChars) / 4);
|
||||
resultMessage = `TOOL RESULT (${proposal.toolName}.${proposal.action}):\n${truncated}\n\n[... output truncated, ~${omittedTokens} tokens omitted. Use more specific parameters to reduce output size.]`;
|
||||
} else {
|
||||
resultMessage = `TOOL RESULT (${proposal.toolName}.${proposal.action}):\n${resultStr}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resultMessage = `TOOL ERROR (${proposal.toolName}.${proposal.action}):\n${result.error}`;
|
||||
}
|
||||
|
||||
this.conversationHistory.push({
|
||||
role: 'system',
|
||||
|
||||
@@ -26,6 +26,10 @@ export interface IDualAgentOptions extends plugins.smartai.ISmartAiOptions {
|
||||
maxConsecutiveRejections?: number;
|
||||
/** Enable verbose logging */
|
||||
verbose?: boolean;
|
||||
/** Maximum characters for tool result output before truncation (default: 15000). Set to 0 to disable. */
|
||||
maxResultChars?: number;
|
||||
/** Maximum history messages to pass to API (default: 20). Set to 0 for unlimited. */
|
||||
maxHistoryMessages?: number;
|
||||
}
|
||||
|
||||
// ================================
|
||||
@@ -84,6 +88,8 @@ export interface IToolExecutionResult {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
/** Optional human-readable summary for history (if provided, used instead of full result) */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,17 +46,25 @@ export class FilesystemTool extends BaseToolWrapper {
|
||||
public actions: interfaces.IToolAction[] = [
|
||||
{
|
||||
name: 'read',
|
||||
description: 'Read the contents of a file',
|
||||
description: 'Read file contents (full or specific line range)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Absolute path to the file' },
|
||||
path: { type: 'string', description: 'Path to the file' },
|
||||
encoding: {
|
||||
type: 'string',
|
||||
enum: ['utf8', 'binary', 'base64'],
|
||||
default: 'utf8',
|
||||
description: 'File encoding',
|
||||
},
|
||||
startLine: {
|
||||
type: 'number',
|
||||
description: 'First line to read (1-indexed, inclusive). If omitted, reads from beginning.',
|
||||
},
|
||||
endLine: {
|
||||
type: 'number',
|
||||
description: 'Last line to read (1-indexed, inclusive). If omitted, reads to end.',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
@@ -182,6 +190,55 @@ export class FilesystemTool extends BaseToolWrapper {
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tree',
|
||||
description: 'Show directory structure as a tree (no file contents)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Root directory path' },
|
||||
maxDepth: {
|
||||
type: 'number',
|
||||
default: 3,
|
||||
description: 'Maximum depth to traverse (default: 3)',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
description: 'Glob pattern to filter files (e.g., "*.ts")',
|
||||
},
|
||||
showSizes: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Include file sizes in output',
|
||||
},
|
||||
format: {
|
||||
type: 'string',
|
||||
enum: ['string', 'json'],
|
||||
default: 'string',
|
||||
description: 'Output format: "string" for human-readable tree, "json" for structured array',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'glob',
|
||||
description: 'Find files matching a glob pattern',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pattern: {
|
||||
type: 'string',
|
||||
description: 'Glob pattern (e.g., "**/*.ts", "src/**/*.js")',
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Base path to search from (defaults to current directory)',
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private smartfs!: plugins.smartfs.SmartFs;
|
||||
@@ -207,16 +264,61 @@ export class FilesystemTool extends BaseToolWrapper {
|
||||
case 'read': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const encoding = (params.encoding as string) || 'utf8';
|
||||
const content = await this.smartfs
|
||||
const startLine = params.startLine as number | undefined;
|
||||
const endLine = params.endLine as number | undefined;
|
||||
|
||||
const fullContent = await this.smartfs
|
||||
.file(validatedPath)
|
||||
.encoding(encoding as 'utf8' | 'binary' | 'base64')
|
||||
.read();
|
||||
|
||||
const contentStr = fullContent.toString();
|
||||
const lines = contentStr.split('\n');
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Apply line range if specified
|
||||
let resultContent: string;
|
||||
let resultStartLine = 1;
|
||||
let resultEndLine = totalLines;
|
||||
|
||||
if (startLine !== undefined || endLine !== undefined) {
|
||||
const start = Math.max(1, startLine ?? 1);
|
||||
const end = Math.min(totalLines, endLine ?? totalLines);
|
||||
resultStartLine = start;
|
||||
resultEndLine = end;
|
||||
|
||||
// Convert to 0-indexed for array slicing
|
||||
const selectedLines = lines.slice(start - 1, end);
|
||||
|
||||
// Add line numbers to output for context
|
||||
resultContent = selectedLines
|
||||
.map((line, idx) => `${String(start + idx).padStart(5)}│ ${line}`)
|
||||
.join('\n');
|
||||
} else {
|
||||
// No range specified - return full content but warn if large
|
||||
const MAX_LINES_WITHOUT_RANGE = 500;
|
||||
if (totalLines > MAX_LINES_WITHOUT_RANGE) {
|
||||
// Return first portion with warning
|
||||
const selectedLines = lines.slice(0, MAX_LINES_WITHOUT_RANGE);
|
||||
resultContent = selectedLines
|
||||
.map((line, idx) => `${String(idx + 1).padStart(5)}│ ${line}`)
|
||||
.join('\n');
|
||||
resultContent += `\n\n[... ${totalLines - MAX_LINES_WITHOUT_RANGE} more lines. Use startLine/endLine to read specific ranges.]`;
|
||||
resultEndLine = MAX_LINES_WITHOUT_RANGE;
|
||||
} else {
|
||||
resultContent = contentStr;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
content: content.toString(),
|
||||
content: resultContent,
|
||||
encoding,
|
||||
totalLines,
|
||||
startLine: resultStartLine,
|
||||
endLine: resultEndLine,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -364,6 +466,158 @@ export class FilesystemTool extends BaseToolWrapper {
|
||||
};
|
||||
}
|
||||
|
||||
case 'tree': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const maxDepth = (params.maxDepth as number) ?? 3;
|
||||
const filter = params.filter as string | undefined;
|
||||
const showSizes = (params.showSizes as boolean) ?? false;
|
||||
const format = (params.format as 'string' | 'json') ?? 'string';
|
||||
|
||||
// Collect all entries recursively up to maxDepth
|
||||
interface ITreeEntry {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
isDir: boolean;
|
||||
depth: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const entries: ITreeEntry[] = [];
|
||||
|
||||
const collectEntries = async (dirPath: string, depth: number, relativePath: string) => {
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
let dir = this.smartfs.directory(dirPath);
|
||||
if (filter) {
|
||||
dir = dir.filter(filter);
|
||||
}
|
||||
const items = await dir.list();
|
||||
|
||||
for (const item of items) {
|
||||
const itemPath = plugins.path.join(dirPath, item);
|
||||
const itemRelPath = relativePath ? `${relativePath}/${item}` : item;
|
||||
|
||||
try {
|
||||
const stats = await this.smartfs.file(itemPath).stat();
|
||||
const isDir = stats.isDirectory;
|
||||
|
||||
const entry: ITreeEntry = {
|
||||
path: itemPath,
|
||||
relativePath: itemRelPath,
|
||||
isDir,
|
||||
depth,
|
||||
};
|
||||
|
||||
if (showSizes && !isDir) {
|
||||
entry.size = stats.size;
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
|
||||
// Recurse into directories
|
||||
if (isDir && depth < maxDepth) {
|
||||
await collectEntries(itemPath, depth + 1, itemRelPath);
|
||||
}
|
||||
} catch {
|
||||
// Skip items we can't stat
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await collectEntries(validatedPath, 0, '');
|
||||
|
||||
// Sort entries by path for consistent output
|
||||
entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
|
||||
if (format === 'json') {
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
entries: entries.map((e) => ({
|
||||
path: e.relativePath,
|
||||
isDir: e.isDir,
|
||||
depth: e.depth,
|
||||
...(e.size !== undefined ? { size: e.size } : {}),
|
||||
})),
|
||||
count: entries.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Format as string tree
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
// Build tree string with proper indentation
|
||||
let treeStr = `${params.path}/\n`;
|
||||
const pathParts = new Map<string, number>(); // Track which paths are last in their parent
|
||||
|
||||
// Group by parent to determine last child
|
||||
const parentChildCount = new Map<string, number>();
|
||||
const parentCurrentChild = new Map<string, number>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const parentPath = entry.relativePath.includes('/')
|
||||
? entry.relativePath.substring(0, entry.relativePath.lastIndexOf('/'))
|
||||
: '';
|
||||
parentChildCount.set(parentPath, (parentChildCount.get(parentPath) || 0) + 1);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const parentPath = entry.relativePath.includes('/')
|
||||
? entry.relativePath.substring(0, entry.relativePath.lastIndexOf('/'))
|
||||
: '';
|
||||
parentCurrentChild.set(parentPath, (parentCurrentChild.get(parentPath) || 0) + 1);
|
||||
const isLast = parentCurrentChild.get(parentPath) === parentChildCount.get(parentPath);
|
||||
|
||||
// Build prefix based on depth
|
||||
let prefix = '';
|
||||
const parts = entry.relativePath.split('/');
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
prefix += '│ ';
|
||||
}
|
||||
prefix += isLast ? '└── ' : '├── ';
|
||||
|
||||
const name = parts[parts.length - 1];
|
||||
const suffix = entry.isDir ? '/' : '';
|
||||
const sizeStr = showSizes && entry.size !== undefined ? ` (${formatSize(entry.size)})` : '';
|
||||
|
||||
treeStr += `${prefix}${name}${suffix}${sizeStr}\n`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
tree: treeStr,
|
||||
count: entries.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'glob': {
|
||||
const pattern = params.pattern as string;
|
||||
const basePath = params.path ? this.validatePath(params.path as string) : (this.basePath || process.cwd());
|
||||
|
||||
// Use smartfs to list with filter
|
||||
const dir = this.smartfs.directory(basePath).recursive().filter(pattern);
|
||||
const matches = await dir.list();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
pattern,
|
||||
basePath,
|
||||
matches,
|
||||
count: matches.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
@@ -380,8 +634,12 @@ export class FilesystemTool extends BaseToolWrapper {
|
||||
|
||||
public getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
switch (action) {
|
||||
case 'read':
|
||||
return `Read file "${params.path}" with encoding ${params.encoding || 'utf8'}`;
|
||||
case 'read': {
|
||||
const lineRange = params.startLine || params.endLine
|
||||
? ` lines ${params.startLine || 1}-${params.endLine || 'end'}`
|
||||
: '';
|
||||
return `Read file "${params.path}"${lineRange}`;
|
||||
}
|
||||
|
||||
case 'write': {
|
||||
const content = params.content as string;
|
||||
@@ -416,6 +674,12 @@ export class FilesystemTool extends BaseToolWrapper {
|
||||
case 'mkdir':
|
||||
return `Create directory "${params.path}"${params.recursive !== false ? ' (with parents)' : ''}`;
|
||||
|
||||
case 'tree':
|
||||
return `Show tree of "${params.path}" (depth: ${params.maxDepth ?? 3}, format: ${params.format ?? 'string'})`;
|
||||
|
||||
case 'glob':
|
||||
return `Find files matching "${params.pattern}"${params.path ? ` in "${params.path}"` : ''}`;
|
||||
|
||||
default:
|
||||
return `Unknown action: ${action}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user