BREAKING CHANGE(api): Migrate public API to ai-sdk v6 and refactor core agent architecture: replace class-based DualAgent/Driver/Guardian with a single runAgent function; introduce ts_tools factories for tools, a compactMessages compaction subpath, and truncateOutput utility; simplify ToolRegistry to return ToolSet and remove legacy BaseToolWrapper/tool classes; update package exports and dependencies and bump major version.
This commit is contained in:
8
ts_tools/index.ts
Normal file
8
ts_tools/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { filesystemTool } from './tool.filesystem.js';
|
||||
export type { IFilesystemToolOptions } from './tool.filesystem.js';
|
||||
export { shellTool } from './tool.shell.js';
|
||||
export type { IShellToolOptions } from './tool.shell.js';
|
||||
export { httpTool } from './tool.http.js';
|
||||
export { jsonTool } from './tool.json.js';
|
||||
export { truncateOutput } from './plugins.js';
|
||||
export type { ITruncateResult } from './plugins.js';
|
||||
30
ts_tools/plugins.ts
Normal file
30
ts_tools/plugins.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// node native
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export { path, fs };
|
||||
|
||||
// zod
|
||||
import { z } from 'zod';
|
||||
|
||||
export { z };
|
||||
|
||||
// ai-sdk
|
||||
import { tool } from '@push.rocks/smartai';
|
||||
|
||||
export { tool };
|
||||
|
||||
export type { ToolSet } from 'ai';
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartfs from '@push.rocks/smartfs';
|
||||
import * as smartshell from '@push.rocks/smartshell';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
|
||||
export { smartfs, smartshell, smartrequest };
|
||||
|
||||
// cross-folder import
|
||||
import { truncateOutput } from '../ts/smartagent.utils.truncation.js';
|
||||
|
||||
export { truncateOutput };
|
||||
export type { ITruncateResult } from '../ts/smartagent.utils.truncation.js';
|
||||
131
ts_tools/tool.filesystem.ts
Normal file
131
ts_tools/tool.filesystem.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IFilesystemToolOptions {
|
||||
/** Restrict file access to this directory. Default: process.cwd() */
|
||||
rootDir?: string;
|
||||
}
|
||||
|
||||
function validatePath(filePath: string, rootDir?: string): string {
|
||||
const resolved = plugins.path.resolve(filePath);
|
||||
if (rootDir) {
|
||||
const resolvedRoot = plugins.path.resolve(rootDir);
|
||||
if (!resolved.startsWith(resolvedRoot + plugins.path.sep) && resolved !== resolvedRoot) {
|
||||
throw new Error(`Access denied: "${filePath}" is outside allowed root "${rootDir}"`);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet {
|
||||
const rootDir = options?.rootDir;
|
||||
|
||||
return {
|
||||
read_file: plugins.tool({
|
||||
description:
|
||||
'Read file contents. Returns the full text or a specified line range.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Absolute path to the file'),
|
||||
startLine: plugins.z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('First line (1-indexed, inclusive)'),
|
||||
endLine: plugins.z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Last line (1-indexed, inclusive)'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: filePath,
|
||||
startLine,
|
||||
endLine,
|
||||
}: {
|
||||
path: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}) => {
|
||||
const resolved = validatePath(filePath, rootDir);
|
||||
const content = plugins.fs.readFileSync(resolved, 'utf-8');
|
||||
|
||||
if (startLine !== undefined || endLine !== undefined) {
|
||||
const lines = content.split('\n');
|
||||
const start = (startLine ?? 1) - 1;
|
||||
const end = endLine ?? lines.length;
|
||||
const sliced = lines.slice(start, end).join('\n');
|
||||
return plugins.truncateOutput(sliced).content;
|
||||
}
|
||||
|
||||
return plugins.truncateOutput(content).content;
|
||||
},
|
||||
}),
|
||||
|
||||
write_file: plugins.tool({
|
||||
description:
|
||||
'Write content to a file (creates parent dirs if needed, overwrites existing).',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Absolute path to the file'),
|
||||
content: plugins.z.string().describe('Content to write'),
|
||||
}),
|
||||
execute: async ({ path: filePath, content }: { path: string; content: string }) => {
|
||||
const resolved = validatePath(filePath, rootDir);
|
||||
const dir = plugins.path.dirname(resolved);
|
||||
plugins.fs.mkdirSync(dir, { recursive: true });
|
||||
plugins.fs.writeFileSync(resolved, content, 'utf-8');
|
||||
return `Written ${content.length} characters to ${filePath}`;
|
||||
},
|
||||
}),
|
||||
|
||||
list_directory: plugins.tool({
|
||||
description: 'List files and directories at the given path.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Directory path to list'),
|
||||
recursive: plugins.z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('List recursively (default: false)'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: dirPath,
|
||||
recursive,
|
||||
}: {
|
||||
path: string;
|
||||
recursive?: boolean;
|
||||
}) => {
|
||||
const resolved = validatePath(dirPath, rootDir);
|
||||
|
||||
function listDir(dir: string, prefix: string = ''): string[] {
|
||||
const entries = plugins.fs.readdirSync(dir, { withFileTypes: true });
|
||||
const result: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const indicator = entry.isDirectory() ? '/' : '';
|
||||
result.push(`${rel}${indicator}`);
|
||||
if (recursive && entry.isDirectory()) {
|
||||
result.push(...listDir(plugins.path.join(dir, entry.name), rel));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const entries = listDir(resolved);
|
||||
return plugins.truncateOutput(entries.join('\n')).content;
|
||||
},
|
||||
}),
|
||||
|
||||
delete_file: plugins.tool({
|
||||
description: 'Delete a file or empty directory.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Path to delete'),
|
||||
}),
|
||||
execute: async ({ path: filePath }: { path: string }) => {
|
||||
const resolved = validatePath(filePath, rootDir);
|
||||
const stat = plugins.fs.statSync(resolved);
|
||||
if (stat.isDirectory()) {
|
||||
plugins.fs.rmdirSync(resolved);
|
||||
} else {
|
||||
plugins.fs.unlinkSync(resolved);
|
||||
}
|
||||
return `Deleted ${filePath}`;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
78
ts_tools/tool.http.ts
Normal file
78
ts_tools/tool.http.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export function httpTool(): plugins.ToolSet {
|
||||
return {
|
||||
http_get: plugins.tool({
|
||||
description: 'Make an HTTP GET request and return the response.',
|
||||
inputSchema: plugins.z.object({
|
||||
url: plugins.z.string().describe('URL to request'),
|
||||
headers: plugins.z
|
||||
.record(plugins.z.string())
|
||||
.optional()
|
||||
.describe('Request headers'),
|
||||
}),
|
||||
execute: async ({
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}) => {
|
||||
let req = plugins.smartrequest.default.create().url(url);
|
||||
if (headers) {
|
||||
req = req.headers(headers);
|
||||
}
|
||||
const response = await req.get();
|
||||
let body: string;
|
||||
try {
|
||||
const json = await response.json();
|
||||
body = JSON.stringify(json, null, 2);
|
||||
} catch {
|
||||
body = await response.text();
|
||||
}
|
||||
return plugins.truncateOutput(`HTTP ${response.status}\n${body}`).content;
|
||||
},
|
||||
}),
|
||||
|
||||
http_post: plugins.tool({
|
||||
description: 'Make an HTTP POST request with a JSON body.',
|
||||
inputSchema: plugins.z.object({
|
||||
url: plugins.z.string().describe('URL to request'),
|
||||
body: plugins.z
|
||||
.record(plugins.z.unknown())
|
||||
.optional()
|
||||
.describe('JSON body to send'),
|
||||
headers: plugins.z
|
||||
.record(plugins.z.string())
|
||||
.optional()
|
||||
.describe('Request headers'),
|
||||
}),
|
||||
execute: async ({
|
||||
url,
|
||||
body,
|
||||
headers,
|
||||
}: {
|
||||
url: string;
|
||||
body?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
}) => {
|
||||
let req = plugins.smartrequest.default.create().url(url);
|
||||
if (headers) {
|
||||
req = req.headers(headers);
|
||||
}
|
||||
if (body) {
|
||||
req = req.json(body);
|
||||
}
|
||||
const response = await req.post();
|
||||
let responseBody: string;
|
||||
try {
|
||||
const json = await response.json();
|
||||
responseBody = JSON.stringify(json, null, 2);
|
||||
} catch {
|
||||
responseBody = await response.text();
|
||||
}
|
||||
return plugins.truncateOutput(`HTTP ${response.status}\n${responseBody}`).content;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
53
ts_tools/tool.json.ts
Normal file
53
ts_tools/tool.json.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export function jsonTool(): plugins.ToolSet {
|
||||
return {
|
||||
json_validate: plugins.tool({
|
||||
description:
|
||||
'Validate a JSON string and optionally check for required fields.',
|
||||
inputSchema: plugins.z.object({
|
||||
jsonString: plugins.z.string().describe('JSON string to validate'),
|
||||
requiredFields: plugins.z
|
||||
.array(plugins.z.string())
|
||||
.optional()
|
||||
.describe('Fields that must exist at the top level'),
|
||||
}),
|
||||
execute: async ({
|
||||
jsonString,
|
||||
requiredFields,
|
||||
}: {
|
||||
jsonString: string;
|
||||
requiredFields?: string[];
|
||||
}) => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
if (requiredFields?.length) {
|
||||
const missing = requiredFields.filter((f) => !(f in parsed));
|
||||
if (missing.length) {
|
||||
return `Invalid: missing required fields: ${missing.join(', ')}`;
|
||||
}
|
||||
}
|
||||
const type = Array.isArray(parsed) ? 'array' : typeof parsed;
|
||||
return `Valid JSON (${type})`;
|
||||
} catch (e) {
|
||||
return `Invalid JSON: ${(e as Error).message}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
json_transform: plugins.tool({
|
||||
description: 'Parse a JSON string and return it pretty-printed.',
|
||||
inputSchema: plugins.z.object({
|
||||
jsonString: plugins.z.string().describe('JSON string to format'),
|
||||
}),
|
||||
execute: async ({ jsonString }: { jsonString: string }) => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
return `Error parsing JSON: ${(e as Error).message}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
62
ts_tools/tool.shell.ts
Normal file
62
ts_tools/tool.shell.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IShellToolOptions {
|
||||
/** Allowed commands whitelist. If empty, all commands are allowed. */
|
||||
allowedCommands?: string[];
|
||||
/** Working directory for shell execution */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
|
||||
const smartshell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
|
||||
return {
|
||||
run_command: plugins.tool({
|
||||
description:
|
||||
'Execute a shell command. Provide the full command string. stdout and stderr are returned.',
|
||||
inputSchema: plugins.z.object({
|
||||
command: plugins.z.string().describe('The shell command to execute'),
|
||||
cwd: plugins.z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Working directory for the command'),
|
||||
timeout: plugins.z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Timeout in milliseconds'),
|
||||
}),
|
||||
execute: async ({
|
||||
command,
|
||||
cwd,
|
||||
timeout,
|
||||
}: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
}) => {
|
||||
// Validate against allowed commands whitelist
|
||||
if (options?.allowedCommands?.length) {
|
||||
const baseCommand = command.split(/\s+/)[0];
|
||||
if (!options.allowedCommands.includes(baseCommand)) {
|
||||
return `Command "${baseCommand}" is not in the allowed commands list: ${options.allowedCommands.join(', ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build full command string with cd prefix if cwd specified
|
||||
const effectiveCwd = cwd ?? options?.cwd;
|
||||
const fullCommand = effectiveCwd
|
||||
? `cd ${JSON.stringify(effectiveCwd)} && ${command}`
|
||||
: command;
|
||||
|
||||
const execResult = await smartshell.exec(fullCommand);
|
||||
|
||||
const output =
|
||||
execResult.exitCode === 0
|
||||
? execResult.stdout
|
||||
: `Exit code: ${execResult.exitCode}\nstdout:\n${execResult.stdout}\nstderr:\n${execResult.stderr ?? ''}`;
|
||||
|
||||
return plugins.truncateOutput(output).content;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user