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:
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-06 - 3.0.0 - 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.
|
||||
|
||||
- Major API break: DualAgent/Driver/Guardian classes and many legacy tool wrapper classes were removed and replaced by runAgent and functional tool factories.
|
||||
- Tooling refactor: new ts_tools (filesystem, shell, http, json) produce ToolSet-style tools; ToolRegistry now stores ToolSet and exposes getTools().
|
||||
- Context management: added compactMessages in ts_compaction for onContextOverflow handling and truncateOutput util to limit tool outputs.
|
||||
- Package changes: package.json bumped to 2.0.0, added exports map, updated devDependencies and dependencies (@push.rocks/smartai -> ^2.0.0, ai -> ^6.0.0, zod added).
|
||||
- Tests updated/added to reflect new API (unit tests and an end-to-end test added).
|
||||
- Consumers must update imports/usages (e.g. import runAgent, tool, z, stepCountIs and new subpath exports) — this is a breaking change.
|
||||
|
||||
## 2026-01-20 - 1.8.0 - feat(tools)
|
||||
add ToolRegistry, ToolSearchTool and ExpertTool to support on-demand tool visibility, discovery, activation, and expert/subagent tooling; extend DualAgentOrchestrator API and interfaces to manage tool lifecycle
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartagent",
|
||||
"description": "an agentic framework built on top of @push.rocks/smartai",
|
||||
"description": "Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.",
|
||||
"npmPackagename": "@push.rocks/smartagent",
|
||||
"license": "MIT",
|
||||
"projectDomain": "push.rocks"
|
||||
|
||||
48
package.json
48
package.json
@@ -1,33 +1,47 @@
|
||||
{
|
||||
"name": "@push.rocks/smartagent",
|
||||
"version": "1.8.0",
|
||||
"version": "2.0.0",
|
||||
"private": false,
|
||||
"description": "an agentic framework built on top of @push.rocks/smartai",
|
||||
"description": "Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist_ts/index.js",
|
||||
"types": "./dist_ts/index.d.ts"
|
||||
},
|
||||
"./tools": {
|
||||
"import": "./dist_ts_tools/index.js",
|
||||
"types": "./dist_ts_tools/index.d.ts"
|
||||
},
|
||||
"./compaction": {
|
||||
"import": "./dist_ts_compaction/index.js",
|
||||
"types": "./dist_ts_compaction/index.d.ts"
|
||||
}
|
||||
},
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --verbose)",
|
||||
"build": "(tsbuild --web --allowimplicitany)",
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 120)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany)",
|
||||
"buildDocs": "(tsdoc)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.0.2",
|
||||
"@git.zone/tsbundle": "^2.6.3",
|
||||
"@git.zone/tsbuild": "^4.3.0",
|
||||
"@git.zone/tsbundle": "^2.9.1",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@types/node": "^25.0.2"
|
||||
"@git.zone/tstest": "^3.3.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@types/node": "^25.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartai": "^0.13.3",
|
||||
"@push.rocks/smartbrowser": "^2.0.8",
|
||||
"@push.rocks/smartdeno": "^1.2.0",
|
||||
"@push.rocks/smartfs": "^1.2.0",
|
||||
"@push.rocks/smartai": "^2.0.0",
|
||||
"@push.rocks/smartfs": "^1.4.0",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartshell": "^3.3.0",
|
||||
"minimatch": "^10.1.1"
|
||||
"@push.rocks/smartshell": "^3.3.7",
|
||||
"ai": "^6.0.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||
"repository": {
|
||||
@@ -40,13 +54,11 @@
|
||||
"homepage": "https://code.foss.global/push.rocks/smartagent#readme",
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"ts_tools/**/*",
|
||||
"ts_compaction/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
],
|
||||
|
||||
3155
pnpm-lock.yaml
generated
3155
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
124
readme.hints.md
124
readme.hints.md
@@ -1,97 +1,51 @@
|
||||
# Project Readme Hints
|
||||
|
||||
## Overview
|
||||
`@push.rocks/smartagent` is a dual-agent agentic framework built on top of `@push.rocks/smartai`. It implements a Driver/Guardian architecture where the Driver proposes tool calls and the Guardian evaluates them against security policies.
|
||||
`@push.rocks/smartagent` v2.0.0 is an agentic loop built on Vercel AI SDK v6 via `@push.rocks/smartai`. It wraps `streamText` with `stopWhen: stepCountIs(n)` for parallel multi-step tool execution.
|
||||
|
||||
## Architecture
|
||||
- **DualAgentOrchestrator**: Main entry point, coordinates Driver and Guardian agents
|
||||
- **DriverAgent**: Reasons about tasks, plans steps, proposes tool calls (supports both XML and native tool calling)
|
||||
- **GuardianAgent**: Evaluates tool calls against configured policies
|
||||
- **ToolRegistry**: Manages tool lifecycle, visibility, and discovery
|
||||
- **BaseToolWrapper**: Base class for creating custom tools
|
||||
- **plugins.ts**: Imports and re-exports smartai and other dependencies
|
||||
## Architecture (v2)
|
||||
- **`runAgent()`**: Pure async function — the core agentic loop. No class state.
|
||||
- **`ToolRegistry`**: Lightweight helper for collecting tools into a `ToolSet`.
|
||||
- **`truncateOutput()`**: Utility to prevent tool output from bloating context.
|
||||
- **`compactMessages()`**: Context overflow handler (separate subpath export).
|
||||
|
||||
## Standard Tools (via registerStandardTools)
|
||||
1. **FilesystemTool** - File operations with scoping and exclusion patterns
|
||||
2. **HttpTool** - HTTP requests
|
||||
3. **ShellTool** - Secure shell commands (no injection possible)
|
||||
4. **BrowserTool** - Web page interaction via Puppeteer
|
||||
5. **DenoTool** - Sandboxed TypeScript/JavaScript execution
|
||||
|
||||
## Additional Tools (must register manually)
|
||||
6. **JsonValidatorTool** - JSON validation and formatting (NOT in registerStandardTools)
|
||||
7. **ToolSearchTool** - AI-facing interface for tool discovery and activation
|
||||
8. **ExpertTool** - Wraps a DualAgentOrchestrator as a specialized expert tool
|
||||
|
||||
## Tool Visibility System
|
||||
Tools can be registered with visibility modes:
|
||||
- **initial**: Always visible to Driver, included in system prompt (default)
|
||||
- **on-demand**: Only discoverable via search, must be activated before use
|
||||
|
||||
```typescript
|
||||
// Register with visibility options
|
||||
orchestrator.registerTool(myTool, {
|
||||
visibility: 'on-demand',
|
||||
tags: ['database', 'sql'],
|
||||
category: 'data'
|
||||
});
|
||||
## Source Layout
|
||||
```
|
||||
ts/ → core: runAgent, ToolRegistry, truncateOutput, interfaces
|
||||
ts_tools/ → built-in tool factories (filesystem, shell, http, json)
|
||||
ts_compaction/ → compactMessages helper for onContextOverflow
|
||||
```
|
||||
|
||||
## Expert/SubAgent System
|
||||
Experts are specialized agents wrapped as tools, enabling hierarchical agent architectures:
|
||||
|
||||
```typescript
|
||||
orchestrator.registerExpert({
|
||||
name: 'code_reviewer',
|
||||
description: 'Reviews code for quality and best practices',
|
||||
systemMessage: 'You are a code review expert...',
|
||||
guardianPolicy: 'Allow read-only file access',
|
||||
tools: [new FilesystemTool()],
|
||||
visibility: 'on-demand',
|
||||
tags: ['code', 'review']
|
||||
});
|
||||
```
|
||||
|
||||
## Tool Search
|
||||
Enable tool discovery for the Driver:
|
||||
|
||||
```typescript
|
||||
orchestrator.enableToolSearch();
|
||||
// Driver can now use:
|
||||
// - tools.search({"query": "database"})
|
||||
// - tools.list({})
|
||||
// - tools.activate({"name": "database_expert"})
|
||||
// - tools.details({"name": "filesystem"})
|
||||
```
|
||||
|
||||
## Key Features
|
||||
- Token streaming support (`onToken` callback)
|
||||
- Vision support (pass images as base64)
|
||||
- Progress events (`onProgress` callback)
|
||||
- Scoped filesystem with exclusion patterns
|
||||
- Result truncation with configurable limits
|
||||
- History windowing to manage token usage
|
||||
- **Native tool calling mode** (`useNativeToolCalling: true`) for providers like Ollama
|
||||
- **Tool visibility system** (initial vs on-demand)
|
||||
- **Expert/SubAgent system** for hierarchical agents
|
||||
- **Tool search and discovery** via ToolSearchTool
|
||||
|
||||
## Native Tool Calling
|
||||
When `useNativeToolCalling` is enabled:
|
||||
- Uses provider's built-in tool calling API instead of XML parsing
|
||||
- Tool names become `toolName_actionName` (e.g., `json_validate`)
|
||||
- Streaming includes `[THINKING]` and `[OUTPUT]` markers
|
||||
- More efficient for models that support it
|
||||
## Built-in Tools (ts_tools/)
|
||||
Each exports a factory returning a flat `ToolSet` (Record<string, Tool>):
|
||||
1. **filesystemTool()** → `read_file`, `write_file`, `list_directory`, `delete_file`
|
||||
2. **shellTool()** → `run_command`
|
||||
3. **httpTool()** → `http_get`, `http_post`
|
||||
4. **jsonTool()** → `json_validate`, `json_transform`
|
||||
|
||||
## Key Dependencies
|
||||
- `@push.rocks/smartai`: Multi-provider AI interface
|
||||
- `@push.rocks/smartfs`: Filesystem operations
|
||||
- `@push.rocks/smartshell`: Shell command execution
|
||||
- `@push.rocks/smartbrowser`: Browser automation
|
||||
- `@push.rocks/smartdeno`: Deno code execution
|
||||
- `@push.rocks/smartrequest`: HTTP requests
|
||||
- `minimatch`: Glob pattern matching for exclusions
|
||||
- `@push.rocks/smartai` ^2.0.0 — provider registry, `getModel()`, re-exports `tool`, `jsonSchema`
|
||||
- `ai` ^6.0.0 — Vercel AI SDK v6 (`streamText`, `stepCountIs`, `ModelMessage`, `ToolSet`)
|
||||
- `zod` ^3.25.0 — tool input schema definitions
|
||||
- `@push.rocks/smartfs`, `smartshell`, `smartrequest` — tool implementations
|
||||
|
||||
## AI SDK v6 Key APIs
|
||||
- `streamText({ model, messages, tools, stopWhen: stepCountIs(20) })` — agentic loop
|
||||
- `tool({ description, inputSchema: z.object({...}), execute })` — define tools
|
||||
- `ModelMessage` — message type (replaces v4's `CoreMessage`)
|
||||
- `LanguageModelV3` — model type from `@ai-sdk/provider`
|
||||
- Result is `StreamTextResult` with PromiseLike properties (`await result.text`, etc.)
|
||||
|
||||
## Package Exports
|
||||
- `.` → core (runAgent, ToolRegistry, truncateOutput, re-exports)
|
||||
- `./tools` → built-in tool factories
|
||||
- `./compaction` → compactMessages
|
||||
|
||||
## Build
|
||||
- `pnpm build` → `tsbuild tsfolders --allowimplicitany`
|
||||
- Cross-folder imports via each folder's `plugins.ts` (tsbuild unpack resolves them)
|
||||
|
||||
## Test Structure
|
||||
- Tests use `@git.zone/tstest/tapbundle`
|
||||
- Tests must end with `export default tap.start();`
|
||||
- Tests must end with `export default tap.start()`
|
||||
- `pnpm test` → `tstest test/ --verbose`
|
||||
|
||||
239
test/test.agent-e2e.ts
Normal file
239
test/test.agent-e2e.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { runAgent, tool, z, ToolRegistry } from '../ts/index.js';
|
||||
import { filesystemTool, shellTool } from '../ts_tools/index.js';
|
||||
|
||||
const testQenv = new qenv.Qenv('./', './.nogit/');
|
||||
|
||||
let model: any;
|
||||
const workDir = '/tmp/smartagent-e2e-' + Date.now();
|
||||
|
||||
tap.test('setup: create model and workspace', async () => {
|
||||
const apiKey = await testQenv.getEnvVarOnDemand('ANTHROPIC_TOKEN');
|
||||
if (!apiKey) {
|
||||
console.log('ANTHROPIC_TOKEN not set — skipping all E2E tests');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { getModel } = await import('@push.rocks/smartai');
|
||||
model = getModel({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
apiKey,
|
||||
});
|
||||
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
console.log(` Workspace: ${workDir}`);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 1: Simple tool call
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should call a single tool and incorporate the result', async () => {
|
||||
let toolCalled = false;
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: 'What is the current time? Use the get_time tool.',
|
||||
system: 'You are a helpful assistant. Use tools when asked.',
|
||||
tools: {
|
||||
get_time: tool({
|
||||
description: 'Returns the current ISO timestamp',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
toolCalled = true;
|
||||
return new Date().toISOString();
|
||||
},
|
||||
}),
|
||||
},
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
console.log(` Response: ${result.text.substring(0, 150)}`);
|
||||
console.log(` Steps: ${result.steps}, Tokens: ${result.usage.totalTokens}`);
|
||||
|
||||
expect(toolCalled).toBeTrue();
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.usage.totalTokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 2: Multiple tools — agent chooses which to use
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should pick the right tool from multiple options', async () => {
|
||||
const callLog: string[] = [];
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: 'Add 7 and 35 using the calculator tool.',
|
||||
system: 'You are a helpful assistant. Use the appropriate tool to answer.',
|
||||
tools: {
|
||||
calculator: tool({
|
||||
description: 'Perform arithmetic. Supports add, subtract, multiply, divide.',
|
||||
inputSchema: z.object({
|
||||
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
|
||||
a: z.number(),
|
||||
b: z.number(),
|
||||
}),
|
||||
execute: async ({ operation, a, b }: { operation: string; a: number; b: number }) => {
|
||||
callLog.push(`calculator:${operation}(${a}, ${b})`);
|
||||
switch (operation) {
|
||||
case 'add': return String(a + b);
|
||||
case 'subtract': return String(a - b);
|
||||
case 'multiply': return String(a * b);
|
||||
case 'divide': return b !== 0 ? String(a / b) : 'Error: division by zero';
|
||||
default: return 'Unknown operation';
|
||||
}
|
||||
},
|
||||
}),
|
||||
get_weather: tool({
|
||||
description: 'Get current weather for a city',
|
||||
inputSchema: z.object({ city: z.string() }),
|
||||
execute: async () => {
|
||||
callLog.push('get_weather');
|
||||
return 'Sunny, 22°C';
|
||||
},
|
||||
}),
|
||||
},
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
console.log(` Tool calls: ${callLog.join(', ')}`);
|
||||
console.log(` Response: ${result.text.substring(0, 150)}`);
|
||||
|
||||
expect(callLog.some((c) => c.startsWith('calculator:add'))).toBeTrue();
|
||||
expect(callLog).not.toContain('get_weather');
|
||||
expect(result.text).toInclude('42');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 3: Multi-step — agent uses filesystem tools
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should use filesystem tools for a multi-step task', async () => {
|
||||
const fsTools = filesystemTool({ rootDir: workDir });
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: `Create a file called "greeting.txt" in ${workDir} with the content "Hello from smartagent!". Then read it back and tell me what it says.`,
|
||||
system: 'You are a helpful assistant that works with files. Use the provided tools.',
|
||||
tools: fsTools,
|
||||
maxSteps: 10,
|
||||
});
|
||||
|
||||
console.log(` Steps: ${result.steps}`);
|
||||
console.log(` Response: ${result.text.substring(0, 200)}`);
|
||||
|
||||
// Verify the file was actually created
|
||||
const filePath = path.join(workDir, 'greeting.txt');
|
||||
expect(fs.existsSync(filePath)).toBeTrue();
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toInclude('Hello from smartagent');
|
||||
expect(result.steps).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 4: ToolRegistry usage
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should work with ToolRegistry', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
|
||||
registry.register('random_number', tool({
|
||||
description: 'Generate a random integer between min and max (inclusive)',
|
||||
inputSchema: z.object({
|
||||
min: z.number().describe('Minimum value'),
|
||||
max: z.number().describe('Maximum value'),
|
||||
}),
|
||||
execute: async ({ min, max }: { min: number; max: number }) => {
|
||||
const value = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
return String(value);
|
||||
},
|
||||
}));
|
||||
|
||||
registry.register('is_even', tool({
|
||||
description: 'Check if a number is even',
|
||||
inputSchema: z.object({ number: z.number() }),
|
||||
execute: async ({ number: n }: { number: number }) => {
|
||||
return n % 2 === 0 ? 'Yes, it is even' : 'No, it is odd';
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: 'Generate a random number between 1 and 100, then check if it is even or odd. Tell me both the number and whether it is even.',
|
||||
system: 'You are a helpful assistant. Use tools step by step.',
|
||||
tools: registry.getTools(),
|
||||
maxSteps: 10,
|
||||
});
|
||||
|
||||
console.log(` Response: ${result.text.substring(0, 200)}`);
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.steps).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 5: Streaming callbacks
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should fire onToken and onToolCall callbacks', async () => {
|
||||
const tokens: string[] = [];
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: 'Use the echo tool to echo "test123".',
|
||||
system: 'You are a helpful assistant. Use tools when asked.',
|
||||
tools: {
|
||||
echo: tool({
|
||||
description: 'Echo back the provided text',
|
||||
inputSchema: z.object({ text: z.string() }),
|
||||
execute: async ({ text }: { text: string }) => text,
|
||||
}),
|
||||
},
|
||||
maxSteps: 5,
|
||||
onToken: (delta) => tokens.push(delta),
|
||||
onToolCall: (name) => toolCalls.push(name),
|
||||
});
|
||||
|
||||
console.log(` Streamed ${tokens.length} token chunks`);
|
||||
console.log(` Tool calls observed: ${toolCalls.join(', ')}`);
|
||||
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
expect(toolCalls).toContain('echo');
|
||||
expect(result.text).toInclude('test123');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 6: Shell tool integration
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should use shell tool to run a command', async () => {
|
||||
const tools = shellTool();
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: `Run the command "echo hello_smartagent" and tell me what it outputs.`,
|
||||
system: 'You are a helpful assistant that can run shell commands.',
|
||||
tools,
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
console.log(` Response: ${result.text.substring(0, 200)}`);
|
||||
expect(result.text).toInclude('hello_smartagent');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Cleanup
|
||||
// ============================================================
|
||||
|
||||
tap.test('cleanup: remove workspace', async () => {
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
console.log(` Cleaned up ${workDir}`);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
294
test/test.ts
294
test/test.ts
@@ -1,150 +1,188 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartagent from '../ts/index.js';
|
||||
import { filesystemTool, shellTool, httpTool, jsonTool, truncateOutput } from '../ts_tools/index.js';
|
||||
import { compactMessages } from '../ts_compaction/index.js';
|
||||
|
||||
// Test exports
|
||||
tap.test('should export DualAgentOrchestrator class', async () => {
|
||||
expect(smartagent.DualAgentOrchestrator).toBeTypeOf('function');
|
||||
// ============================================================
|
||||
// Core exports
|
||||
// ============================================================
|
||||
|
||||
tap.test('should export runAgent function', async () => {
|
||||
expect(smartagent.runAgent).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
tap.test('should export DriverAgent class', async () => {
|
||||
expect(smartagent.DriverAgent).toBeTypeOf('function');
|
||||
tap.test('should export ToolRegistry class', async () => {
|
||||
expect(smartagent.ToolRegistry).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
tap.test('should export GuardianAgent class', async () => {
|
||||
expect(smartagent.GuardianAgent).toBeTypeOf('function');
|
||||
tap.test('should export ContextOverflowError class', async () => {
|
||||
expect(smartagent.ContextOverflowError).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
tap.test('should export BaseToolWrapper class', async () => {
|
||||
expect(smartagent.BaseToolWrapper).toBeTypeOf('function');
|
||||
tap.test('should export truncateOutput function', async () => {
|
||||
expect(smartagent.truncateOutput).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
// Test standard tools exports
|
||||
tap.test('should export FilesystemTool class', async () => {
|
||||
expect(smartagent.FilesystemTool).toBeTypeOf('function');
|
||||
tap.test('should re-export tool helper', async () => {
|
||||
expect(smartagent.tool).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
tap.test('should export HttpTool class', async () => {
|
||||
expect(smartagent.HttpTool).toBeTypeOf('function');
|
||||
tap.test('should re-export z (zod)', async () => {
|
||||
expect(smartagent.z).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should export ShellTool class', async () => {
|
||||
expect(smartagent.ShellTool).toBeTypeOf('function');
|
||||
tap.test('should re-export stepCountIs', async () => {
|
||||
expect(smartagent.stepCountIs).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
tap.test('should export BrowserTool class', async () => {
|
||||
expect(smartagent.BrowserTool).toBeTypeOf('function');
|
||||
});
|
||||
// ============================================================
|
||||
// ToolRegistry
|
||||
// ============================================================
|
||||
|
||||
tap.test('should export DenoTool class', async () => {
|
||||
expect(smartagent.DenoTool).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
// Test tool instantiation
|
||||
tap.test('should be able to instantiate FilesystemTool', async () => {
|
||||
const fsTool = new smartagent.FilesystemTool();
|
||||
expect(fsTool.name).toEqual('filesystem');
|
||||
expect(fsTool.actions).toBeTypeOf('object');
|
||||
expect(fsTool.actions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should be able to instantiate HttpTool', async () => {
|
||||
const httpTool = new smartagent.HttpTool();
|
||||
expect(httpTool.name).toEqual('http');
|
||||
expect(httpTool.actions).toBeTypeOf('object');
|
||||
});
|
||||
|
||||
tap.test('should be able to instantiate ShellTool', async () => {
|
||||
const shellTool = new smartagent.ShellTool();
|
||||
expect(shellTool.name).toEqual('shell');
|
||||
expect(shellTool.actions).toBeTypeOf('object');
|
||||
});
|
||||
|
||||
tap.test('should be able to instantiate BrowserTool', async () => {
|
||||
const browserTool = new smartagent.BrowserTool();
|
||||
expect(browserTool.name).toEqual('browser');
|
||||
expect(browserTool.actions).toBeTypeOf('object');
|
||||
});
|
||||
|
||||
tap.test('should be able to instantiate DenoTool', async () => {
|
||||
const denoTool = new smartagent.DenoTool();
|
||||
expect(denoTool.name).toEqual('deno');
|
||||
expect(denoTool.actions).toBeTypeOf('object');
|
||||
});
|
||||
|
||||
// Test tool descriptions
|
||||
tap.test('FilesystemTool should have required actions', async () => {
|
||||
const fsTool = new smartagent.FilesystemTool();
|
||||
const actionNames = fsTool.actions.map((a) => a.name);
|
||||
expect(actionNames).toContain('read');
|
||||
expect(actionNames).toContain('write');
|
||||
expect(actionNames).toContain('list');
|
||||
expect(actionNames).toContain('delete');
|
||||
expect(actionNames).toContain('exists');
|
||||
});
|
||||
|
||||
tap.test('HttpTool should have required actions', async () => {
|
||||
const httpTool = new smartagent.HttpTool();
|
||||
const actionNames = httpTool.actions.map((a) => a.name);
|
||||
expect(actionNames).toContain('get');
|
||||
expect(actionNames).toContain('post');
|
||||
expect(actionNames).toContain('put');
|
||||
expect(actionNames).toContain('delete');
|
||||
});
|
||||
|
||||
tap.test('ShellTool should have required actions', async () => {
|
||||
const shellTool = new smartagent.ShellTool();
|
||||
const actionNames = shellTool.actions.map((a) => a.name);
|
||||
expect(actionNames).toContain('execute');
|
||||
expect(actionNames).toContain('which');
|
||||
});
|
||||
|
||||
tap.test('BrowserTool should have required actions', async () => {
|
||||
const browserTool = new smartagent.BrowserTool();
|
||||
const actionNames = browserTool.actions.map((a) => a.name);
|
||||
expect(actionNames).toContain('screenshot');
|
||||
expect(actionNames).toContain('pdf');
|
||||
expect(actionNames).toContain('evaluate');
|
||||
expect(actionNames).toContain('getPageContent');
|
||||
});
|
||||
|
||||
tap.test('DenoTool should have required actions', async () => {
|
||||
const denoTool = new smartagent.DenoTool();
|
||||
const actionNames = denoTool.actions.map((a) => a.name);
|
||||
expect(actionNames).toContain('execute');
|
||||
expect(actionNames).toContain('executeWithResult');
|
||||
});
|
||||
|
||||
// Test getCallSummary
|
||||
tap.test('FilesystemTool should generate call summaries', async () => {
|
||||
const fsTool = new smartagent.FilesystemTool();
|
||||
const summary = fsTool.getCallSummary('read', { path: '/tmp/test.txt' });
|
||||
expect(summary).toBeTypeOf('string');
|
||||
expect(summary).toInclude('/tmp/test.txt');
|
||||
});
|
||||
|
||||
tap.test('HttpTool should generate call summaries', async () => {
|
||||
const httpTool = new smartagent.HttpTool();
|
||||
const summary = httpTool.getCallSummary('get', { url: 'https://example.com' });
|
||||
expect(summary).toBeTypeOf('string');
|
||||
expect(summary).toInclude('example.com');
|
||||
});
|
||||
|
||||
tap.test('DenoTool should generate call summaries', async () => {
|
||||
const denoTool = new smartagent.DenoTool();
|
||||
const summary = denoTool.getCallSummary('execute', { code: 'console.log("hello");' });
|
||||
expect(summary).toBeTypeOf('string');
|
||||
expect(summary).toInclude('sandboxed');
|
||||
});
|
||||
|
||||
tap.test('DenoTool should show permissions in call summary', async () => {
|
||||
const denoTool = new smartagent.DenoTool();
|
||||
const summary = denoTool.getCallSummary('execute', {
|
||||
code: 'console.log("hello");',
|
||||
permissions: ['net', 'read']
|
||||
tap.test('ToolRegistry should register and return tools', async () => {
|
||||
const registry = new smartagent.ToolRegistry();
|
||||
const echoTool = smartagent.tool({
|
||||
description: 'Echo tool',
|
||||
inputSchema: smartagent.z.object({ text: smartagent.z.string() }),
|
||||
execute: async ({ text }: { text: string }) => text,
|
||||
});
|
||||
expect(summary).toBeTypeOf('string');
|
||||
expect(summary).toInclude('permissions');
|
||||
expect(summary).toInclude('net');
|
||||
registry.register('echo', echoTool);
|
||||
const tools = registry.getTools();
|
||||
expect(Object.keys(tools)).toContain('echo');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Truncation
|
||||
// ============================================================
|
||||
|
||||
tap.test('truncateOutput should not truncate short strings', async () => {
|
||||
const result = truncateOutput('hello world');
|
||||
expect(result.truncated).toBeFalse();
|
||||
expect(result.content).toEqual('hello world');
|
||||
});
|
||||
|
||||
tap.test('truncateOutput should truncate strings over maxLines', async () => {
|
||||
const lines = Array.from({ length: 3000 }, (_, i) => `line ${i}`).join('\n');
|
||||
const result = truncateOutput(lines, { maxLines: 100 });
|
||||
expect(result.truncated).toBeTrue();
|
||||
expect(result.notice).toBeTruthy();
|
||||
expect(result.content).toInclude('[Output truncated');
|
||||
});
|
||||
|
||||
tap.test('truncateOutput should truncate strings over maxBytes', async () => {
|
||||
const big = 'x'.repeat(100_000);
|
||||
const result = truncateOutput(big, { maxBytes: 1000 });
|
||||
expect(result.truncated).toBeTrue();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Tool factories
|
||||
// ============================================================
|
||||
|
||||
tap.test('filesystemTool returns expected tool names', async () => {
|
||||
const tools = filesystemTool();
|
||||
const names = Object.keys(tools);
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('write_file');
|
||||
expect(names).toContain('list_directory');
|
||||
expect(names).toContain('delete_file');
|
||||
});
|
||||
|
||||
tap.test('shellTool returns expected tool names', async () => {
|
||||
const tools = shellTool();
|
||||
const names = Object.keys(tools);
|
||||
expect(names).toContain('run_command');
|
||||
});
|
||||
|
||||
tap.test('httpTool returns expected tool names', async () => {
|
||||
const tools = httpTool();
|
||||
const names = Object.keys(tools);
|
||||
expect(names).toContain('http_get');
|
||||
expect(names).toContain('http_post');
|
||||
});
|
||||
|
||||
tap.test('jsonTool returns expected tool names', async () => {
|
||||
const tools = jsonTool();
|
||||
const names = Object.keys(tools);
|
||||
expect(names).toContain('json_validate');
|
||||
expect(names).toContain('json_transform');
|
||||
});
|
||||
|
||||
tap.test('json_validate tool should validate valid JSON', async () => {
|
||||
const tools = jsonTool();
|
||||
const result = await (tools.json_validate as any).execute({
|
||||
jsonString: '{"name":"test","value":42}',
|
||||
});
|
||||
expect(result).toInclude('Valid JSON');
|
||||
});
|
||||
|
||||
tap.test('json_validate tool should detect invalid JSON', async () => {
|
||||
const tools = jsonTool();
|
||||
const result = await (tools.json_validate as any).execute({
|
||||
jsonString: '{invalid json',
|
||||
});
|
||||
expect(result).toInclude('Invalid JSON');
|
||||
});
|
||||
|
||||
tap.test('json_validate tool should check required fields', async () => {
|
||||
const tools = jsonTool();
|
||||
const result = await (tools.json_validate as any).execute({
|
||||
jsonString: '{"name":"test"}',
|
||||
requiredFields: ['name', 'missing_field'],
|
||||
});
|
||||
expect(result).toInclude('missing_field');
|
||||
});
|
||||
|
||||
tap.test('json_transform tool should pretty-print JSON', async () => {
|
||||
const tools = jsonTool();
|
||||
const result = await (tools.json_transform as any).execute({
|
||||
jsonString: '{"a":1,"b":2}',
|
||||
});
|
||||
expect(result).toInclude(' "a": 1');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Compaction export
|
||||
// ============================================================
|
||||
|
||||
tap.test('compactMessages should be a function', async () => {
|
||||
expect(compactMessages).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Filesystem tool read/write round-trip
|
||||
// ============================================================
|
||||
|
||||
tap.test('filesystem tool should write and read a file', async () => {
|
||||
const tmpDir = '/tmp/smartagent-test-' + Date.now();
|
||||
const tools = filesystemTool({ rootDir: tmpDir });
|
||||
|
||||
await (tools.write_file as any).execute({
|
||||
path: tmpDir + '/hello.txt',
|
||||
content: 'Hello, world!',
|
||||
});
|
||||
|
||||
const content = await (tools.read_file as any).execute({
|
||||
path: tmpDir + '/hello.txt',
|
||||
});
|
||||
expect(content).toInclude('Hello, world!');
|
||||
|
||||
// Cleanup
|
||||
await (tools.delete_file as any).execute({
|
||||
path: tmpDir + '/hello.txt',
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('filesystem tool should enforce rootDir restriction', async () => {
|
||||
const tools = filesystemTool({ rootDir: '/tmp/restricted' });
|
||||
let threw = false;
|
||||
try {
|
||||
await (tools.read_file as any).execute({ path: '/etc/passwd' });
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
expect((e as Error).message).toInclude('Access denied');
|
||||
}
|
||||
expect(threw).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartagent',
|
||||
version: '1.8.0',
|
||||
description: 'an agentic framework built on top of @push.rocks/smartai'
|
||||
version: '3.0.0',
|
||||
description: 'Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.'
|
||||
}
|
||||
|
||||
45
ts/index.ts
45
ts/index.ts
@@ -1,38 +1,11 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
// Export the dual-agent orchestrator (main entry point)
|
||||
export { DualAgentOrchestrator } from './smartagent.classes.dualagent.js';
|
||||
|
||||
// Export individual agents
|
||||
export { DriverAgent } from './smartagent.classes.driveragent.js';
|
||||
export { GuardianAgent } from './smartagent.classes.guardianagent.js';
|
||||
|
||||
// Export tool registry and related classes
|
||||
export { runAgent } from './smartagent.classes.agent.js';
|
||||
export { ToolRegistry } from './smartagent.classes.toolregistry.js';
|
||||
export { truncateOutput } from './smartagent.utils.truncation.js';
|
||||
export type { ITruncateResult } from './smartagent.utils.truncation.js';
|
||||
export { ContextOverflowError } from './smartagent.interfaces.js';
|
||||
export type { IAgentRunOptions, IAgentRunResult } from './smartagent.interfaces.js';
|
||||
|
||||
// Export base tool class for custom tool creation
|
||||
export { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
// Export standard tools
|
||||
export { FilesystemTool, type IFilesystemToolOptions } from './smartagent.tools.filesystem.js';
|
||||
export { HttpTool } from './smartagent.tools.http.js';
|
||||
export { ShellTool } from './smartagent.tools.shell.js';
|
||||
export { BrowserTool } from './smartagent.tools.browser.js';
|
||||
export { DenoTool, type TDenoPermission } from './smartagent.tools.deno.js';
|
||||
export { JsonValidatorTool } from './smartagent.tools.json.js';
|
||||
|
||||
// Export tool search and expert tools
|
||||
export { ToolSearchTool } from './smartagent.tools.search.js';
|
||||
export { ExpertTool } from './smartagent.tools.expert.js';
|
||||
|
||||
// Export all interfaces
|
||||
export * from './smartagent.interfaces.js';
|
||||
|
||||
// Re-export useful types from smartai
|
||||
export {
|
||||
type ISmartAiOptions,
|
||||
type TProvider,
|
||||
type ChatMessage,
|
||||
type ChatOptions,
|
||||
type ChatResponse,
|
||||
} from '@push.rocks/smartai';
|
||||
// Re-export tool() and z so consumers can define tools without extra imports
|
||||
export { tool, jsonSchema } from '@push.rocks/smartai';
|
||||
export { z } from 'zod';
|
||||
export { stepCountIs } from 'ai';
|
||||
|
||||
@@ -3,24 +3,25 @@ import * as path from 'path';
|
||||
|
||||
export { path };
|
||||
|
||||
// third party
|
||||
import { minimatch } from 'minimatch';
|
||||
// ai-sdk core
|
||||
import { streamText, generateText, stepCountIs } from 'ai';
|
||||
|
||||
export { minimatch };
|
||||
export { streamText, generateText, stepCountIs };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartai from '@push.rocks/smartai';
|
||||
import * as smartdeno from '@push.rocks/smartdeno';
|
||||
import * as smartfs from '@push.rocks/smartfs';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartbrowser from '@push.rocks/smartbrowser';
|
||||
import * as smartshell from '@push.rocks/smartshell';
|
||||
export type {
|
||||
ModelMessage,
|
||||
ToolSet,
|
||||
StreamTextResult,
|
||||
} from 'ai';
|
||||
|
||||
export {
|
||||
smartai,
|
||||
smartdeno,
|
||||
smartfs,
|
||||
smartrequest,
|
||||
smartbrowser,
|
||||
smartshell,
|
||||
};
|
||||
// @push.rocks/smartai
|
||||
import { tool, jsonSchema } from '@push.rocks/smartai';
|
||||
|
||||
export { tool, jsonSchema };
|
||||
|
||||
export type { LanguageModelV3 } from '@push.rocks/smartai';
|
||||
|
||||
// zod
|
||||
import { z } from 'zod';
|
||||
|
||||
export { z };
|
||||
|
||||
198
ts/smartagent.classes.agent.ts
Normal file
198
ts/smartagent.classes.agent.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// Retry backoff and context overflow logic derived from opencode (MIT) — https://github.com/sst/opencode
|
||||
|
||||
import * as plugins from './plugins.js';
|
||||
import type { IAgentRunOptions, IAgentRunResult } from './smartagent.interfaces.js';
|
||||
import { ContextOverflowError } from './smartagent.interfaces.js';
|
||||
|
||||
// Retry constants
|
||||
const RETRY_INITIAL_DELAY = 2000;
|
||||
const RETRY_BACKOFF_FACTOR = 2;
|
||||
const RETRY_MAX_DELAY = 30_000;
|
||||
const MAX_RETRY_ATTEMPTS = 8;
|
||||
|
||||
function retryDelay(attempt: number, headers?: Record<string, string>): number {
|
||||
if (headers) {
|
||||
const ms = headers['retry-after-ms'];
|
||||
if (ms) {
|
||||
const n = parseFloat(ms);
|
||||
if (!isNaN(n)) return n;
|
||||
}
|
||||
const after = headers['retry-after'];
|
||||
if (after) {
|
||||
const secs = parseFloat(after);
|
||||
if (!isNaN(secs)) return Math.ceil(secs * 1000);
|
||||
const date = Date.parse(after) - Date.now();
|
||||
if (!isNaN(date) && date > 0) return Math.ceil(date);
|
||||
}
|
||||
}
|
||||
return Math.min(
|
||||
RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1),
|
||||
RETRY_MAX_DELAY,
|
||||
);
|
||||
}
|
||||
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isRetryableError(err: unknown): boolean {
|
||||
const status = (err as any)?.status ?? (err as any)?.statusCode;
|
||||
if (status === 429 || status === 529 || status === 503) return true;
|
||||
if (err instanceof Error) {
|
||||
const msg = err.message.toLowerCase();
|
||||
if (msg.includes('rate limit') || msg.includes('overloaded') || msg.includes('too many requests')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isContextOverflow(err: unknown): boolean {
|
||||
if (err instanceof Error) {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes('context_length_exceeded') ||
|
||||
msg.includes('context window') ||
|
||||
msg.includes('maximum context length') ||
|
||||
msg.includes('too many tokens') ||
|
||||
msg.includes('input is too long') ||
|
||||
(err as any)?.name === 'AI_ContextWindowExceededError'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResult> {
|
||||
let stepCount = 0;
|
||||
let attempt = 0;
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
|
||||
const tools = options.tools ?? {};
|
||||
|
||||
// Add a no-op sink for repaired-but-unrecognised tool calls
|
||||
const allTools: plugins.ToolSet = {
|
||||
...tools,
|
||||
invalid: plugins.tool({
|
||||
description: 'Sink for unrecognised tool calls — returns an error message to the model',
|
||||
inputSchema: plugins.z.object({
|
||||
tool: plugins.z.string(),
|
||||
error: plugins.z.string(),
|
||||
}),
|
||||
execute: async ({ tool, error }: { tool: string; error: string }) =>
|
||||
`Unknown tool "${tool}": ${error}`,
|
||||
}),
|
||||
};
|
||||
|
||||
// Build messages — streamText requires either prompt OR messages, not both
|
||||
let messages: plugins.ModelMessage[] = options.messages
|
||||
? [...options.messages, { role: 'user' as const, content: options.prompt }]
|
||||
: [{ role: 'user' as const, content: options.prompt }];
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = plugins.streamText({
|
||||
model: options.model,
|
||||
system: options.system,
|
||||
messages,
|
||||
tools: allTools,
|
||||
stopWhen: plugins.stepCountIs(options.maxSteps ?? 20),
|
||||
maxRetries: 0, // handled manually below
|
||||
abortSignal: options.abort,
|
||||
|
||||
experimental_repairToolCall: async ({ toolCall, tools: availableTools, error }) => {
|
||||
const lower = toolCall.toolName.toLowerCase();
|
||||
if (lower !== toolCall.toolName && (availableTools as any)[lower]) {
|
||||
return { ...toolCall, toolName: lower };
|
||||
}
|
||||
return {
|
||||
...toolCall,
|
||||
toolName: 'invalid',
|
||||
args: JSON.stringify({
|
||||
tool: toolCall.toolName,
|
||||
error: String(error),
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
onChunk: ({ chunk }) => {
|
||||
if (chunk.type === 'text-delta' && options.onToken) {
|
||||
options.onToken((chunk as any).textDelta ?? (chunk as any).text ?? '');
|
||||
}
|
||||
},
|
||||
|
||||
experimental_onToolCallStart: options.onToolCall
|
||||
? ({ toolCall }) => {
|
||||
options.onToolCall!(toolCall.toolName, (toolCall as any).input ?? (toolCall as any).args);
|
||||
}
|
||||
: undefined,
|
||||
|
||||
experimental_onToolCallFinish: options.onToolResult
|
||||
? ({ toolCall }) => {
|
||||
options.onToolResult!(toolCall.toolName, (toolCall as any).result);
|
||||
}
|
||||
: undefined,
|
||||
|
||||
onStepFinish: ({ usage }) => {
|
||||
stepCount++;
|
||||
totalInput += usage?.inputTokens ?? 0;
|
||||
totalOutput += usage?.outputTokens ?? 0;
|
||||
},
|
||||
});
|
||||
|
||||
// Consume the stream and collect results
|
||||
const text = await result.text;
|
||||
const finishReason = await result.finishReason;
|
||||
const responseData = await result.response;
|
||||
|
||||
attempt = 0; // reset on success
|
||||
|
||||
return {
|
||||
text,
|
||||
messages: responseData.messages as plugins.ModelMessage[],
|
||||
steps: stepCount,
|
||||
finishReason,
|
||||
usage: {
|
||||
inputTokens: totalInput,
|
||||
outputTokens: totalOutput,
|
||||
totalTokens: totalInput + totalOutput,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
// Abort — don't retry
|
||||
if (err instanceof DOMException && err.name === 'AbortError') throw err;
|
||||
|
||||
// Rate limit / overload — retry with backoff
|
||||
if (isRetryableError(err) && attempt < MAX_RETRY_ATTEMPTS) {
|
||||
attempt++;
|
||||
const headers = (err as any)?.responseHeaders ?? (err as any)?.headers;
|
||||
const delay = retryDelay(attempt, headers);
|
||||
await sleep(delay, options.abort);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Context overflow — compact and retry if handler provided
|
||||
if (isContextOverflow(err)) {
|
||||
if (!options.onContextOverflow) throw new ContextOverflowError();
|
||||
messages = await options.onContextOverflow(messages);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,775 +0,0 @@
|
||||
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;
|
||||
/** Callback fired for each token during LLM generation */
|
||||
onToken?: (token: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DriverAgent - Executes tasks by reasoning and proposing tool calls
|
||||
* Works in conjunction with GuardianAgent for approval
|
||||
*/
|
||||
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();
|
||||
private onToken?: (token: string) => void;
|
||||
private isInThinkingMode = false; // Track thinking/content state for markers
|
||||
|
||||
constructor(
|
||||
provider: plugins.smartai.MultiModalModel,
|
||||
options?: IDriverAgentOptions | string
|
||||
) {
|
||||
this.provider = provider;
|
||||
|
||||
// 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;
|
||||
this.onToken = options?.onToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the token callback for streaming mode
|
||||
* @param callback Function to call for each generated token
|
||||
*/
|
||||
public setOnToken(callback: (token: string) => void): void {
|
||||
this.onToken = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool for use by the driver
|
||||
*/
|
||||
public registerTool(tool: BaseToolWrapper): void {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered tools
|
||||
*/
|
||||
public getTools(): Map<string, BaseToolWrapper> {
|
||||
return this.tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a new conversation for a task
|
||||
* @param task The task description
|
||||
* @param images Optional base64-encoded images for vision tasks
|
||||
*/
|
||||
public async startTask(task: string, images?: string[]): Promise<interfaces.IAgentMessage> {
|
||||
// Reset message history
|
||||
this.messageHistory = [];
|
||||
|
||||
// Build the user message based on available tools
|
||||
const hasTools = this.tools.size > 0;
|
||||
let userMessage: string;
|
||||
if (hasTools) {
|
||||
userMessage = `TASK: ${task}\n\nAnalyze this task and determine what actions are needed. If you need to use a tool, provide a tool call proposal.`;
|
||||
} else {
|
||||
userMessage = `TASK: ${task}\n\nComplete this task directly. When done, wrap your final output in <task_complete>your output here</task_complete> tags.`;
|
||||
}
|
||||
|
||||
// Add to history
|
||||
this.messageHistory.push({
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
});
|
||||
|
||||
// Build the system message - adapt based on available tools
|
||||
let fullSystemMessage: string;
|
||||
if (hasTools) {
|
||||
const toolDescriptions = this.buildToolDescriptions();
|
||||
fullSystemMessage = `${this.systemMessage}\n\n## Available Tools\n${toolDescriptions}`;
|
||||
} else {
|
||||
// Use a simpler system message when no tools are available
|
||||
fullSystemMessage = this.getNoToolsSystemMessage();
|
||||
}
|
||||
|
||||
// Get response from provider - use streaming if available and callback is set
|
||||
let response: plugins.smartai.ChatResponse;
|
||||
|
||||
if (this.onToken && typeof (this.provider as any).chatStreaming === 'function') {
|
||||
// Use streaming mode with token callback
|
||||
response = await (this.provider as any).chatStreaming({
|
||||
systemMessage: fullSystemMessage,
|
||||
userMessage: userMessage,
|
||||
messageHistory: [],
|
||||
images: images,
|
||||
onToken: this.onToken,
|
||||
});
|
||||
} else {
|
||||
// Fallback to non-streaming mode
|
||||
response = await this.provider.chat({
|
||||
systemMessage: fullSystemMessage,
|
||||
userMessage: userMessage,
|
||||
messageHistory: [],
|
||||
images: images,
|
||||
});
|
||||
}
|
||||
|
||||
// Add assistant response to history (store images if provided, preserve reasoning for GPT-OSS)
|
||||
const historyMessage: plugins.smartai.ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: response.message,
|
||||
reasoning: response.reasoning,
|
||||
};
|
||||
this.messageHistory.push(historyMessage);
|
||||
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: response.message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue the conversation with feedback or results
|
||||
*/
|
||||
public async continueWithMessage(message: string): Promise<interfaces.IAgentMessage> {
|
||||
// Add the new message to history
|
||||
this.messageHistory.push({
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
|
||||
// Build the system message - adapt based on available tools
|
||||
const hasTools = this.tools.size > 0;
|
||||
let fullSystemMessage: string;
|
||||
if (hasTools) {
|
||||
const toolDescriptions = this.buildToolDescriptions();
|
||||
fullSystemMessage = `${this.systemMessage}\n\n## Available Tools\n${toolDescriptions}`;
|
||||
} else {
|
||||
fullSystemMessage = this.getNoToolsSystemMessage();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Get response from provider - use streaming if available and callback is set
|
||||
let response: plugins.smartai.ChatResponse;
|
||||
|
||||
if (this.onToken && typeof (this.provider as any).chatStreaming === 'function') {
|
||||
// Use streaming mode with token callback
|
||||
response = await (this.provider as any).chatStreaming({
|
||||
systemMessage: fullSystemMessage,
|
||||
userMessage: message,
|
||||
messageHistory: historyForChat,
|
||||
onToken: this.onToken,
|
||||
});
|
||||
} else {
|
||||
// Fallback to non-streaming mode
|
||||
response = await this.provider.chat({
|
||||
systemMessage: fullSystemMessage,
|
||||
userMessage: message,
|
||||
messageHistory: historyForChat,
|
||||
});
|
||||
}
|
||||
|
||||
// Add assistant response to history (preserve reasoning for GPT-OSS)
|
||||
this.messageHistory.push({
|
||||
role: 'assistant',
|
||||
content: response.message,
|
||||
reasoning: response.reasoning,
|
||||
});
|
||||
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: response.message,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool call proposals from assistant response
|
||||
*/
|
||||
public parseToolCallProposals(response: string): interfaces.IToolCallProposal[] {
|
||||
const proposals: interfaces.IToolCallProposal[] = [];
|
||||
|
||||
// Match <tool_call>...</tool_call> blocks
|
||||
const toolCallRegex = /<tool_call>([\s\S]*?)<\/tool_call>/g;
|
||||
let match;
|
||||
|
||||
while ((match = toolCallRegex.exec(response)) !== null) {
|
||||
const content = match[1];
|
||||
|
||||
try {
|
||||
const proposal = this.parseToolCallContent(content);
|
||||
if (proposal) {
|
||||
proposals.push(proposal);
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip malformed tool calls
|
||||
console.warn('Failed to parse tool call:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return proposals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the content inside a tool_call block
|
||||
*/
|
||||
private parseToolCallContent(content: string): interfaces.IToolCallProposal | null {
|
||||
// Extract tool name
|
||||
const toolMatch = content.match(/<tool>(.*?)<\/tool>/s);
|
||||
if (!toolMatch) return null;
|
||||
const toolName = toolMatch[1].trim();
|
||||
|
||||
// Extract action
|
||||
const actionMatch = content.match(/<action>(.*?)<\/action>/s);
|
||||
if (!actionMatch) return null;
|
||||
const action = actionMatch[1].trim();
|
||||
|
||||
// Extract params (JSON)
|
||||
const paramsMatch = content.match(/<params>([\s\S]*?)<\/params>/);
|
||||
let params: Record<string, unknown> = {};
|
||||
if (paramsMatch) {
|
||||
try {
|
||||
params = JSON.parse(paramsMatch[1].trim());
|
||||
} catch {
|
||||
// Try to extract individual parameters if JSON fails
|
||||
params = this.extractParamsFromXml(paramsMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract reasoning (optional)
|
||||
const reasoningMatch = content.match(/<reasoning>([\s\S]*?)<\/reasoning>/);
|
||||
const reasoning = reasoningMatch ? reasoningMatch[1].trim() : undefined;
|
||||
|
||||
return {
|
||||
proposalId: this.generateProposalId(),
|
||||
toolName,
|
||||
action,
|
||||
params,
|
||||
reasoning,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from XML-like format when JSON parsing fails
|
||||
*/
|
||||
private extractParamsFromXml(content: string): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/g;
|
||||
let match;
|
||||
|
||||
while ((match = paramRegex.exec(content)) !== null) {
|
||||
const key = match[1];
|
||||
let value: unknown = match[2].trim();
|
||||
|
||||
// Try to parse as JSON for arrays/objects
|
||||
try {
|
||||
value = JSON.parse(value as string);
|
||||
} catch {
|
||||
// Keep as string if not valid JSON
|
||||
}
|
||||
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the response indicates task completion
|
||||
*/
|
||||
public isTaskComplete(response: string): boolean {
|
||||
// Check for explicit completion markers
|
||||
const completionMarkers = [
|
||||
'<task_complete>',
|
||||
'<task_completed>',
|
||||
'TASK COMPLETE',
|
||||
'Task completed successfully',
|
||||
];
|
||||
|
||||
const lowerResponse = response.toLowerCase();
|
||||
return completionMarkers.some(marker =>
|
||||
lowerResponse.includes(marker.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the response needs clarification or user input
|
||||
*/
|
||||
public needsClarification(response: string): boolean {
|
||||
const clarificationMarkers = [
|
||||
'<needs_clarification>',
|
||||
'<question>',
|
||||
'please clarify',
|
||||
'could you specify',
|
||||
'what do you mean by',
|
||||
];
|
||||
|
||||
const lowerResponse = response.toLowerCase();
|
||||
return clarificationMarkers.some(marker =>
|
||||
lowerResponse.includes(marker.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the final result from a completed task
|
||||
*/
|
||||
public extractTaskResult(response: string): string | null {
|
||||
// Try to extract from result tags
|
||||
const resultMatch = response.match(/<task_result>([\s\S]*?)<\/task_result>/);
|
||||
if (resultMatch) {
|
||||
return resultMatch[1].trim();
|
||||
}
|
||||
|
||||
const completeMatch = response.match(/<task_complete>([\s\S]*?)<\/task_complete>/);
|
||||
if (completeMatch) {
|
||||
return completeMatch[1].trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tool descriptions for the system message
|
||||
*/
|
||||
private buildToolDescriptions(): string {
|
||||
const descriptions: string[] = [];
|
||||
|
||||
for (const tool of this.tools.values()) {
|
||||
descriptions.push(tool.getFullDescription());
|
||||
}
|
||||
|
||||
return descriptions.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique proposal ID
|
||||
*/
|
||||
private generateProposalId(): string {
|
||||
return `prop_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default system message for the driver
|
||||
*/
|
||||
private getDefaultSystemMessage(): string {
|
||||
return `You are an AI assistant that executes tasks by using available tools.
|
||||
|
||||
## Your Role
|
||||
You analyze tasks, break them down into steps, and use tools to accomplish goals.
|
||||
|
||||
## CRITICAL: Tool Usage Format
|
||||
To use a tool, you MUST literally write out the XML tags in your response. The system parses your output looking for these exact tags. Do NOT just describe or mention the tool call - you must OUTPUT the actual XML.
|
||||
|
||||
CORRECT (the XML is in the output):
|
||||
<tool_call>
|
||||
<tool>json</tool>
|
||||
<action>validate</action>
|
||||
<params>{"jsonString": "{\\"key\\":\\"value\\"}"}</params>
|
||||
</tool_call>
|
||||
|
||||
WRONG (just describing, no actual XML):
|
||||
"I will call json.validate now" or "Let me use the tool"
|
||||
|
||||
## Guidelines
|
||||
1. Think step by step about what needs to be done
|
||||
2. When you need a tool, OUTPUT the <tool_call> XML tags - do not just mention them
|
||||
3. Only propose ONE tool call at a time
|
||||
4. Wait for the result before proposing the next action
|
||||
5. When the task is complete, OUTPUT:
|
||||
|
||||
<task_complete>
|
||||
Your final result here
|
||||
</task_complete>
|
||||
|
||||
## Important
|
||||
- The <tool_call> and <task_complete> tags MUST appear literally in your response
|
||||
- If you just say "I'll call the tool" without the actual XML, it will NOT work
|
||||
- If you need clarification, ask using <needs_clarification>your question</needs_clarification>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the system message when no tools are available
|
||||
* Used for direct task completion without tool usage
|
||||
*/
|
||||
private getNoToolsSystemMessage(): string {
|
||||
// Use custom system message if provided, otherwise use a simple default
|
||||
if (this.systemMessage && this.systemMessage !== this.getDefaultSystemMessage()) {
|
||||
return this.systemMessage;
|
||||
}
|
||||
|
||||
return `You are an AI assistant that completes tasks directly.
|
||||
|
||||
## Your Role
|
||||
You analyze tasks and provide complete, high-quality outputs.
|
||||
|
||||
## Output Format
|
||||
When you have completed the task, wrap your final output in task_complete tags:
|
||||
|
||||
<task_complete>
|
||||
Your complete output here
|
||||
</task_complete>
|
||||
|
||||
## Guidelines
|
||||
1. Analyze the task requirements carefully
|
||||
2. Provide a complete and accurate response
|
||||
3. Always wrap your final output in <task_complete></task_complete> tags
|
||||
4. If you need clarification, ask using <needs_clarification>your question</needs_clarification>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the conversation state
|
||||
*/
|
||||
public reset(): void {
|
||||
this.messageHistory = [];
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Native Tool Calling Support
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Start a task with native tool calling support
|
||||
* Uses Ollama's native tool calling API instead of XML parsing
|
||||
* @param task The task description
|
||||
* @param images Optional base64-encoded images for vision tasks
|
||||
* @returns Response with content, reasoning, and any tool calls
|
||||
*/
|
||||
public async startTaskWithNativeTools(
|
||||
task: string,
|
||||
images?: string[]
|
||||
): Promise<{ message: interfaces.IAgentMessage; toolCalls?: interfaces.INativeToolCall[] }> {
|
||||
// Reset message history
|
||||
this.messageHistory = [];
|
||||
|
||||
// Build simple user message (no XML instructions needed for native tool calling)
|
||||
const userMessage = `TASK: ${task}\n\nComplete this task using the available tools. When done, provide your final output.`;
|
||||
|
||||
// Add to history
|
||||
this.messageHistory.push({
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
});
|
||||
|
||||
// Build system message for native tool calling
|
||||
const fullSystemMessage = this.getNativeToolsSystemMessage();
|
||||
|
||||
// Get tools in JSON schema format
|
||||
const tools = this.getToolsAsJsonSchema();
|
||||
|
||||
// Check if provider supports native tool calling (Ollama)
|
||||
const provider = this.provider as any;
|
||||
if (typeof provider.collectStreamResponse !== 'function') {
|
||||
throw new Error('Provider does not support native tool calling. Use startTask() instead.');
|
||||
}
|
||||
|
||||
// Use collectStreamResponse for streaming support with tools
|
||||
const response = await provider.collectStreamResponse(
|
||||
{
|
||||
systemMessage: fullSystemMessage,
|
||||
userMessage: userMessage,
|
||||
messageHistory: [],
|
||||
images: images,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
},
|
||||
// Pass onToken callback through onChunk for streaming with thinking markers
|
||||
this.onToken ? (chunk: any) => {
|
||||
if (chunk.thinking && this.onToken) {
|
||||
// Add marker only when transitioning INTO thinking mode
|
||||
if (!this.isInThinkingMode) {
|
||||
this.onToken('\n[THINKING] ');
|
||||
this.isInThinkingMode = true;
|
||||
}
|
||||
this.onToken(chunk.thinking);
|
||||
}
|
||||
if (chunk.content && this.onToken) {
|
||||
// Add marker when transitioning OUT of thinking mode
|
||||
if (this.isInThinkingMode) {
|
||||
this.onToken('\n[OUTPUT] ');
|
||||
this.isInThinkingMode = false;
|
||||
}
|
||||
this.onToken(chunk.content);
|
||||
}
|
||||
} : undefined
|
||||
);
|
||||
|
||||
// Reset thinking state after response completes
|
||||
this.isInThinkingMode = false;
|
||||
|
||||
// Add assistant response to history
|
||||
const historyMessage: any = {
|
||||
role: 'assistant',
|
||||
content: response.message || '',
|
||||
reasoning: response.thinking || response.reasoning,
|
||||
};
|
||||
|
||||
// CRITICAL: Preserve tool_calls in history for native tool calling
|
||||
// Without this, the model doesn't know it already called a tool and loops forever
|
||||
if (response.toolCalls && response.toolCalls.length > 0) {
|
||||
historyMessage.tool_calls = response.toolCalls.map((tc: any) => ({
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
this.messageHistory.push(historyMessage as unknown as plugins.smartai.ChatMessage);
|
||||
|
||||
// Convert Ollama tool calls to our format
|
||||
let toolCalls: interfaces.INativeToolCall[] | undefined;
|
||||
if (response.toolCalls && response.toolCalls.length > 0) {
|
||||
toolCalls = response.toolCalls.map((tc: any) => ({
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
index: tc.function.index,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: response.message || '',
|
||||
},
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue conversation with native tool calling support
|
||||
* @param message The message to continue with (e.g., tool result)
|
||||
* @param toolName Optional tool name - when provided, message is added as role: 'tool' instead of 'user'
|
||||
* @returns Response with content, reasoning, and any tool calls
|
||||
*/
|
||||
public async continueWithNativeTools(
|
||||
message: string,
|
||||
toolName?: string
|
||||
): Promise<{ message: interfaces.IAgentMessage; toolCalls?: interfaces.INativeToolCall[] }> {
|
||||
// Add the new message to history
|
||||
if (toolName) {
|
||||
// Tool result - must use role: 'tool' for native tool calling
|
||||
// The 'tool' role is supported by providers but not in the ChatMessage type
|
||||
this.messageHistory.push({
|
||||
role: 'tool',
|
||||
content: message,
|
||||
toolName: toolName,
|
||||
} as unknown as plugins.smartai.ChatMessage);
|
||||
} else {
|
||||
// Regular user message
|
||||
this.messageHistory.push({
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
}
|
||||
|
||||
// Build system message
|
||||
const fullSystemMessage = this.getNativeToolsSystemMessage();
|
||||
|
||||
// Get tools in JSON schema format
|
||||
const tools = this.getToolsAsJsonSchema();
|
||||
|
||||
// Get response from provider with history windowing
|
||||
// For tool results, include the full history (with tool message)
|
||||
// For regular user messages, exclude the last message (it becomes userMessage)
|
||||
let historyForChat: plugins.smartai.ChatMessage[];
|
||||
const fullHistory = toolName
|
||||
? this.messageHistory // Include tool result in history
|
||||
: this.messageHistory.slice(0, -1); // Exclude last user message
|
||||
|
||||
if (this.maxHistoryMessages > 0 && fullHistory.length > this.maxHistoryMessages) {
|
||||
historyForChat = [
|
||||
fullHistory[0],
|
||||
...fullHistory.slice(-(this.maxHistoryMessages - 1)),
|
||||
];
|
||||
} else {
|
||||
historyForChat = fullHistory;
|
||||
}
|
||||
|
||||
// Check if provider supports native tool calling
|
||||
const provider = this.provider as any;
|
||||
if (typeof provider.collectStreamResponse !== 'function') {
|
||||
throw new Error('Provider does not support native tool calling. Use continueWithMessage() instead.');
|
||||
}
|
||||
|
||||
// For tool results, use a continuation prompt instead of repeating the result
|
||||
const userMessage = toolName
|
||||
? 'Continue with the task. The tool result has been provided above.'
|
||||
: message;
|
||||
|
||||
// Use collectStreamResponse for streaming support with tools
|
||||
const response = await provider.collectStreamResponse(
|
||||
{
|
||||
systemMessage: fullSystemMessage,
|
||||
userMessage: userMessage,
|
||||
messageHistory: historyForChat,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
},
|
||||
// Pass onToken callback through onChunk for streaming with thinking markers
|
||||
this.onToken ? (chunk: any) => {
|
||||
if (chunk.thinking && this.onToken) {
|
||||
// Add marker only when transitioning INTO thinking mode
|
||||
if (!this.isInThinkingMode) {
|
||||
this.onToken('\n[THINKING] ');
|
||||
this.isInThinkingMode = true;
|
||||
}
|
||||
this.onToken(chunk.thinking);
|
||||
}
|
||||
if (chunk.content && this.onToken) {
|
||||
// Add marker when transitioning OUT of thinking mode
|
||||
if (this.isInThinkingMode) {
|
||||
this.onToken('\n[OUTPUT] ');
|
||||
this.isInThinkingMode = false;
|
||||
}
|
||||
this.onToken(chunk.content);
|
||||
}
|
||||
} : undefined
|
||||
);
|
||||
|
||||
// Reset thinking state after response completes
|
||||
this.isInThinkingMode = false;
|
||||
|
||||
// Add assistant response to history
|
||||
const historyMessage: any = {
|
||||
role: 'assistant',
|
||||
content: response.message || '',
|
||||
reasoning: response.thinking || response.reasoning,
|
||||
};
|
||||
|
||||
// CRITICAL: Preserve tool_calls in history for native tool calling
|
||||
// Without this, the model doesn't know it already called a tool and loops forever
|
||||
if (response.toolCalls && response.toolCalls.length > 0) {
|
||||
historyMessage.tool_calls = response.toolCalls.map((tc: any) => ({
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
this.messageHistory.push(historyMessage as unknown as plugins.smartai.ChatMessage);
|
||||
|
||||
// Convert Ollama tool calls to our format
|
||||
let toolCalls: interfaces.INativeToolCall[] | undefined;
|
||||
if (response.toolCalls && response.toolCalls.length > 0) {
|
||||
toolCalls = response.toolCalls.map((tc: any) => ({
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
index: tc.function.index,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: response.message || '',
|
||||
},
|
||||
toolCalls,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system message for native tool calling mode
|
||||
* Simplified prompt that lets the model use tools naturally
|
||||
*/
|
||||
private getNativeToolsSystemMessage(): string {
|
||||
return `You are an AI assistant that executes tasks by using available tools.
|
||||
|
||||
## Your Role
|
||||
You analyze tasks, break them down into steps, and use tools to accomplish goals.
|
||||
|
||||
## Guidelines
|
||||
1. Think step by step about what needs to be done
|
||||
2. Use the available tools to complete the task
|
||||
3. Process tool results and continue until the task is complete
|
||||
4. When the task is complete, provide a final summary
|
||||
|
||||
## Important
|
||||
- Use tools when needed to gather information or perform actions
|
||||
- If you need clarification, ask the user
|
||||
- Always verify your work before marking the task complete`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert registered tools to Ollama JSON Schema format for native tool calling
|
||||
* Each tool action becomes a separate function with name format: "toolName_actionName"
|
||||
* @returns Array of IOllamaTool compatible tool definitions
|
||||
*/
|
||||
public getToolsAsJsonSchema(): plugins.smartai.IOllamaTool[] {
|
||||
const tools: plugins.smartai.IOllamaTool[] = [];
|
||||
|
||||
for (const tool of this.tools.values()) {
|
||||
for (const action of tool.actions) {
|
||||
// Build the tool definition in Ollama format
|
||||
const toolDef: plugins.smartai.IOllamaTool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: `${tool.name}_${action.name}`, // e.g., "json_validate"
|
||||
description: `[${tool.name}] ${action.description}`,
|
||||
parameters: action.parameters as plugins.smartai.IOllamaTool['function']['parameters'],
|
||||
},
|
||||
};
|
||||
tools.push(toolDef);
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse native tool calls from provider response into IToolCallProposal format
|
||||
* @param toolCalls Array of native tool calls from the provider
|
||||
* @returns Array of IToolCallProposal ready for execution
|
||||
*/
|
||||
public parseNativeToolCalls(
|
||||
toolCalls: interfaces.INativeToolCall[]
|
||||
): interfaces.IToolCallProposal[] {
|
||||
return toolCalls.map(tc => {
|
||||
// Split "json_validate" -> toolName="json", action="validate"
|
||||
const fullName = tc.function.name;
|
||||
const underscoreIndex = fullName.indexOf('_');
|
||||
|
||||
let toolName: string;
|
||||
let action: string;
|
||||
|
||||
if (underscoreIndex > 0) {
|
||||
toolName = fullName.substring(0, underscoreIndex);
|
||||
action = fullName.substring(underscoreIndex + 1);
|
||||
} else {
|
||||
// Fallback: treat entire name as tool name with empty action
|
||||
toolName = fullName;
|
||||
action = '';
|
||||
}
|
||||
|
||||
return {
|
||||
proposalId: this.generateProposalId(),
|
||||
toolName,
|
||||
action,
|
||||
params: tc.function.arguments,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,692 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
import { DriverAgent } from './smartagent.classes.driveragent.js';
|
||||
import { GuardianAgent } from './smartagent.classes.guardianagent.js';
|
||||
import { FilesystemTool } from './smartagent.tools.filesystem.js';
|
||||
import { HttpTool } from './smartagent.tools.http.js';
|
||||
import { ShellTool } from './smartagent.tools.shell.js';
|
||||
import { BrowserTool } from './smartagent.tools.browser.js';
|
||||
import { DenoTool } from './smartagent.tools.deno.js';
|
||||
import { ToolRegistry } from './smartagent.classes.toolregistry.js';
|
||||
import { ToolSearchTool } from './smartagent.tools.search.js';
|
||||
import { ExpertTool } from './smartagent.tools.expert.js';
|
||||
|
||||
/**
|
||||
* DualAgentOrchestrator - Coordinates Driver and Guardian agents
|
||||
* Manages the complete lifecycle of task execution with tool approval
|
||||
*/
|
||||
export class DualAgentOrchestrator {
|
||||
private options: interfaces.IDualAgentOptions;
|
||||
private smartai: plugins.smartai.SmartAi;
|
||||
private driverProvider: plugins.smartai.MultiModalModel;
|
||||
private guardianProvider: plugins.smartai.MultiModalModel;
|
||||
private driver: DriverAgent;
|
||||
private guardian: GuardianAgent;
|
||||
private registry: ToolRegistry = new ToolRegistry();
|
||||
private isRunning = false;
|
||||
private conversationHistory: interfaces.IAgentMessage[] = [];
|
||||
private ownsSmartAi = true; // true if we created the SmartAi instance, false if it was provided
|
||||
|
||||
constructor(options: interfaces.IDualAgentOptions) {
|
||||
this.options = {
|
||||
maxIterations: 20,
|
||||
maxConsecutiveRejections: 3,
|
||||
defaultProvider: 'openai',
|
||||
maxResultChars: 15000,
|
||||
maxHistoryMessages: 20,
|
||||
...options,
|
||||
};
|
||||
|
||||
// Use existing SmartAi instance if provided, otherwise create a new one
|
||||
if (options.smartAiInstance) {
|
||||
this.smartai = options.smartAiInstance;
|
||||
this.ownsSmartAi = false; // Don't manage lifecycle of provided instance
|
||||
} else {
|
||||
this.smartai = new plugins.smartai.SmartAi(options);
|
||||
this.ownsSmartAi = true;
|
||||
}
|
||||
// Note: Don't access providers here - they don't exist until start() is called
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider by name
|
||||
*/
|
||||
private getProviderByName(providerName: plugins.smartai.TProvider): plugins.smartai.MultiModalModel {
|
||||
switch (providerName) {
|
||||
case 'openai':
|
||||
return this.smartai.openaiProvider;
|
||||
case 'anthropic':
|
||||
return this.smartai.anthropicProvider;
|
||||
case 'perplexity':
|
||||
return this.smartai.perplexityProvider;
|
||||
case 'ollama':
|
||||
return this.smartai.ollamaProvider;
|
||||
case 'groq':
|
||||
return this.smartai.groqProvider;
|
||||
case 'xai':
|
||||
return this.smartai.xaiProvider;
|
||||
case 'exo':
|
||||
return this.smartai.exoProvider;
|
||||
default:
|
||||
return this.smartai.openaiProvider;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a progress event if callback is configured
|
||||
*/
|
||||
private emitProgress(event: Omit<interfaces.IProgressEvent, 'timestamp' | 'logLevel' | 'logMessage'>): void {
|
||||
if (this.options.onProgress) {
|
||||
const prefix = this.options.logPrefix ? `${this.options.logPrefix} ` : '';
|
||||
const { logLevel, logMessage } = this.formatProgressEvent(event, prefix);
|
||||
|
||||
this.options.onProgress({
|
||||
...event,
|
||||
timestamp: new Date(),
|
||||
logLevel,
|
||||
logMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a progress event into a log level and message
|
||||
*/
|
||||
private formatProgressEvent(
|
||||
event: Omit<interfaces.IProgressEvent, 'timestamp' | 'logLevel' | 'logMessage'>,
|
||||
prefix: string
|
||||
): { logLevel: interfaces.TLogLevel; logMessage: string } {
|
||||
switch (event.type) {
|
||||
case 'task_started':
|
||||
return { logLevel: 'info', logMessage: `${prefix}Task started` };
|
||||
case 'iteration_started':
|
||||
return { logLevel: 'info', logMessage: `${prefix}Iteration ${event.iteration}/${event.maxIterations}` };
|
||||
case 'tool_proposed':
|
||||
return { logLevel: 'info', logMessage: `${prefix} → Proposing: ${event.toolName}.${event.action}` };
|
||||
case 'guardian_evaluating':
|
||||
return { logLevel: 'info', logMessage: `${prefix} ⏳ Guardian evaluating...` };
|
||||
case 'tool_approved':
|
||||
return { logLevel: 'info', logMessage: `${prefix} ✓ Approved: ${event.toolName}.${event.action}` };
|
||||
case 'tool_rejected':
|
||||
return { logLevel: 'warn', logMessage: `${prefix} ✗ Rejected: ${event.toolName}.${event.action} - ${event.reason}` };
|
||||
case 'tool_executing':
|
||||
return { logLevel: 'info', logMessage: `${prefix} ⚡ Executing: ${event.toolName}.${event.action}...` };
|
||||
case 'tool_completed':
|
||||
return { logLevel: 'info', logMessage: `${prefix} ✓ Completed: ${event.message}` };
|
||||
case 'task_completed':
|
||||
return { logLevel: 'success', logMessage: `${prefix}Task completed in ${event.iteration} iterations` };
|
||||
case 'clarification_needed':
|
||||
return { logLevel: 'warn', logMessage: `${prefix}Clarification needed from user` };
|
||||
case 'max_iterations':
|
||||
return { logLevel: 'error', logMessage: `${prefix}${event.message}` };
|
||||
case 'max_rejections':
|
||||
return { logLevel: 'error', logMessage: `${prefix}${event.message}` };
|
||||
default:
|
||||
return { logLevel: 'info', logMessage: `${prefix}${event.type}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom tool with optional visibility settings
|
||||
*/
|
||||
public registerTool(
|
||||
tool: BaseToolWrapper,
|
||||
options?: interfaces.IToolRegistrationOptions
|
||||
): void {
|
||||
this.registry.register(tool, options);
|
||||
|
||||
// If initial visibility and agents exist, register with them
|
||||
const visibility = options?.visibility ?? 'initial';
|
||||
if (visibility === 'initial') {
|
||||
if (this.driver) {
|
||||
this.driver.registerTool(tool);
|
||||
}
|
||||
if (this.guardian) {
|
||||
this.guardian.registerTool(tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an expert (subagent) as a tool
|
||||
*/
|
||||
public registerExpert(config: interfaces.IExpertConfig): void {
|
||||
const expert = new ExpertTool(config, this.smartai);
|
||||
this.registerTool(expert, {
|
||||
visibility: config.visibility,
|
||||
tags: config.tags,
|
||||
category: config.category ?? 'expert',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable tool search functionality
|
||||
* This adds a 'tools' tool that allows the Driver to discover and activate on-demand tools
|
||||
*/
|
||||
public enableToolSearch(): void {
|
||||
const searchTool = new ToolSearchTool(this.registry, (tool) => {
|
||||
// Callback when an on-demand tool is activated
|
||||
if (this.driver) {
|
||||
this.driver.registerTool(tool);
|
||||
}
|
||||
if (this.guardian) {
|
||||
this.guardian.registerTool(tool);
|
||||
}
|
||||
});
|
||||
this.registerTool(searchTool); // Always initial visibility
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all standard tools
|
||||
*/
|
||||
public registerStandardTools(): void {
|
||||
const standardTools = [
|
||||
new FilesystemTool(),
|
||||
new HttpTool(),
|
||||
new ShellTool(),
|
||||
new BrowserTool(),
|
||||
new DenoTool(),
|
||||
];
|
||||
|
||||
for (const tool of standardTools) {
|
||||
this.registerTool(tool);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a scoped filesystem tool that can only access files within the specified directory
|
||||
* @param basePath The directory to scope filesystem operations to
|
||||
* @param excludePatterns Optional glob patterns to exclude from listings (e.g., ['.nogit/**', 'node_modules/**'])
|
||||
*/
|
||||
public registerScopedFilesystemTool(basePath: string, excludePatterns?: string[]): void {
|
||||
const scopedTool = new FilesystemTool({ basePath, excludePatterns });
|
||||
this.registerTool(scopedTool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all tools (eager loading)
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Start smartai only if we created it (external instances should already be started)
|
||||
if (this.ownsSmartAi) {
|
||||
await this.smartai.start();
|
||||
}
|
||||
|
||||
// NOW get providers (after they've been initialized by smartai.start())
|
||||
this.driverProvider = this.getProviderByName(this.options.defaultProvider!);
|
||||
this.guardianProvider = this.options.guardianProvider
|
||||
? this.getProviderByName(this.options.guardianProvider)
|
||||
: this.driverProvider;
|
||||
|
||||
// NOW create agents with initialized providers
|
||||
// Set up token callback wrapper if streaming is enabled
|
||||
const driverOnToken = this.options.onToken
|
||||
? (token: string) => this.options.onToken!(token, 'driver')
|
||||
: undefined;
|
||||
|
||||
this.driver = new DriverAgent(this.driverProvider, {
|
||||
systemMessage: this.options.driverSystemMessage,
|
||||
maxHistoryMessages: this.options.maxHistoryMessages,
|
||||
onToken: driverOnToken,
|
||||
});
|
||||
this.guardian = new GuardianAgent(this.guardianProvider, this.options.guardianPolicyPrompt);
|
||||
|
||||
// Register visible tools with agents
|
||||
for (const tool of this.registry.getVisibleTools()) {
|
||||
this.driver.registerTool(tool);
|
||||
this.guardian.registerTool(tool);
|
||||
}
|
||||
|
||||
// Initialize visible tools
|
||||
await this.registry.initializeVisibleTools();
|
||||
this.isRunning = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all tools
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
await this.registry.cleanup();
|
||||
|
||||
// Only stop smartai if we created it (don't stop external instances)
|
||||
if (this.ownsSmartAi) {
|
||||
await this.smartai.stop();
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
if (this.driver) {
|
||||
this.driver.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task through the dual-agent system
|
||||
* @param task The task description
|
||||
* @param options Optional task run options (e.g., images for vision tasks)
|
||||
*/
|
||||
public async run(task: string, options?: interfaces.ITaskRunOptions): Promise<interfaces.IDualAgentRunResult> {
|
||||
if (!this.isRunning) {
|
||||
throw new Error('Orchestrator not started. Call start() first.');
|
||||
}
|
||||
|
||||
// Use native tool calling if enabled
|
||||
const useNativeTools = this.options.useNativeToolCalling === true;
|
||||
|
||||
this.conversationHistory = [];
|
||||
let iterations = 0;
|
||||
let consecutiveRejections = 0;
|
||||
let completed = false;
|
||||
let finalResult: string | null = null;
|
||||
|
||||
// Track pending native tool calls
|
||||
let pendingNativeToolCalls: interfaces.INativeToolCall[] | undefined;
|
||||
|
||||
// Extract images from options
|
||||
const images = options?.images;
|
||||
|
||||
// Add initial task to history
|
||||
this.conversationHistory.push({
|
||||
role: 'user',
|
||||
content: task,
|
||||
});
|
||||
|
||||
// Start the driver with the task and optional images
|
||||
let driverResponse: interfaces.IAgentMessage;
|
||||
|
||||
if (useNativeTools) {
|
||||
// Native tool calling mode
|
||||
const result = await this.driver.startTaskWithNativeTools(task, images);
|
||||
driverResponse = result.message;
|
||||
pendingNativeToolCalls = result.toolCalls;
|
||||
} else {
|
||||
// XML parsing mode
|
||||
driverResponse = await this.driver.startTask(task, images);
|
||||
}
|
||||
this.conversationHistory.push(driverResponse);
|
||||
|
||||
// Emit task started event
|
||||
this.emitProgress({
|
||||
type: 'task_started',
|
||||
message: task.length > 100 ? task.substring(0, 100) + '...' : task,
|
||||
});
|
||||
|
||||
while (
|
||||
iterations < this.options.maxIterations! &&
|
||||
consecutiveRejections < this.options.maxConsecutiveRejections! &&
|
||||
!completed
|
||||
) {
|
||||
iterations++;
|
||||
|
||||
// Emit iteration started event
|
||||
this.emitProgress({
|
||||
type: 'iteration_started',
|
||||
iteration: iterations,
|
||||
maxIterations: this.options.maxIterations,
|
||||
});
|
||||
|
||||
// Check if task is complete (for native mode, no pending tool calls and has content)
|
||||
const isComplete = useNativeTools
|
||||
? (!pendingNativeToolCalls || pendingNativeToolCalls.length === 0) && driverResponse.content.length > 0
|
||||
: this.driver.isTaskComplete(driverResponse.content);
|
||||
|
||||
if (isComplete) {
|
||||
completed = true;
|
||||
finalResult = useNativeTools
|
||||
? driverResponse.content
|
||||
: (this.driver.extractTaskResult(driverResponse.content) || driverResponse.content);
|
||||
|
||||
// Emit task completed event
|
||||
this.emitProgress({
|
||||
type: 'task_completed',
|
||||
iteration: iterations,
|
||||
message: 'Task completed successfully',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if driver needs clarification
|
||||
if (this.driver.needsClarification(driverResponse.content)) {
|
||||
// Emit clarification needed event
|
||||
this.emitProgress({
|
||||
type: 'clarification_needed',
|
||||
iteration: iterations,
|
||||
message: 'Driver needs clarification from user',
|
||||
});
|
||||
|
||||
// Return with clarification needed status
|
||||
return {
|
||||
success: false,
|
||||
completed: false,
|
||||
result: driverResponse.content,
|
||||
iterations,
|
||||
history: this.conversationHistory,
|
||||
status: 'clarification_needed',
|
||||
};
|
||||
}
|
||||
|
||||
// Parse tool call proposals - native mode uses pendingNativeToolCalls, XML mode parses content
|
||||
let proposals: interfaces.IToolCallProposal[];
|
||||
|
||||
if (useNativeTools && pendingNativeToolCalls && pendingNativeToolCalls.length > 0) {
|
||||
// Native tool calling mode - convert native tool calls to proposals
|
||||
proposals = this.driver.parseNativeToolCalls(pendingNativeToolCalls);
|
||||
pendingNativeToolCalls = undefined; // Clear after processing
|
||||
} else if (!useNativeTools) {
|
||||
// XML parsing mode
|
||||
proposals = this.driver.parseToolCallProposals(driverResponse.content);
|
||||
} else {
|
||||
proposals = [];
|
||||
}
|
||||
|
||||
if (proposals.length === 0) {
|
||||
if (useNativeTools) {
|
||||
// Native mode: no tool calls and no content means we should continue
|
||||
const result = await this.driver.continueWithNativeTools(
|
||||
'Please continue with the task. Use the available tools or provide your final output.'
|
||||
);
|
||||
driverResponse = result.message;
|
||||
pendingNativeToolCalls = result.toolCalls;
|
||||
this.conversationHistory.push(driverResponse);
|
||||
continue;
|
||||
} else {
|
||||
// XML mode: remind the model of the exact XML format
|
||||
driverResponse = await this.driver.continueWithMessage(
|
||||
`No valid tool call was found in your response. To use a tool, you MUST output the exact XML format:
|
||||
|
||||
<tool_call>
|
||||
<tool>tool_name</tool>
|
||||
<action>action_name</action>
|
||||
<params>{"param1": "value1"}</params>
|
||||
</tool_call>
|
||||
|
||||
For example, to validate JSON:
|
||||
<tool_call>
|
||||
<tool>json</tool>
|
||||
<action>validate</action>
|
||||
<params>{"jsonString": "{\\"key\\":\\"value\\"}", "requiredFields": ["key"]}</params>
|
||||
</tool_call>
|
||||
|
||||
Or to complete the task:
|
||||
<task_complete>your final JSON output here</task_complete>
|
||||
|
||||
Please output the exact XML format above.`
|
||||
);
|
||||
this.conversationHistory.push(driverResponse);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Process the first proposal (one at a time)
|
||||
const proposal = proposals[0];
|
||||
|
||||
// Emit tool proposed event
|
||||
this.emitProgress({
|
||||
type: 'tool_proposed',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
message: `${proposal.toolName}.${proposal.action}`,
|
||||
});
|
||||
|
||||
// Quick validation first
|
||||
const quickDecision = this.guardian.quickValidate(proposal);
|
||||
let decision: interfaces.IGuardianDecision;
|
||||
|
||||
if (quickDecision) {
|
||||
decision = quickDecision;
|
||||
} else {
|
||||
// Emit guardian evaluating event
|
||||
this.emitProgress({
|
||||
type: 'guardian_evaluating',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
});
|
||||
|
||||
// Full AI evaluation
|
||||
decision = await this.guardian.evaluate(proposal, task);
|
||||
}
|
||||
|
||||
if (decision.decision === 'approve') {
|
||||
consecutiveRejections = 0;
|
||||
|
||||
// Emit tool approved event
|
||||
this.emitProgress({
|
||||
type: 'tool_approved',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
});
|
||||
|
||||
// Execute the tool
|
||||
const tool = this.registry.getTool(proposal.toolName);
|
||||
if (!tool) {
|
||||
const errorMessage = `Tool "${proposal.toolName}" not found.`;
|
||||
driverResponse = await this.driver.continueWithMessage(
|
||||
`TOOL ERROR: ${errorMessage}\n\nPlease try a different approach.`
|
||||
);
|
||||
this.conversationHistory.push(driverResponse);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Emit tool executing event
|
||||
this.emitProgress({
|
||||
type: 'tool_executing',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
});
|
||||
|
||||
const result = await tool.execute(proposal.action, proposal.params);
|
||||
|
||||
// Emit tool completed event
|
||||
this.emitProgress({
|
||||
type: 'tool_completed',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
message: result.success ? 'success' : 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',
|
||||
content: resultMessage,
|
||||
toolCall: proposal,
|
||||
toolResult: result,
|
||||
});
|
||||
|
||||
// Continue with appropriate method based on mode
|
||||
if (useNativeTools) {
|
||||
const toolNameForHistory = `${proposal.toolName}_${proposal.action}`;
|
||||
const continueResult = await this.driver.continueWithNativeTools(resultMessage, toolNameForHistory);
|
||||
driverResponse = continueResult.message;
|
||||
pendingNativeToolCalls = continueResult.toolCalls;
|
||||
} else {
|
||||
driverResponse = await this.driver.continueWithMessage(resultMessage);
|
||||
}
|
||||
this.conversationHistory.push(driverResponse);
|
||||
} catch (error) {
|
||||
const errorMessage = `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
if (useNativeTools) {
|
||||
const toolNameForHistory = `${proposal.toolName}_${proposal.action}`;
|
||||
const continueResult = await this.driver.continueWithNativeTools(
|
||||
`TOOL ERROR: ${errorMessage}\n\nPlease try a different approach.`,
|
||||
toolNameForHistory
|
||||
);
|
||||
driverResponse = continueResult.message;
|
||||
pendingNativeToolCalls = continueResult.toolCalls;
|
||||
} else {
|
||||
driverResponse = await this.driver.continueWithMessage(
|
||||
`TOOL ERROR: ${errorMessage}\n\nPlease try a different approach.`
|
||||
);
|
||||
}
|
||||
this.conversationHistory.push(driverResponse);
|
||||
}
|
||||
} else {
|
||||
// Rejected
|
||||
consecutiveRejections++;
|
||||
|
||||
// Emit tool rejected event
|
||||
this.emitProgress({
|
||||
type: 'tool_rejected',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
reason: decision.reason,
|
||||
});
|
||||
|
||||
// Build rejection feedback
|
||||
let feedback = `TOOL CALL REJECTED by Guardian:\n`;
|
||||
feedback += `- Reason: ${decision.reason}\n`;
|
||||
|
||||
if (decision.concerns && decision.concerns.length > 0) {
|
||||
feedback += `- Concerns:\n${decision.concerns.map(c => ` - ${c}`).join('\n')}\n`;
|
||||
}
|
||||
|
||||
if (decision.suggestions) {
|
||||
feedback += `- Suggestions: ${decision.suggestions}\n`;
|
||||
}
|
||||
|
||||
feedback += `\nPlease adapt your approach based on this feedback.`;
|
||||
|
||||
this.conversationHistory.push({
|
||||
role: 'system',
|
||||
content: feedback,
|
||||
toolCall: proposal,
|
||||
guardianDecision: decision,
|
||||
});
|
||||
|
||||
// Continue with appropriate method based on mode
|
||||
if (useNativeTools) {
|
||||
const continueResult = await this.driver.continueWithNativeTools(feedback);
|
||||
driverResponse = continueResult.message;
|
||||
pendingNativeToolCalls = continueResult.toolCalls;
|
||||
} else {
|
||||
driverResponse = await this.driver.continueWithMessage(feedback);
|
||||
}
|
||||
this.conversationHistory.push(driverResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final status
|
||||
let status: interfaces.TDualAgentRunStatus = 'completed';
|
||||
if (!completed) {
|
||||
if (iterations >= this.options.maxIterations!) {
|
||||
status = 'max_iterations_reached';
|
||||
// Emit max iterations event
|
||||
this.emitProgress({
|
||||
type: 'max_iterations',
|
||||
iteration: iterations,
|
||||
maxIterations: this.options.maxIterations,
|
||||
message: `Maximum iterations (${this.options.maxIterations}) reached`,
|
||||
});
|
||||
} else if (consecutiveRejections >= this.options.maxConsecutiveRejections!) {
|
||||
status = 'max_rejections_reached';
|
||||
// Emit max rejections event
|
||||
this.emitProgress({
|
||||
type: 'max_rejections',
|
||||
iteration: iterations,
|
||||
message: `Maximum consecutive rejections (${this.options.maxConsecutiveRejections}) reached`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: completed,
|
||||
completed,
|
||||
result: finalResult || driverResponse.content,
|
||||
iterations,
|
||||
history: this.conversationHistory,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue an existing task with user input
|
||||
*/
|
||||
public async continueTask(userInput: string): Promise<interfaces.IDualAgentRunResult> {
|
||||
if (!this.isRunning) {
|
||||
throw new Error('Orchestrator not started. Call start() first.');
|
||||
}
|
||||
|
||||
this.conversationHistory.push({
|
||||
role: 'user',
|
||||
content: userInput,
|
||||
});
|
||||
|
||||
const driverResponse = await this.driver.continueWithMessage(userInput);
|
||||
this.conversationHistory.push(driverResponse);
|
||||
|
||||
// Continue the run loop
|
||||
// For simplicity, we return the current state - full continuation would need refactoring
|
||||
return {
|
||||
success: false,
|
||||
completed: false,
|
||||
result: driverResponse.content,
|
||||
iterations: 1,
|
||||
history: this.conversationHistory,
|
||||
status: 'in_progress',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conversation history
|
||||
*/
|
||||
public getHistory(): interfaces.IAgentMessage[] {
|
||||
return [...this.conversationHistory];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the guardian policy
|
||||
*/
|
||||
public setGuardianPolicy(policyPrompt: string): void {
|
||||
this.guardian.setPolicy(policyPrompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if orchestrator is running
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered tool names
|
||||
*/
|
||||
public getToolNames(): string[] {
|
||||
return this.registry.getAllMetadata().map((m) => m.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tool registry for advanced operations
|
||||
*/
|
||||
public getRegistry(): ToolRegistry {
|
||||
return this.registry;
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import type { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* GuardianAgent - Evaluates tool call proposals against a policy
|
||||
* Uses AI reasoning to approve or reject tool calls
|
||||
*/
|
||||
export class GuardianAgent {
|
||||
private provider: plugins.smartai.MultiModalModel;
|
||||
private policyPrompt: string;
|
||||
private tools: Map<string, BaseToolWrapper> = new Map();
|
||||
|
||||
constructor(
|
||||
provider: plugins.smartai.MultiModalModel,
|
||||
policyPrompt: string
|
||||
) {
|
||||
this.provider = provider;
|
||||
this.policyPrompt = policyPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool for reference during evaluation
|
||||
*/
|
||||
public registerTool(tool: BaseToolWrapper): void {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a tool call proposal against the policy
|
||||
*/
|
||||
public async evaluate(
|
||||
proposal: interfaces.IToolCallProposal,
|
||||
taskContext: string
|
||||
): Promise<interfaces.IGuardianDecision> {
|
||||
// Get the tool to generate a human-readable summary
|
||||
const tool = this.tools.get(proposal.toolName);
|
||||
let callSummary = `${proposal.toolName}.${proposal.action}(${JSON.stringify(proposal.params)})`;
|
||||
|
||||
if (tool) {
|
||||
try {
|
||||
callSummary = tool.getCallSummary(proposal.action, proposal.params);
|
||||
} catch {
|
||||
// Fallback to basic summary
|
||||
}
|
||||
}
|
||||
|
||||
// Build the evaluation prompt
|
||||
const evaluationPrompt = this.buildEvaluationPrompt(
|
||||
proposal,
|
||||
callSummary,
|
||||
taskContext
|
||||
);
|
||||
|
||||
// Get response from provider
|
||||
const response = await this.provider.chat({
|
||||
systemMessage: this.buildGuardianSystemMessage(),
|
||||
userMessage: evaluationPrompt,
|
||||
messageHistory: [],
|
||||
});
|
||||
|
||||
// Parse the decision from the response
|
||||
return this.parseDecision(response.message, proposal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the system message for the Guardian
|
||||
*/
|
||||
private buildGuardianSystemMessage(): string {
|
||||
return `You are a Guardian AI responsible for evaluating tool call proposals.
|
||||
|
||||
## Your Role
|
||||
You evaluate whether proposed tool calls are safe and aligned with the policy.
|
||||
|
||||
## Policy to Enforce
|
||||
${this.policyPrompt}
|
||||
|
||||
## Response Format
|
||||
For EVERY evaluation, respond with a decision in this exact format:
|
||||
|
||||
<guardian_decision>
|
||||
<decision>approve OR reject</decision>
|
||||
<reason>Your detailed explanation</reason>
|
||||
<concerns>List any concerns, even if approving</concerns>
|
||||
<suggestions>Alternative approaches if rejecting</suggestions>
|
||||
</guardian_decision>
|
||||
|
||||
## Guidelines
|
||||
1. Carefully analyze what the tool call will do
|
||||
2. Consider security implications
|
||||
3. Check against the policy requirements
|
||||
4. If uncertain, err on the side of caution (reject)
|
||||
5. Provide actionable feedback when rejecting`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the evaluation prompt for a specific proposal
|
||||
*/
|
||||
private buildEvaluationPrompt(
|
||||
proposal: interfaces.IToolCallProposal,
|
||||
callSummary: string,
|
||||
taskContext: string
|
||||
): string {
|
||||
const toolInfo = this.tools.get(proposal.toolName);
|
||||
const toolDescription = toolInfo ? toolInfo.getFullDescription() : 'Unknown tool';
|
||||
|
||||
return `## Task Context
|
||||
${taskContext}
|
||||
|
||||
## Tool Being Used
|
||||
${toolDescription}
|
||||
|
||||
## Proposed Tool Call
|
||||
- **Tool**: ${proposal.toolName}
|
||||
- **Action**: ${proposal.action}
|
||||
- **Parameters**: ${JSON.stringify(proposal.params, null, 2)}
|
||||
|
||||
## Human-Readable Summary
|
||||
${callSummary}
|
||||
|
||||
## Driver's Reasoning
|
||||
${proposal.reasoning || 'No reasoning provided'}
|
||||
|
||||
---
|
||||
|
||||
Evaluate this tool call against the policy. Should it be approved or rejected?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the guardian decision from the response
|
||||
*/
|
||||
private parseDecision(
|
||||
response: string,
|
||||
proposal: interfaces.IToolCallProposal
|
||||
): interfaces.IGuardianDecision {
|
||||
// Try to extract from XML tags
|
||||
const decisionMatch = response.match(/<decision>(.*?)<\/decision>/s);
|
||||
const reasonMatch = response.match(/<reason>([\s\S]*?)<\/reason>/);
|
||||
const concernsMatch = response.match(/<concerns>([\s\S]*?)<\/concerns>/);
|
||||
const suggestionsMatch = response.match(/<suggestions>([\s\S]*?)<\/suggestions>/);
|
||||
|
||||
// Determine decision
|
||||
let decision: 'approve' | 'reject' = 'reject';
|
||||
if (decisionMatch) {
|
||||
const decisionText = decisionMatch[1].trim().toLowerCase();
|
||||
decision = decisionText.includes('approve') ? 'approve' : 'reject';
|
||||
} else {
|
||||
// Fallback: look for approval keywords in the response
|
||||
const lowerResponse = response.toLowerCase();
|
||||
if (
|
||||
lowerResponse.includes('approved') ||
|
||||
lowerResponse.includes('i approve') ||
|
||||
lowerResponse.includes('looks safe')
|
||||
) {
|
||||
decision = 'approve';
|
||||
}
|
||||
}
|
||||
|
||||
// Extract reason
|
||||
let reason = reasonMatch ? reasonMatch[1].trim() : '';
|
||||
if (!reason) {
|
||||
// Use the full response as reason if no tag found
|
||||
reason = response.substring(0, 500);
|
||||
}
|
||||
|
||||
// Extract concerns
|
||||
const concerns: string[] = [];
|
||||
if (concernsMatch) {
|
||||
const concernsText = concernsMatch[1].trim();
|
||||
if (concernsText && concernsText.toLowerCase() !== 'none') {
|
||||
// Split by newlines or bullet points
|
||||
const concernLines = concernsText.split(/[\n\r]+/).map(l => l.trim()).filter(l => l);
|
||||
concerns.push(...concernLines);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract suggestions
|
||||
const suggestions = suggestionsMatch ? suggestionsMatch[1].trim() : undefined;
|
||||
|
||||
return {
|
||||
decision,
|
||||
reason,
|
||||
concerns: concerns.length > 0 ? concerns : undefined,
|
||||
suggestions: suggestions && suggestions.toLowerCase() !== 'none' ? suggestions : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation without AI (for obviously safe/unsafe operations)
|
||||
* Returns null if AI evaluation is needed
|
||||
*/
|
||||
public quickValidate(proposal: interfaces.IToolCallProposal): interfaces.IGuardianDecision | null {
|
||||
// Check if tool exists
|
||||
if (!this.tools.has(proposal.toolName)) {
|
||||
return {
|
||||
decision: 'reject',
|
||||
reason: `Unknown tool: ${proposal.toolName}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if action exists
|
||||
const tool = this.tools.get(proposal.toolName)!;
|
||||
const validAction = tool.actions.find(a => a.name === proposal.action);
|
||||
if (!validAction) {
|
||||
return {
|
||||
decision: 'reject',
|
||||
reason: `Unknown action "${proposal.action}" for tool "${proposal.toolName}". Available actions: ${tool.actions.map(a => a.name).join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check required parameters
|
||||
const schema = validAction.parameters;
|
||||
if (schema && schema.required && Array.isArray(schema.required)) {
|
||||
for (const requiredParam of schema.required as string[]) {
|
||||
if (!(requiredParam in proposal.params)) {
|
||||
return {
|
||||
decision: 'reject',
|
||||
reason: `Missing required parameter: ${requiredParam}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Needs full AI evaluation
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the policy prompt
|
||||
*/
|
||||
public setPolicy(policyPrompt: string): void {
|
||||
this.policyPrompt = policyPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current policy
|
||||
*/
|
||||
public getPolicy(): string {
|
||||
return this.policyPrompt;
|
||||
}
|
||||
}
|
||||
@@ -1,188 +1,20 @@
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
import type { ToolSet } from './plugins.js';
|
||||
|
||||
/**
|
||||
* ToolRegistry - Manages tool registration, visibility, and lifecycle
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Track all registered tools with their metadata
|
||||
* - Manage visibility (initial vs on-demand)
|
||||
* - Handle activation of on-demand tools
|
||||
* - Provide search functionality
|
||||
*/
|
||||
export class ToolRegistry {
|
||||
private tools: Map<string, BaseToolWrapper> = new Map();
|
||||
private metadata: Map<string, interfaces.IToolMetadata> = new Map();
|
||||
private activated: Set<string> = new Set();
|
||||
private tools: ToolSet = {};
|
||||
|
||||
/**
|
||||
* Register a tool with optional visibility settings
|
||||
* Register a tool.
|
||||
* @param name Tool name (must be unique, snake_case recommended)
|
||||
* @param def Tool definition created with ai-sdk's tool() helper
|
||||
*/
|
||||
register(tool: BaseToolWrapper, options: interfaces.IToolRegistrationOptions = {}): void {
|
||||
const visibility = options.visibility ?? 'initial';
|
||||
|
||||
this.tools.set(tool.name, tool);
|
||||
this.metadata.set(tool.name, {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
actions: tool.actions,
|
||||
visibility,
|
||||
isActivated: visibility === 'initial',
|
||||
isInitialized: false,
|
||||
tags: options.tags,
|
||||
category: options.category,
|
||||
});
|
||||
|
||||
if (visibility === 'initial') {
|
||||
this.activated.add(tool.name);
|
||||
}
|
||||
public register(name: string, def: ToolSet[string]): this {
|
||||
this.tools[name] = def;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools visible to the Driver (initial + activated on-demand)
|
||||
*/
|
||||
getVisibleTools(): BaseToolWrapper[] {
|
||||
return Array.from(this.tools.entries())
|
||||
.filter(([name]) => this.activated.has(name))
|
||||
.map(([, tool]) => tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tools (for search results)
|
||||
*/
|
||||
getAllTools(): BaseToolWrapper[] {
|
||||
return Array.from(this.tools.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific tool by name
|
||||
*/
|
||||
getTool(name: string): BaseToolWrapper | undefined {
|
||||
return this.tools.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a tool
|
||||
*/
|
||||
getMetadata(name: string): interfaces.IToolMetadata | undefined {
|
||||
return this.metadata.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metadata
|
||||
*/
|
||||
getAllMetadata(): interfaces.IToolMetadata[] {
|
||||
return Array.from(this.metadata.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Search tools by query (matches name, description, tags, action names)
|
||||
*/
|
||||
search(query: string): interfaces.IToolMetadata[] {
|
||||
const q = query.toLowerCase();
|
||||
return this.getAllMetadata().filter((meta) => {
|
||||
if (meta.name.toLowerCase().includes(q)) return true;
|
||||
if (meta.description.toLowerCase().includes(q)) return true;
|
||||
if (meta.tags?.some((t) => t.toLowerCase().includes(q))) return true;
|
||||
if (meta.category?.toLowerCase().includes(q)) return true;
|
||||
if (
|
||||
meta.actions.some(
|
||||
(a) => a.name.toLowerCase().includes(q) || a.description.toLowerCase().includes(q)
|
||||
)
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate an on-demand tool
|
||||
*/
|
||||
async activate(name: string): Promise<{ success: boolean; error?: string }> {
|
||||
const tool = this.tools.get(name);
|
||||
const meta = this.metadata.get(name);
|
||||
|
||||
if (!tool || !meta) {
|
||||
return { success: false, error: `Tool "${name}" not found` };
|
||||
}
|
||||
|
||||
if (this.activated.has(name)) {
|
||||
return { success: true }; // Already activated
|
||||
}
|
||||
|
||||
// Initialize if not already initialized
|
||||
if (!meta.isInitialized) {
|
||||
await tool.initialize();
|
||||
meta.isInitialized = true;
|
||||
}
|
||||
|
||||
this.activated.add(name);
|
||||
meta.isActivated = true;
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is activated
|
||||
*/
|
||||
isActivated(name: string): boolean {
|
||||
return this.activated.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all initial (visible) tools
|
||||
*/
|
||||
async initializeVisibleTools(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const [name, tool] of this.tools) {
|
||||
const meta = this.metadata.get(name);
|
||||
if (meta && this.activated.has(name) && !meta.isInitialized) {
|
||||
promises.push(
|
||||
tool.initialize().then(() => {
|
||||
meta.isInitialized = true;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all initialized tools
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const [name, tool] of this.tools) {
|
||||
const meta = this.metadata.get(name);
|
||||
if (meta?.isInitialized) {
|
||||
promises.push(tool.cleanup());
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool exists in the registry
|
||||
*/
|
||||
has(name: string): boolean {
|
||||
return this.tools.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of registered tools
|
||||
*/
|
||||
get size(): number {
|
||||
return this.tools.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of activated tools
|
||||
*/
|
||||
get activatedCount(): number {
|
||||
return this.activated.size;
|
||||
/** Get the full ToolSet for passing to runAgent */
|
||||
public getTools(): ToolSet {
|
||||
return { ...this.tools };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,366 +1,54 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type { ToolSet, ModelMessage, LanguageModelV3 } from './plugins.js';
|
||||
|
||||
// ================================
|
||||
// Tool Visibility & Registry Types
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Tool visibility mode
|
||||
* - 'initial': Conveyed to model in system prompt AND discoverable via search
|
||||
* - 'on-demand': Only discoverable via search, must be activated before use
|
||||
*/
|
||||
export type TToolVisibility = 'initial' | 'on-demand';
|
||||
|
||||
/**
|
||||
* Tool metadata for discovery and management
|
||||
*/
|
||||
export interface IToolMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
actions: IToolAction[];
|
||||
visibility: TToolVisibility;
|
||||
isActivated: boolean;
|
||||
isInitialized: boolean;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options when registering a tool
|
||||
*/
|
||||
export interface IToolRegistrationOptions {
|
||||
visibility?: TToolVisibility;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating an Expert (SubAgent)
|
||||
*/
|
||||
export interface IExpertConfig {
|
||||
/** Unique name for the expert */
|
||||
name: string;
|
||||
/** Description of the expert's capabilities */
|
||||
description: string;
|
||||
/** System message defining expert behavior */
|
||||
systemMessage: string;
|
||||
/** Guardian policy for the expert's inner agent */
|
||||
guardianPolicy: string;
|
||||
/** AI provider (defaults to parent's provider) */
|
||||
provider?: plugins.smartai.TProvider;
|
||||
/** Tools available to this expert */
|
||||
tools?: IAgentToolWrapper[];
|
||||
/** Max iterations for expert tasks (default: 10) */
|
||||
maxIterations?: number;
|
||||
/** Visibility mode (default: 'initial') */
|
||||
visibility?: TToolVisibility;
|
||||
/** Searchable tags */
|
||||
tags?: string[];
|
||||
/** Category for grouping */
|
||||
category?: string;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Task Run Options
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Options for running a task with the DualAgentOrchestrator
|
||||
*/
|
||||
export interface ITaskRunOptions {
|
||||
/** Base64-encoded images to include with the task (for vision-capable models) */
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Agent Configuration Interfaces
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Configuration options for the DualAgentOrchestrator
|
||||
*/
|
||||
export interface IDualAgentOptions extends plugins.smartai.ISmartAiOptions {
|
||||
/** Existing SmartAi instance to reuse (avoids creating duplicate providers) */
|
||||
smartAiInstance?: plugins.smartai.SmartAi;
|
||||
/** Name of the agent system */
|
||||
name?: string;
|
||||
/** Default AI provider for both Driver and Guardian */
|
||||
defaultProvider?: plugins.smartai.TProvider;
|
||||
/** Optional separate provider for Guardian (for cost optimization) */
|
||||
guardianProvider?: plugins.smartai.TProvider;
|
||||
/** System message for the Driver agent */
|
||||
driverSystemMessage?: string;
|
||||
/** Policy prompt for the Guardian agent - REQUIRED */
|
||||
guardianPolicyPrompt: string;
|
||||
/** Maximum iterations for task completion (default: 20) */
|
||||
maxIterations?: number;
|
||||
/** Maximum consecutive rejections before aborting (default: 3) */
|
||||
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;
|
||||
/** Optional callback for live progress updates during execution */
|
||||
onProgress?: (event: IProgressEvent) => void;
|
||||
/** Prefix for log messages (e.g., "[README]", "[Commit]"). Default: empty */
|
||||
logPrefix?: string;
|
||||
/** Callback fired for each token during LLM generation (streaming mode) */
|
||||
onToken?: (token: string, source: 'driver' | 'guardian') => void;
|
||||
export interface IAgentRunOptions {
|
||||
/** The LanguageModelV3 to use — from smartai.getModel() */
|
||||
model: LanguageModelV3;
|
||||
/** Initial user message or task description */
|
||||
prompt: string;
|
||||
/** System prompt override */
|
||||
system?: string;
|
||||
/** Tools available to the agent */
|
||||
tools?: ToolSet;
|
||||
/**
|
||||
* Enable native tool calling mode (default: false)
|
||||
* When enabled, uses Ollama's native tool calling API instead of XML parsing
|
||||
* This is more efficient for models that support it (e.g., GPT-OSS with Harmony format)
|
||||
* Maximum number of LLM↔tool round trips.
|
||||
* Each step may execute multiple tools in parallel.
|
||||
* Default: 20
|
||||
*/
|
||||
useNativeToolCalling?: boolean;
|
||||
maxSteps?: number;
|
||||
/** Prior conversation messages to include */
|
||||
messages?: ModelMessage[];
|
||||
/** Called for each streamed text delta */
|
||||
onToken?: (delta: string) => void;
|
||||
/** Called when a tool call starts */
|
||||
onToolCall?: (toolName: string, input: unknown) => void;
|
||||
/** Called when a tool call completes */
|
||||
onToolResult?: (toolName: string, result: unknown) => void;
|
||||
/**
|
||||
* Called when total token usage approaches the model's context limit.
|
||||
* Receives the full message history and must return a compacted replacement.
|
||||
* If not provided, runAgent throws a ContextOverflowError instead.
|
||||
*/
|
||||
onContextOverflow?: (messages: ModelMessage[]) => Promise<ModelMessage[]>;
|
||||
/** AbortSignal to cancel the run mid-flight */
|
||||
abort?: AbortSignal;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Message Interfaces
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Represents a message in the agent's conversation history
|
||||
*/
|
||||
export interface IAgentMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool' | 'guardian';
|
||||
content: string;
|
||||
toolName?: string;
|
||||
toolResult?: unknown;
|
||||
toolCall?: IToolCallProposal;
|
||||
guardianDecision?: IGuardianDecision;
|
||||
timestamp?: Date;
|
||||
export interface IAgentRunResult {
|
||||
/** Final text output from the model */
|
||||
text: string;
|
||||
/** All messages in the completed conversation */
|
||||
messages: ModelMessage[];
|
||||
/** Total steps taken */
|
||||
steps: number;
|
||||
/** Finish reason from the final step */
|
||||
finishReason: string;
|
||||
/** Accumulated token usage across all steps */
|
||||
usage: { inputTokens: number; outputTokens: number; totalTokens: number };
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Tool Interfaces
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Represents an action that a tool can perform
|
||||
*/
|
||||
export interface IToolAction {
|
||||
/** Action name (e.g., 'read', 'write', 'delete') */
|
||||
name: string;
|
||||
/** Description of what this action does */
|
||||
description: string;
|
||||
/** JSON schema for action parameters */
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native tool call from provider (matches Ollama's tool calling format)
|
||||
* Format: function name is "toolName_actionName" (e.g., "json_validate")
|
||||
*/
|
||||
export interface INativeToolCall {
|
||||
function: {
|
||||
name: string; // Format: "toolName_actionName"
|
||||
arguments: Record<string, unknown>;
|
||||
index?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposed tool call from the Driver
|
||||
*/
|
||||
export interface IToolCallProposal {
|
||||
/** Unique ID for this proposal */
|
||||
proposalId: string;
|
||||
/** Name of the tool */
|
||||
toolName: string;
|
||||
/** Specific action to perform */
|
||||
action: string;
|
||||
/** Parameters for the action */
|
||||
params: Record<string, unknown>;
|
||||
/** Driver's reasoning for this call */
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of tool execution
|
||||
*/
|
||||
export interface IToolExecutionResult {
|
||||
success: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
/** Optional human-readable summary for history (if provided, used instead of full result) */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for wrapped tools
|
||||
*/
|
||||
export interface IAgentToolWrapper {
|
||||
/** Tool name */
|
||||
name: string;
|
||||
/** Tool description */
|
||||
description: string;
|
||||
/** Available actions */
|
||||
actions: IToolAction[];
|
||||
/** Initialize the tool */
|
||||
initialize(): Promise<void>;
|
||||
/** Cleanup resources */
|
||||
cleanup(): Promise<void>;
|
||||
/** Execute an action */
|
||||
execute(action: string, params: Record<string, unknown>): Promise<IToolExecutionResult>;
|
||||
/** Get a summary for Guardian review */
|
||||
getCallSummary(action: string, params: Record<string, unknown>): string;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Guardian Interfaces
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Request for Guardian evaluation
|
||||
*/
|
||||
export interface IGuardianEvaluationRequest {
|
||||
/** The proposed tool call */
|
||||
proposal: IToolCallProposal;
|
||||
/** Current task context */
|
||||
taskContext: string;
|
||||
/** Recent conversation history (last N messages) */
|
||||
recentHistory: IAgentMessage[];
|
||||
/** Summary of what the tool call will do */
|
||||
callSummary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardian's decision
|
||||
*/
|
||||
export interface IGuardianDecision {
|
||||
/** Approve or reject */
|
||||
decision: 'approve' | 'reject';
|
||||
/** Explanation of the decision */
|
||||
reason: string;
|
||||
/** Specific concerns if rejected */
|
||||
concerns?: string[];
|
||||
/** Suggestions for the Driver if rejected */
|
||||
suggestions?: string;
|
||||
/** Confidence level (0-1) */
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Result Interfaces
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Log entry for tool executions
|
||||
*/
|
||||
export interface IToolExecutionLog {
|
||||
timestamp: Date;
|
||||
toolName: string;
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
guardianDecision: 'approved' | 'rejected';
|
||||
guardianReason: string;
|
||||
executionResult?: unknown;
|
||||
executionError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a dual-agent run
|
||||
*/
|
||||
export type TDualAgentRunStatus =
|
||||
| 'completed'
|
||||
| 'in_progress'
|
||||
| 'max_iterations_reached'
|
||||
| 'max_rejections_reached'
|
||||
| 'clarification_needed'
|
||||
| 'error';
|
||||
|
||||
/**
|
||||
* Result of a dual-agent run
|
||||
*/
|
||||
export interface IDualAgentRunResult {
|
||||
/** Whether the task was successful */
|
||||
success: boolean;
|
||||
/** Whether the task is completed */
|
||||
completed: boolean;
|
||||
/** Final result or response */
|
||||
result: string;
|
||||
/** Total iterations taken */
|
||||
iterations: number;
|
||||
/** Full conversation history */
|
||||
history: IAgentMessage[];
|
||||
/** Current status */
|
||||
status: TDualAgentRunStatus;
|
||||
/** Number of tool calls made */
|
||||
toolCallCount?: number;
|
||||
/** Number of Guardian rejections */
|
||||
rejectionCount?: number;
|
||||
/** Tool execution log */
|
||||
toolLog?: IToolExecutionLog[];
|
||||
/** Error message if status is 'error' */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Progress Event Interfaces
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Progress event types for live feedback during agent execution
|
||||
*/
|
||||
export type TProgressEventType =
|
||||
| 'task_started'
|
||||
| 'iteration_started'
|
||||
| 'tool_proposed'
|
||||
| 'guardian_evaluating'
|
||||
| 'tool_approved'
|
||||
| 'tool_rejected'
|
||||
| 'tool_executing'
|
||||
| 'tool_completed'
|
||||
| 'task_completed'
|
||||
| 'clarification_needed'
|
||||
| 'max_iterations'
|
||||
| 'max_rejections';
|
||||
|
||||
/**
|
||||
* Log level for progress events
|
||||
*/
|
||||
export type TLogLevel = 'info' | 'warn' | 'error' | 'success';
|
||||
|
||||
/**
|
||||
* Progress event for live feedback during agent execution
|
||||
*/
|
||||
export interface IProgressEvent {
|
||||
/** Type of progress event */
|
||||
type: TProgressEventType;
|
||||
/** Current iteration number */
|
||||
iteration?: number;
|
||||
/** Maximum iterations configured */
|
||||
maxIterations?: number;
|
||||
/** Name of the tool being used */
|
||||
toolName?: string;
|
||||
/** Action being performed */
|
||||
action?: string;
|
||||
/** Reason for rejection or other explanation */
|
||||
reason?: string;
|
||||
/** Human-readable message about the event */
|
||||
message?: string;
|
||||
/** Timestamp of the event */
|
||||
timestamp: Date;
|
||||
/** Log level for this event (info, warn, error, success) */
|
||||
logLevel: TLogLevel;
|
||||
/** Pre-formatted log message ready for output */
|
||||
logMessage: string;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Utility Types
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Available tool names
|
||||
*/
|
||||
export type TToolName = 'filesystem' | 'http' | 'browser' | 'shell';
|
||||
|
||||
/**
|
||||
* Generate a unique proposal ID
|
||||
*/
|
||||
export function generateProposalId(): string {
|
||||
return `proposal_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
export class ContextOverflowError extends Error {
|
||||
constructor(message = 'Agent context limit reached and no onContextOverflow handler provided') {
|
||||
super(message);
|
||||
this.name = 'ContextOverflowError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for tool wrappers
|
||||
* All tool implementations should extend this class
|
||||
*/
|
||||
export abstract class BaseToolWrapper implements interfaces.IAgentToolWrapper {
|
||||
abstract name: string;
|
||||
abstract description: string;
|
||||
abstract actions: interfaces.IToolAction[];
|
||||
|
||||
protected isInitialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the tool and any required resources
|
||||
*/
|
||||
abstract initialize(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cleanup any resources used by the tool
|
||||
*/
|
||||
abstract cleanup(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Execute an action with the given parameters
|
||||
*/
|
||||
abstract execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<interfaces.IToolExecutionResult>;
|
||||
|
||||
/**
|
||||
* Generate a human-readable summary of what the action will do
|
||||
* This is used by the Guardian to understand the proposed action
|
||||
*/
|
||||
abstract getCallSummary(action: string, params: Record<string, unknown>): string;
|
||||
|
||||
/**
|
||||
* Get a comprehensive explanation of this tool for LLM consumption.
|
||||
* Tools should implement this to provide detailed usage instructions with examples.
|
||||
* This includes parameter schemas and concrete <tool_call> XML examples.
|
||||
*/
|
||||
abstract getToolExplanation(): string;
|
||||
|
||||
/**
|
||||
* Validate that an action exists for this tool
|
||||
* @throws Error if the action is not valid
|
||||
*/
|
||||
protected validateAction(action: string): void {
|
||||
const validAction = this.actions.find((a) => a.name === action);
|
||||
if (!validAction) {
|
||||
const availableActions = this.actions.map((a) => a.name).join(', ');
|
||||
throw new Error(
|
||||
`Unknown action "${action}" for tool "${this.name}". Available actions: ${availableActions}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the tool is initialized
|
||||
*/
|
||||
protected ensureInitialized(): void {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(`Tool "${this.name}" is not initialized. Call initialize() first.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full tool description including all actions
|
||||
* Used for Driver's tool awareness - now delegates to getToolExplanation()
|
||||
*/
|
||||
public getFullDescription(): string {
|
||||
return this.getToolExplanation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the JSON schema for a specific action
|
||||
*/
|
||||
public getActionSchema(action: string): Record<string, unknown> | undefined {
|
||||
const actionDef = this.actions.find((a) => a.name === action);
|
||||
return actionDef?.parameters;
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* Browser tool for web page interaction
|
||||
* Wraps @push.rocks/smartbrowser (Puppeteer-based)
|
||||
*/
|
||||
export class BrowserTool extends BaseToolWrapper {
|
||||
public name = 'browser';
|
||||
public description =
|
||||
'Interact with web pages - take screenshots, generate PDFs, and execute JavaScript on pages';
|
||||
|
||||
public actions: interfaces.IToolAction[] = [
|
||||
{
|
||||
name: 'screenshot',
|
||||
description: 'Take a screenshot of a webpage',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'URL of the page to screenshot' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pdf',
|
||||
description: 'Generate a PDF from a webpage',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'URL of the page to convert to PDF' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate',
|
||||
description:
|
||||
'Execute JavaScript code on a webpage and return the result. The script runs in the browser context.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'URL of the page to run the script on' },
|
||||
script: {
|
||||
type: 'string',
|
||||
description:
|
||||
'JavaScript code to execute. Must be a valid expression or statements that return a value.',
|
||||
},
|
||||
},
|
||||
required: ['url', 'script'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'getPageContent',
|
||||
description: 'Get the text content and title of a webpage',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'URL of the page to get content from' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private smartbrowser!: plugins.smartbrowser.SmartBrowser;
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.smartbrowser = new plugins.smartbrowser.SmartBrowser();
|
||||
await this.smartbrowser.start();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
if (this.smartbrowser) {
|
||||
await this.smartbrowser.stop();
|
||||
}
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
public async execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<interfaces.IToolExecutionResult> {
|
||||
this.validateAction(action);
|
||||
this.ensureInitialized();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'screenshot': {
|
||||
const result = await this.smartbrowser.screenshotFromPage(params.url as string);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
url: params.url,
|
||||
name: result.name,
|
||||
id: result.id,
|
||||
bufferBase64: Buffer.from(result.buffer).toString('base64'),
|
||||
bufferLength: result.buffer.length,
|
||||
type: 'screenshot',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'pdf': {
|
||||
const result = await this.smartbrowser.pdfFromPage(params.url as string);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
url: params.url,
|
||||
name: result.name,
|
||||
id: result.id,
|
||||
bufferBase64: Buffer.from(result.buffer).toString('base64'),
|
||||
bufferLength: result.buffer.length,
|
||||
type: 'pdf',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'evaluate': {
|
||||
const script = params.script as string;
|
||||
// Create an async function from the script
|
||||
// The script should be valid JavaScript that returns a value
|
||||
const result = await this.smartbrowser.evaluateOnPage(params.url as string, async () => {
|
||||
// This runs in the browser context
|
||||
// We need to evaluate the script string dynamically
|
||||
// eslint-disable-next-line no-eval
|
||||
return eval(script);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
url: params.url,
|
||||
script: script.substring(0, 200) + (script.length > 200 ? '...' : ''),
|
||||
evaluationResult: result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'getPageContent': {
|
||||
const result = await this.smartbrowser.evaluateOnPage(params.url as string, async () => {
|
||||
return {
|
||||
title: document.title,
|
||||
textContent: document.body?.innerText || '',
|
||||
url: window.location.href,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
url: params.url,
|
||||
title: result.title,
|
||||
textContent:
|
||||
result.textContent.length > 10000
|
||||
? result.textContent.substring(0, 10000) + '... [truncated]'
|
||||
: result.textContent,
|
||||
actualUrl: result.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown action: ${action}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getToolExplanation(): string {
|
||||
return `## Tool: browser
|
||||
Interact with web pages - take screenshots, generate PDFs, and execute JavaScript on pages.
|
||||
|
||||
### Actions:
|
||||
|
||||
**screenshot** - Take a screenshot of a webpage
|
||||
Parameters:
|
||||
- url (required): URL of the page to screenshot
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>browser</tool>
|
||||
<action>screenshot</action>
|
||||
<params>{"url": "https://example.com"}</params>
|
||||
</tool_call>
|
||||
|
||||
**pdf** - Generate a PDF from a webpage
|
||||
Parameters:
|
||||
- url (required): URL of the page to convert to PDF
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>browser</tool>
|
||||
<action>pdf</action>
|
||||
<params>{"url": "https://example.com/report"}</params>
|
||||
</tool_call>
|
||||
|
||||
**evaluate** - Execute JavaScript code on a webpage and return the result
|
||||
Parameters:
|
||||
- url (required): URL of the page to run the script on
|
||||
- script (required): JavaScript code to execute (must return a value)
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>browser</tool>
|
||||
<action>evaluate</action>
|
||||
<params>{"url": "https://example.com", "script": "document.querySelectorAll('a').length"}</params>
|
||||
</tool_call>
|
||||
|
||||
**getPageContent** - Get the text content and title of a webpage
|
||||
Parameters:
|
||||
- url (required): URL of the page to get content from
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>browser</tool>
|
||||
<action>getPageContent</action>
|
||||
<params>{"url": "https://example.com"}</params>
|
||||
</tool_call>
|
||||
`;
|
||||
}
|
||||
|
||||
public getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
switch (action) {
|
||||
case 'screenshot':
|
||||
return `Take screenshot of "${params.url}"`;
|
||||
|
||||
case 'pdf':
|
||||
return `Generate PDF from "${params.url}"`;
|
||||
|
||||
case 'evaluate': {
|
||||
const script = params.script as string;
|
||||
const preview = script.length > 100 ? script.substring(0, 100) + '...' : script;
|
||||
return `Execute JavaScript on "${params.url}": "${preview}"`;
|
||||
}
|
||||
|
||||
case 'getPageContent':
|
||||
return `Get text content and title from "${params.url}"`;
|
||||
|
||||
default:
|
||||
return `Unknown action: ${action}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* Deno permission types for sandboxed code execution
|
||||
*/
|
||||
export type TDenoPermission =
|
||||
| 'all'
|
||||
| 'env'
|
||||
| 'ffi'
|
||||
| 'hrtime'
|
||||
| 'net'
|
||||
| 'read'
|
||||
| 'run'
|
||||
| 'sys'
|
||||
| 'write';
|
||||
|
||||
/**
|
||||
* Deno tool for executing TypeScript/JavaScript code in a sandboxed environment
|
||||
* Wraps @push.rocks/smartdeno
|
||||
*/
|
||||
export class DenoTool extends BaseToolWrapper {
|
||||
public name = 'deno';
|
||||
public description =
|
||||
'Execute TypeScript/JavaScript code in a sandboxed Deno environment with fine-grained permission control';
|
||||
|
||||
public actions: interfaces.IToolAction[] = [
|
||||
{
|
||||
name: 'execute',
|
||||
description:
|
||||
'Execute TypeScript/JavaScript code and return stdout/stderr. Code runs in Deno sandbox with specified permissions.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
description: 'TypeScript/JavaScript code to execute',
|
||||
},
|
||||
permissions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['all', 'env', 'ffi', 'hrtime', 'net', 'read', 'run', 'sys', 'write'],
|
||||
},
|
||||
description:
|
||||
'Deno permissions to grant. Default: none (fully sandboxed). Options: all, env, net, read, write, run, sys, ffi, hrtime',
|
||||
},
|
||||
},
|
||||
required: ['code'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'executeWithResult',
|
||||
description:
|
||||
'Execute code that outputs JSON on the last line of stdout. The JSON is parsed and returned as the result.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Code that console.logs a JSON value on the final line. This JSON will be parsed and returned.',
|
||||
},
|
||||
permissions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['all', 'env', 'ffi', 'hrtime', 'net', 'read', 'run', 'sys', 'write'],
|
||||
},
|
||||
description: 'Deno permissions to grant',
|
||||
},
|
||||
},
|
||||
required: ['code'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private smartdeno!: plugins.smartdeno.SmartDeno;
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.smartdeno = new plugins.smartdeno.SmartDeno();
|
||||
await this.smartdeno.start();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
if (this.smartdeno) {
|
||||
await this.smartdeno.stop();
|
||||
}
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
public async execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<interfaces.IToolExecutionResult> {
|
||||
this.validateAction(action);
|
||||
this.ensureInitialized();
|
||||
|
||||
try {
|
||||
const code = params.code as string;
|
||||
const permissions = (params.permissions as TDenoPermission[]) || [];
|
||||
|
||||
// Execute the script
|
||||
const result = await this.smartdeno.executeScript(code, {
|
||||
permissions,
|
||||
});
|
||||
|
||||
switch (action) {
|
||||
case 'execute': {
|
||||
return {
|
||||
success: result.exitCode === 0,
|
||||
result: {
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
permissions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'executeWithResult': {
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Script failed with exit code ${result.exitCode}: ${result.stderr}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse the last line of stdout as JSON
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
try {
|
||||
const parsedResult = JSON.parse(lastLine);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
parsed: parsedResult,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
},
|
||||
};
|
||||
} catch (parseError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to parse JSON from last line of output: ${lastLine}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown action: ${action}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getToolExplanation(): string {
|
||||
return `## Tool: deno
|
||||
Execute TypeScript/JavaScript code in a sandboxed Deno environment with fine-grained permission control.
|
||||
|
||||
### Actions:
|
||||
|
||||
**execute** - Execute TypeScript/JavaScript code and return stdout/stderr
|
||||
Parameters:
|
||||
- code (required): TypeScript/JavaScript code to execute
|
||||
- permissions (optional): Array of Deno permissions to grant. Options: "all", "env", "net", "read", "write", "run", "sys", "ffi", "hrtime". Default: none (fully sandboxed)
|
||||
|
||||
Example - Simple execution:
|
||||
<tool_call>
|
||||
<tool>deno</tool>
|
||||
<action>execute</action>
|
||||
<params>{"code": "console.log('Hello from Deno!');"}</params>
|
||||
</tool_call>
|
||||
|
||||
Example - With network permission:
|
||||
<tool_call>
|
||||
<tool>deno</tool>
|
||||
<action>execute</action>
|
||||
<params>{"code": "const resp = await fetch('https://api.example.com/data');\\nconsole.log(await resp.text());", "permissions": ["net"]}</params>
|
||||
</tool_call>
|
||||
|
||||
**executeWithResult** - Execute code that outputs JSON on the last line of stdout
|
||||
Parameters:
|
||||
- code (required): Code that console.logs a JSON value on the final line
|
||||
- permissions (optional): Array of Deno permissions to grant
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>deno</tool>
|
||||
<action>executeWithResult</action>
|
||||
<params>{"code": "const result = { sum: 1 + 2, product: 2 * 3 };\\nconsole.log(JSON.stringify(result));"}</params>
|
||||
</tool_call>
|
||||
`;
|
||||
}
|
||||
|
||||
public getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
const code = params.code as string;
|
||||
const permissions = (params.permissions as string[]) || [];
|
||||
|
||||
// Create a preview of the code (first 100 chars)
|
||||
const codePreview = code.length > 100 ? code.substring(0, 100) + '...' : code;
|
||||
// Escape newlines for single-line display
|
||||
const cleanPreview = codePreview.replace(/\n/g, '\\n');
|
||||
|
||||
const permissionInfo = permissions.length > 0
|
||||
? ` [permissions: ${permissions.join(', ')}]`
|
||||
: ' [sandboxed - no permissions]';
|
||||
|
||||
switch (action) {
|
||||
case 'execute':
|
||||
return `Execute Deno code${permissionInfo}: "${cleanPreview}"`;
|
||||
|
||||
case 'executeWithResult':
|
||||
return `Execute Deno code and parse JSON result${permissionInfo}: "${cleanPreview}"`;
|
||||
|
||||
default:
|
||||
return `Unknown action: ${action}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
// Forward declaration to avoid circular import at module load time
|
||||
// The actual import happens lazily in initialize()
|
||||
let DualAgentOrchestrator: typeof import('./smartagent.classes.dualagent.js').DualAgentOrchestrator;
|
||||
|
||||
/**
|
||||
* ExpertTool - A specialized agent wrapped as a tool
|
||||
*
|
||||
* Enables hierarchical agent architectures where the Driver can delegate
|
||||
* complex tasks to specialized experts with their own tools and policies.
|
||||
*/
|
||||
export class ExpertTool extends BaseToolWrapper {
|
||||
public name: string;
|
||||
public description: string;
|
||||
public actions: interfaces.IToolAction[] = [
|
||||
{
|
||||
name: 'consult',
|
||||
description: 'Delegate a task or question to this expert',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: { type: 'string', description: 'The task or question for the expert' },
|
||||
context: { type: 'string', description: 'Additional context to help the expert' },
|
||||
},
|
||||
required: ['task'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private config: interfaces.IExpertConfig;
|
||||
private smartAi: plugins.smartai.SmartAi;
|
||||
private inner?: InstanceType<typeof DualAgentOrchestrator>;
|
||||
|
||||
constructor(config: interfaces.IExpertConfig, smartAi: plugins.smartai.SmartAi) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.smartAi = smartAi;
|
||||
this.name = config.name;
|
||||
this.description = config.description;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Lazy import to avoid circular dependency
|
||||
if (!DualAgentOrchestrator) {
|
||||
const module = await import('./smartagent.classes.dualagent.js');
|
||||
DualAgentOrchestrator = module.DualAgentOrchestrator;
|
||||
}
|
||||
|
||||
this.inner = new DualAgentOrchestrator({
|
||||
smartAiInstance: this.smartAi, // Share SmartAi instance
|
||||
defaultProvider: this.config.provider,
|
||||
driverSystemMessage: this.config.systemMessage,
|
||||
guardianPolicyPrompt: this.config.guardianPolicy,
|
||||
maxIterations: this.config.maxIterations ?? 10,
|
||||
});
|
||||
|
||||
// Register expert's tools
|
||||
if (this.config.tools) {
|
||||
for (const tool of this.config.tools) {
|
||||
// Tools in the config are IAgentToolWrapper, but we need BaseToolWrapper
|
||||
// Since all our tools extend BaseToolWrapper, this cast is safe
|
||||
this.inner.registerTool(tool as BaseToolWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
await this.inner.start();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.inner) {
|
||||
await this.inner.stop();
|
||||
this.inner = undefined;
|
||||
}
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<interfaces.IToolExecutionResult> {
|
||||
this.validateAction(action);
|
||||
this.ensureInitialized();
|
||||
|
||||
const task = params.task as string;
|
||||
const context = params.context as string | undefined;
|
||||
|
||||
const fullTask = context ? `Context: ${context}\n\nTask: ${task}` : task;
|
||||
|
||||
try {
|
||||
const result = await this.inner!.run(fullTask);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
result: {
|
||||
response: result.result,
|
||||
iterations: result.iterations,
|
||||
status: result.status,
|
||||
},
|
||||
summary: result.success
|
||||
? `Expert "${this.name}" completed (${result.iterations} iterations)`
|
||||
: `Expert "${this.name}" failed: ${result.status}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Expert error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
const task = params.task as string;
|
||||
const preview = task.length > 60 ? task.substring(0, 60) + '...' : task;
|
||||
return `Consult ${this.name}: "${preview}"`;
|
||||
}
|
||||
|
||||
getToolExplanation(): string {
|
||||
return `## Expert: ${this.name}
|
||||
${this.description}
|
||||
|
||||
### Usage:
|
||||
Delegate tasks to this expert when you need specialized help.
|
||||
|
||||
\`\`\`
|
||||
<tool_call>
|
||||
<tool>${this.name}</tool>
|
||||
<action>consult</action>
|
||||
<params>{"task": "Your question or task", "context": "Optional background"}</params>
|
||||
</tool_call>
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expert's configuration
|
||||
*/
|
||||
getConfig(): interfaces.IExpertConfig {
|
||||
return this.config;
|
||||
}
|
||||
}
|
||||
@@ -1,885 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* Options for FilesystemTool
|
||||
*/
|
||||
export interface IFilesystemToolOptions {
|
||||
/** Base path to scope all operations to. If set, all paths must be within this directory. */
|
||||
basePath?: string;
|
||||
/** Glob patterns to exclude from listings (e.g., ['.nogit/**', 'node_modules/**']) */
|
||||
excludePatterns?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem tool for file and directory operations
|
||||
* Wraps @push.rocks/smartfs
|
||||
*/
|
||||
export class FilesystemTool extends BaseToolWrapper {
|
||||
public name = 'filesystem';
|
||||
public description = 'Read, write, list, and delete files and directories';
|
||||
|
||||
/** Base path to scope all operations to */
|
||||
private basePath?: string;
|
||||
/** Glob patterns to exclude from listings */
|
||||
private excludePatterns: string[];
|
||||
|
||||
constructor(options?: IFilesystemToolOptions) {
|
||||
super();
|
||||
if (options?.basePath) {
|
||||
this.basePath = plugins.path.resolve(options.basePath);
|
||||
}
|
||||
this.excludePatterns = options?.excludePatterns || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a relative path should be excluded based on exclude patterns
|
||||
*/
|
||||
private isExcluded(relativePath: string): boolean {
|
||||
if (this.excludePatterns.length === 0) return false;
|
||||
return this.excludePatterns.some(pattern =>
|
||||
plugins.minimatch(relativePath, pattern, { dot: true })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path is within the allowed base path
|
||||
* @throws Error if path is outside allowed directory
|
||||
*/
|
||||
private validatePath(pathArg: string): string {
|
||||
const resolved = plugins.path.resolve(pathArg);
|
||||
if (this.basePath) {
|
||||
// Ensure the resolved path starts with the base path
|
||||
if (!resolved.startsWith(this.basePath + plugins.path.sep) && resolved !== this.basePath) {
|
||||
throw new Error(`Access denied: path "${pathArg}" is outside allowed directory "${this.basePath}"`);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public actions: interfaces.IToolAction[] = [
|
||||
{
|
||||
name: 'read',
|
||||
description: 'Read file contents (full or specific line range)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
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'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'write',
|
||||
description: 'Write content to a file (creates or overwrites)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Absolute path to the file' },
|
||||
content: { type: 'string', description: 'Content to write' },
|
||||
encoding: {
|
||||
type: 'string',
|
||||
enum: ['utf8', 'binary', 'base64'],
|
||||
default: 'utf8',
|
||||
description: 'File encoding',
|
||||
},
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'append',
|
||||
description: 'Append content to a file',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Absolute path to the file' },
|
||||
content: { type: 'string', description: 'Content to append' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List files and directories in a path',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Directory path to list' },
|
||||
recursive: { type: 'boolean', default: false, description: 'List recursively' },
|
||||
filter: { type: 'string', description: 'Glob pattern to filter results (e.g., "*.ts")' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
description: 'Delete a file or directory',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to delete' },
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'For directories, delete recursively',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'exists',
|
||||
description: 'Check if a file or directory exists',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to check' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stat',
|
||||
description: 'Get file or directory statistics (size, dates, etc.)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to get stats for' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'copy',
|
||||
description: 'Copy a file to a new location',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'string', description: 'Source file path' },
|
||||
destination: { type: 'string', description: 'Destination file path' },
|
||||
},
|
||||
required: ['source', 'destination'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'move',
|
||||
description: 'Move a file to a new location',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'string', description: 'Source file path' },
|
||||
destination: { type: 'string', description: 'Destination file path' },
|
||||
},
|
||||
required: ['source', 'destination'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mkdir',
|
||||
description: 'Create a directory',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Directory path to create' },
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Create parent directories if needed',
|
||||
},
|
||||
},
|
||||
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;
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.smartfs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
public async execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<interfaces.IToolExecutionResult> {
|
||||
this.validateAction(action);
|
||||
this.ensureInitialized();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'read': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const encoding = (params.encoding as string) || 'utf8';
|
||||
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: resultContent,
|
||||
encoding,
|
||||
totalLines,
|
||||
startLine: resultStartLine,
|
||||
endLine: resultEndLine,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'write': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const encoding = (params.encoding as string) || 'utf8';
|
||||
await this.smartfs
|
||||
.file(validatedPath)
|
||||
.encoding(encoding as 'utf8' | 'binary' | 'base64')
|
||||
.write(params.content as string);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
written: true,
|
||||
bytesWritten: (params.content as string).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'append': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
await this.smartfs.file(validatedPath).append(params.content as string);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
appended: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
let dir = this.smartfs.directory(validatedPath);
|
||||
if (params.recursive) {
|
||||
dir = dir.recursive();
|
||||
}
|
||||
if (params.filter) {
|
||||
dir = dir.filter(params.filter as string);
|
||||
}
|
||||
let entries = await dir.list();
|
||||
|
||||
// Filter out excluded paths
|
||||
if (this.excludePatterns.length > 0) {
|
||||
entries = entries.filter(entry => {
|
||||
const relativePath = plugins.path.relative(validatedPath, entry.path);
|
||||
return !this.isExcluded(relativePath) && !this.isExcluded(entry.name);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
entries,
|
||||
count: entries.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
// Check if it's a directory or file
|
||||
const exists = await this.smartfs.file(validatedPath).exists();
|
||||
if (exists) {
|
||||
// Try to get stats to check if it's a directory
|
||||
try {
|
||||
const stats = await this.smartfs.file(validatedPath).stat();
|
||||
if (stats.isDirectory && params.recursive) {
|
||||
await this.smartfs.directory(validatedPath).recursive().delete();
|
||||
} else {
|
||||
await this.smartfs.file(validatedPath).delete();
|
||||
}
|
||||
} catch {
|
||||
await this.smartfs.file(validatedPath).delete();
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
deleted: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'exists': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const exists = await this.smartfs.file(validatedPath).exists();
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
exists,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'stat': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const stats = await this.smartfs.file(validatedPath).stat();
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
stats,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'copy': {
|
||||
const validatedSource = this.validatePath(params.source as string);
|
||||
const validatedDest = this.validatePath(params.destination as string);
|
||||
await this.smartfs.file(validatedSource).copy(validatedDest);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
source: params.source,
|
||||
destination: params.destination,
|
||||
copied: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
const validatedSource = this.validatePath(params.source as string);
|
||||
const validatedDest = this.validatePath(params.destination as string);
|
||||
await this.smartfs.file(validatedSource).move(validatedDest);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
source: params.source,
|
||||
destination: params.destination,
|
||||
moved: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'mkdir': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
let dir = this.smartfs.directory(validatedPath);
|
||||
if (params.recursive !== false) {
|
||||
dir = dir.recursive();
|
||||
}
|
||||
await dir.create();
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
created: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
// item is IDirectoryEntry with name, path, isFile, isDirectory properties
|
||||
const itemPath = item.path;
|
||||
const itemRelPath = relativePath ? `${relativePath}/${item.name}` : item.name;
|
||||
const isDir = item.isDirectory;
|
||||
|
||||
// Skip excluded paths
|
||||
if (this.isExcluded(itemRelPath) || this.isExcluded(item.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry: ITreeEntry = {
|
||||
path: itemPath,
|
||||
relativePath: itemRelPath,
|
||||
isDir,
|
||||
depth,
|
||||
};
|
||||
|
||||
if (showSizes && !isDir && item.stats) {
|
||||
entry.size = item.stats.size;
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
|
||||
// Recurse into directories
|
||||
if (isDir && depth < maxDepth) {
|
||||
await collectEntries(itemPath, depth + 1, itemRelPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 file paths relative to base path for readability
|
||||
// Filter out excluded paths
|
||||
const files = matches
|
||||
.map((entry) => ({
|
||||
path: entry.path,
|
||||
relativePath: plugins.path.relative(basePath, entry.path),
|
||||
isDirectory: entry.isDirectory,
|
||||
}))
|
||||
.filter((file) => !this.isExcluded(file.relativePath));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
pattern,
|
||||
basePath,
|
||||
files,
|
||||
count: files.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown action: ${action}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getToolExplanation(): string {
|
||||
return `## Tool: filesystem
|
||||
Read, write, list, and delete files and directories.
|
||||
|
||||
### Actions:
|
||||
|
||||
**read** - Read file contents (full or specific line range)
|
||||
Parameters:
|
||||
- path (required): Path to the file
|
||||
- encoding (optional): File encoding - "utf8" (default), "binary", or "base64"
|
||||
- startLine (optional): First line to read (1-indexed, inclusive)
|
||||
- endLine (optional): Last line to read (1-indexed, inclusive)
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>read</action>
|
||||
<params>{"path": "/path/to/file.txt"}</params>
|
||||
</tool_call>
|
||||
|
||||
Example with line range:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>read</action>
|
||||
<params>{"path": "/path/to/file.txt", "startLine": 10, "endLine": 20}</params>
|
||||
</tool_call>
|
||||
|
||||
**write** - Write content to a file (creates or overwrites)
|
||||
Parameters:
|
||||
- path (required): Absolute path to the file
|
||||
- content (required): Content to write
|
||||
- encoding (optional): File encoding - "utf8" (default), "binary", or "base64"
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>write</action>
|
||||
<params>{"path": "/path/to/output.txt", "content": "Hello, World!"}</params>
|
||||
</tool_call>
|
||||
|
||||
**list** - List files and directories in a path
|
||||
Parameters:
|
||||
- path (required): Directory path to list
|
||||
- recursive (optional): List recursively (default: false)
|
||||
- filter (optional): Glob pattern to filter results (e.g., "*.ts")
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>list</action>
|
||||
<params>{"path": "/path/to/dir", "recursive": true, "filter": "*.ts"}</params>
|
||||
</tool_call>
|
||||
|
||||
**exists** - Check if a file or directory exists
|
||||
Parameters:
|
||||
- path (required): Path to check
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>exists</action>
|
||||
<params>{"path": "/path/to/check"}</params>
|
||||
</tool_call>
|
||||
|
||||
**mkdir** - Create a directory
|
||||
Parameters:
|
||||
- path (required): Directory path to create
|
||||
- recursive (optional): Create parent directories if needed (default: true)
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>mkdir</action>
|
||||
<params>{"path": "/path/to/new/dir"}</params>
|
||||
</tool_call>
|
||||
|
||||
**delete** - Delete a file or directory
|
||||
Parameters:
|
||||
- path (required): Path to delete
|
||||
- recursive (optional): For directories, delete recursively (default: false)
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>delete</action>
|
||||
<params>{"path": "/path/to/delete", "recursive": true}</params>
|
||||
</tool_call>
|
||||
|
||||
**copy** - Copy a file to a new location
|
||||
Parameters:
|
||||
- source (required): Source file path
|
||||
- destination (required): Destination file path
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>copy</action>
|
||||
<params>{"source": "/path/to/source.txt", "destination": "/path/to/dest.txt"}</params>
|
||||
</tool_call>
|
||||
|
||||
**move** - Move a file to a new location
|
||||
Parameters:
|
||||
- source (required): Source file path
|
||||
- destination (required): Destination file path
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>move</action>
|
||||
<params>{"source": "/path/to/old.txt", "destination": "/path/to/new.txt"}</params>
|
||||
</tool_call>
|
||||
|
||||
**stat** - Get file or directory statistics (size, dates, etc.)
|
||||
Parameters:
|
||||
- path (required): Path to get stats for
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>stat</action>
|
||||
<params>{"path": "/path/to/file.txt"}</params>
|
||||
</tool_call>
|
||||
|
||||
**append** - Append content to a file
|
||||
Parameters:
|
||||
- path (required): Absolute path to the file
|
||||
- content (required): Content to append
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>append</action>
|
||||
<params>{"path": "/path/to/log.txt", "content": "New log entry\\n"}</params>
|
||||
</tool_call>
|
||||
|
||||
**tree** - Show directory structure as a tree
|
||||
Parameters:
|
||||
- path (required): Root directory path
|
||||
- maxDepth (optional): Maximum depth to traverse (default: 3)
|
||||
- filter (optional): Glob pattern to filter files
|
||||
- showSizes (optional): Include file sizes in output (default: false)
|
||||
- format (optional): Output format - "string" (default) or "json"
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>tree</action>
|
||||
<params>{"path": "/path/to/dir", "maxDepth": 2}</params>
|
||||
</tool_call>
|
||||
|
||||
**glob** - Find files matching a glob pattern
|
||||
Parameters:
|
||||
- pattern (required): Glob pattern (e.g., "**/*.ts", "src/**/*.js")
|
||||
- path (optional): Base path to search from
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>filesystem</tool>
|
||||
<action>glob</action>
|
||||
<params>{"pattern": "**/*.ts", "path": "/path/to/project"}</params>
|
||||
</tool_call>
|
||||
`;
|
||||
}
|
||||
|
||||
public getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
switch (action) {
|
||||
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;
|
||||
const preview = content.length > 100 ? content.substring(0, 100) + '...' : content;
|
||||
return `Write ${content.length} bytes to "${params.path}". Content preview: "${preview}"`;
|
||||
}
|
||||
|
||||
case 'append': {
|
||||
const content = params.content as string;
|
||||
const preview = content.length > 100 ? content.substring(0, 100) + '...' : content;
|
||||
return `Append ${content.length} bytes to "${params.path}". Content preview: "${preview}"`;
|
||||
}
|
||||
|
||||
case 'list':
|
||||
return `List directory "${params.path}"${params.recursive ? ' recursively' : ''}${params.filter ? ` with filter "${params.filter}"` : ''}`;
|
||||
|
||||
case 'delete':
|
||||
return `Delete "${params.path}"${params.recursive ? ' recursively' : ''}`;
|
||||
|
||||
case 'exists':
|
||||
return `Check if "${params.path}" exists`;
|
||||
|
||||
case 'stat':
|
||||
return `Get statistics for "${params.path}"`;
|
||||
|
||||
case 'copy':
|
||||
return `Copy "${params.source}" to "${params.destination}"`;
|
||||
|
||||
case 'move':
|
||||
return `Move "${params.source}" to "${params.destination}"`;
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* HTTP tool for making web requests
|
||||
* Wraps @push.rocks/smartrequest
|
||||
*/
|
||||
export class HttpTool extends BaseToolWrapper {
|
||||
public name = 'http';
|
||||
public description = 'Make HTTP requests to web APIs and services';
|
||||
|
||||
public actions: interfaces.IToolAction[] = [
|
||||
{
|
||||
name: 'get',
|
||||
description: 'Make a GET request',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'URL to request' },
|
||||
headers: { type: 'object', description: 'Request headers (key-value pairs)' },
|
||||
query: { type: 'object', description: 'Query parameters (key-value pairs)' },
|
||||
timeout: { type: 'number', description: 'Timeout in milliseconds' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'post',
|
||||
description: 'Make a POST request with JSON body',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'URL to request' },
|
||||
body: { type: 'object', description: 'JSON body to send' },
|
||||
headers: { type: 'object', description: 'Request headers (key-value pairs)' },
|
||||
query: { type: 'object', description: 'Query parameters (key-value pairs)' },
|
||||
timeout: { type: 'number', description: 'Timeout in milliseconds' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'put',
|
||||
description: 'Make a PUT request with JSON body',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'URL to request' },
|
||||
body: { type: 'object', description: 'JSON body to send' },
|
||||
headers: { type: 'object', description: 'Request headers (key-value pairs)' },
|
||||
timeout: { type: 'number', description: 'Timeout in milliseconds' },
|
||||
},
|
||||
required: ['url', 'body'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'patch',
|
||||
description: 'Make a PATCH request with JSON body',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'URL to request' },
|
||||
body: { type: 'object', description: 'JSON body to send' },
|
||||
headers: { type: 'object', description: 'Request headers (key-value pairs)' },
|
||||
timeout: { type: 'number', description: 'Timeout in milliseconds' },
|
||||
},
|
||||
required: ['url', 'body'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
description: 'Make a DELETE request',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'URL to request' },
|
||||
headers: { type: 'object', description: 'Request headers (key-value pairs)' },
|
||||
timeout: { type: 'number', description: 'Timeout in milliseconds' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
// SmartRequest is stateless, no initialization needed
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
public async execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<interfaces.IToolExecutionResult> {
|
||||
this.validateAction(action);
|
||||
this.ensureInitialized();
|
||||
|
||||
try {
|
||||
let request = plugins.smartrequest.SmartRequest.create().url(params.url as string);
|
||||
|
||||
// Add headers
|
||||
if (params.headers && typeof params.headers === 'object') {
|
||||
for (const [key, value] of Object.entries(params.headers as Record<string, string>)) {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add query parameters
|
||||
if (params.query && typeof params.query === 'object') {
|
||||
request = request.query(params.query as Record<string, string>);
|
||||
}
|
||||
|
||||
// Add timeout
|
||||
if (params.timeout) {
|
||||
request = request.timeout(params.timeout as number);
|
||||
}
|
||||
|
||||
// Add JSON body for POST, PUT, PATCH
|
||||
if (params.body && ['post', 'put', 'patch'].includes(action)) {
|
||||
request = request.json(params.body);
|
||||
}
|
||||
|
||||
// Execute the request
|
||||
let response;
|
||||
switch (action) {
|
||||
case 'get':
|
||||
response = await request.get();
|
||||
break;
|
||||
case 'post':
|
||||
response = await request.post();
|
||||
break;
|
||||
case 'put':
|
||||
response = await request.put();
|
||||
break;
|
||||
case 'patch':
|
||||
response = await request.patch();
|
||||
break;
|
||||
case 'delete':
|
||||
response = await request.delete();
|
||||
break;
|
||||
default:
|
||||
return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
|
||||
// Parse response body
|
||||
let body: unknown;
|
||||
const contentType = response.headers?.['content-type'] || '';
|
||||
|
||||
try {
|
||||
if (contentType.includes('application/json')) {
|
||||
body = await response.json();
|
||||
} else {
|
||||
body = await response.text();
|
||||
}
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
result: {
|
||||
url: params.url,
|
||||
method: action.toUpperCase(),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
ok: response.ok,
|
||||
headers: response.headers,
|
||||
body,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getToolExplanation(): string {
|
||||
return `## Tool: http
|
||||
Make HTTP requests to web APIs and services.
|
||||
|
||||
### Actions:
|
||||
|
||||
**get** - Make a GET request
|
||||
Parameters:
|
||||
- url (required): URL to request
|
||||
- headers (optional): Request headers (key-value object)
|
||||
- query (optional): Query parameters (key-value object)
|
||||
- timeout (optional): Timeout in milliseconds
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>http</tool>
|
||||
<action>get</action>
|
||||
<params>{"url": "https://api.example.com/data", "headers": {"Authorization": "Bearer token123"}}</params>
|
||||
</tool_call>
|
||||
|
||||
**post** - Make a POST request with JSON body
|
||||
Parameters:
|
||||
- url (required): URL to request
|
||||
- body (optional): JSON body to send
|
||||
- headers (optional): Request headers (key-value object)
|
||||
- query (optional): Query parameters (key-value object)
|
||||
- timeout (optional): Timeout in milliseconds
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>http</tool>
|
||||
<action>post</action>
|
||||
<params>{"url": "https://api.example.com/submit", "body": {"name": "test", "value": 123}}</params>
|
||||
</tool_call>
|
||||
|
||||
**put** - Make a PUT request with JSON body
|
||||
Parameters:
|
||||
- url (required): URL to request
|
||||
- body (required): JSON body to send
|
||||
- headers (optional): Request headers (key-value object)
|
||||
- timeout (optional): Timeout in milliseconds
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>http</tool>
|
||||
<action>put</action>
|
||||
<params>{"url": "https://api.example.com/resource/1", "body": {"name": "updated"}}</params>
|
||||
</tool_call>
|
||||
|
||||
**patch** - Make a PATCH request with JSON body
|
||||
Parameters:
|
||||
- url (required): URL to request
|
||||
- body (required): JSON body to send
|
||||
- headers (optional): Request headers (key-value object)
|
||||
- timeout (optional): Timeout in milliseconds
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>http</tool>
|
||||
<action>patch</action>
|
||||
<params>{"url": "https://api.example.com/resource/1", "body": {"status": "active"}}</params>
|
||||
</tool_call>
|
||||
|
||||
**delete** - Make a DELETE request
|
||||
Parameters:
|
||||
- url (required): URL to request
|
||||
- headers (optional): Request headers (key-value object)
|
||||
- timeout (optional): Timeout in milliseconds
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>http</tool>
|
||||
<action>delete</action>
|
||||
<params>{"url": "https://api.example.com/resource/1"}</params>
|
||||
</tool_call>
|
||||
`;
|
||||
}
|
||||
|
||||
public getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
const method = action.toUpperCase();
|
||||
let summary = `${method} request to "${params.url}"`;
|
||||
|
||||
if (params.query && Object.keys(params.query as object).length > 0) {
|
||||
const queryStr = JSON.stringify(params.query);
|
||||
summary += ` with query: ${queryStr.length > 50 ? queryStr.substring(0, 50) + '...' : queryStr}`;
|
||||
}
|
||||
|
||||
if (params.body) {
|
||||
const bodyStr = JSON.stringify(params.body);
|
||||
const preview = bodyStr.length > 100 ? bodyStr.substring(0, 100) + '...' : bodyStr;
|
||||
summary += ` with body: ${preview}`;
|
||||
}
|
||||
|
||||
if (params.headers && Object.keys(params.headers as object).length > 0) {
|
||||
const headerKeys = Object.keys(params.headers as object).join(', ');
|
||||
summary += ` with headers: [${headerKeys}]`;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* JsonValidatorTool - Validates and formats JSON data
|
||||
* Useful for agents to self-validate their JSON output before completing a task
|
||||
*/
|
||||
export class JsonValidatorTool extends BaseToolWrapper {
|
||||
public name = 'json';
|
||||
public description = 'Validate and format JSON data. Use this to verify your JSON output is valid before completing a task.';
|
||||
|
||||
public actions: interfaces.IToolAction[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate that a string is valid JSON and optionally check required fields',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
jsonString: {
|
||||
type: 'string',
|
||||
description: 'The JSON string to validate',
|
||||
},
|
||||
requiredFields: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Optional list of field names that must be present at the root level',
|
||||
},
|
||||
},
|
||||
required: ['jsonString'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'format',
|
||||
description: 'Parse and pretty-print JSON string',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
jsonString: {
|
||||
type: 'string',
|
||||
description: 'The JSON string to format',
|
||||
},
|
||||
},
|
||||
required: ['jsonString'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<interfaces.IToolExecutionResult> {
|
||||
this.validateAction(action);
|
||||
|
||||
switch (action) {
|
||||
case 'validate':
|
||||
return this.validateJson(params);
|
||||
case 'format':
|
||||
return this.formatJson(params);
|
||||
default:
|
||||
return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON string and optionally check for required fields
|
||||
*/
|
||||
private validateJson(params: Record<string, unknown>): interfaces.IToolExecutionResult {
|
||||
const jsonString = params.jsonString as string;
|
||||
const requiredFields = params.requiredFields as string[] | undefined;
|
||||
|
||||
if (!jsonString || typeof jsonString !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'jsonString parameter is required and must be a string',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
// Check required fields if specified
|
||||
if (requiredFields && Array.isArray(requiredFields)) {
|
||||
const missingFields = requiredFields.filter((field) => {
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return true;
|
||||
}
|
||||
return !(field in parsed);
|
||||
});
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Missing required fields: ${missingFields.join(', ')}`,
|
||||
result: {
|
||||
valid: false,
|
||||
missingFields,
|
||||
presentFields: Object.keys(parsed || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
valid: true,
|
||||
parsed,
|
||||
type: Array.isArray(parsed) ? 'array' : typeof parsed,
|
||||
fieldCount: typeof parsed === 'object' && parsed !== null ? Object.keys(parsed).length : undefined,
|
||||
},
|
||||
summary: `JSON is valid (${Array.isArray(parsed) ? 'array' : typeof parsed})`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
|
||||
// Extract position from error message if available
|
||||
const posMatch = errorMessage.match(/position\s*(\d+)/i);
|
||||
const position = posMatch ? parseInt(posMatch[1]) : undefined;
|
||||
|
||||
// Provide context around the error position
|
||||
let context: string | undefined;
|
||||
if (position !== undefined) {
|
||||
const start = Math.max(0, position - 20);
|
||||
const end = Math.min(jsonString.length, position + 20);
|
||||
context = jsonString.substring(start, end);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid JSON: ${errorMessage}`,
|
||||
result: {
|
||||
valid: false,
|
||||
errorPosition: position,
|
||||
errorContext: context,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format/pretty-print JSON string
|
||||
*/
|
||||
private formatJson(params: Record<string, unknown>): interfaces.IToolExecutionResult {
|
||||
const jsonString = params.jsonString as string;
|
||||
|
||||
if (!jsonString || typeof jsonString !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'jsonString parameter is required and must be a string',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: formatted,
|
||||
summary: `Formatted JSON (${formatted.length} chars)`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot format invalid JSON: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getToolExplanation(): string {
|
||||
return `## Tool: json
|
||||
Validate and format JSON data. Use this to verify your JSON output is valid before completing a task.
|
||||
|
||||
### Actions:
|
||||
|
||||
**validate** - Validate that a string is valid JSON and optionally check required fields
|
||||
Parameters:
|
||||
- jsonString (required): The JSON string to validate
|
||||
- requiredFields (optional): Array of field names that must be present
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>json</tool>
|
||||
<action>validate</action>
|
||||
<params>{"jsonString": "{\\"invoice_number\\":\\"INV-001\\",\\"total\\":99.99}", "requiredFields": ["invoice_number", "total"]}</params>
|
||||
</tool_call>
|
||||
|
||||
**format** - Parse and pretty-print JSON string
|
||||
Parameters:
|
||||
- jsonString (required): The JSON string to format
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>json</tool>
|
||||
<action>format</action>
|
||||
<params>{"jsonString": "{\\"name\\":\\"test\\",\\"value\\":123}"}</params>
|
||||
</tool_call>
|
||||
`;
|
||||
}
|
||||
|
||||
getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
const jsonStr = (params.jsonString as string) || '';
|
||||
const preview = jsonStr.length > 50 ? jsonStr.substring(0, 50) + '...' : jsonStr;
|
||||
|
||||
switch (action) {
|
||||
case 'validate':
|
||||
const fields = params.requiredFields as string[] | undefined;
|
||||
const fieldInfo = fields ? ` (checking fields: ${fields.join(', ')})` : '';
|
||||
return `Validate JSON: ${preview}${fieldInfo}`;
|
||||
case 'format':
|
||||
return `Format JSON: ${preview}`;
|
||||
default:
|
||||
return `JSON ${action}: ${preview}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
import { ToolRegistry } from './smartagent.classes.toolregistry.js';
|
||||
|
||||
/**
|
||||
* ToolSearchTool - AI-facing interface for discovering and activating tools
|
||||
*
|
||||
* This tool enables the Driver to:
|
||||
* - Search for tools by capability
|
||||
* - List all available tools
|
||||
* - Activate on-demand tools
|
||||
* - Get detailed information about specific tools
|
||||
*/
|
||||
export class ToolSearchTool extends BaseToolWrapper {
|
||||
public name = 'tools';
|
||||
public description =
|
||||
'Search for and activate available tools and experts. Use this to discover specialized capabilities.';
|
||||
|
||||
public actions: interfaces.IToolAction[] = [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search for tools by name, description, tags, or capabilities',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List all available tools grouped by visibility',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'activate',
|
||||
description: 'Activate an on-demand tool to make it available for use',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name of the tool to activate' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'details',
|
||||
description: 'Get detailed information about a specific tool',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name of the tool' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private registry: ToolRegistry;
|
||||
private onToolActivated?: (tool: BaseToolWrapper) => void;
|
||||
|
||||
constructor(registry: ToolRegistry, onToolActivated?: (tool: BaseToolWrapper) => void) {
|
||||
super();
|
||||
this.registry = registry;
|
||||
this.onToolActivated = onToolActivated;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<interfaces.IToolExecutionResult> {
|
||||
this.validateAction(action);
|
||||
|
||||
switch (action) {
|
||||
case 'search':
|
||||
return this.handleSearch(params.query as string);
|
||||
case 'list':
|
||||
return this.handleList();
|
||||
case 'activate':
|
||||
return this.handleActivate(params.name as string);
|
||||
case 'details':
|
||||
return this.handleDetails(params.name as string);
|
||||
default:
|
||||
return { success: false, error: `Unknown action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
private handleSearch(query: string): interfaces.IToolExecutionResult {
|
||||
const results = this.registry.search(query);
|
||||
return {
|
||||
success: true,
|
||||
result: results.map((m) => ({
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
visibility: m.visibility,
|
||||
isActivated: m.isActivated,
|
||||
category: m.category,
|
||||
tags: m.tags,
|
||||
actionCount: m.actions.length,
|
||||
})),
|
||||
summary: `Found ${results.length} tools matching "${query}"`,
|
||||
};
|
||||
}
|
||||
|
||||
private handleList(): interfaces.IToolExecutionResult {
|
||||
const all = this.registry.getAllMetadata();
|
||||
const initial = all.filter((m) => m.visibility === 'initial');
|
||||
const onDemand = all.filter((m) => m.visibility === 'on-demand');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
initial: initial.map((m) => ({
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
category: m.category,
|
||||
})),
|
||||
onDemand: onDemand.map((m) => ({
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
category: m.category,
|
||||
isActivated: m.isActivated,
|
||||
})),
|
||||
summary: `${initial.length} initial, ${onDemand.length} on-demand`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async handleActivate(name: string): Promise<interfaces.IToolExecutionResult> {
|
||||
const result = await this.registry.activate(name);
|
||||
|
||||
if (result.success && this.onToolActivated) {
|
||||
const tool = this.registry.getTool(name);
|
||||
if (tool) {
|
||||
this.onToolActivated(tool);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
result: result.success ? { name, message: `Tool "${name}" is now available` } : undefined,
|
||||
error: result.error,
|
||||
summary: result.success ? `Activated: ${name}` : result.error,
|
||||
};
|
||||
}
|
||||
|
||||
private handleDetails(name: string): interfaces.IToolExecutionResult {
|
||||
const tool = this.registry.getTool(name);
|
||||
const meta = this.registry.getMetadata(name);
|
||||
|
||||
if (!tool || !meta) {
|
||||
return { success: false, error: `Tool "${name}" not found` };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
name: meta.name,
|
||||
description: meta.description,
|
||||
visibility: meta.visibility,
|
||||
isActivated: meta.isActivated,
|
||||
category: meta.category,
|
||||
tags: meta.tags,
|
||||
actions: meta.actions,
|
||||
fullExplanation: tool.getToolExplanation(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
switch (action) {
|
||||
case 'search':
|
||||
return `Search tools: "${params.query}"`;
|
||||
case 'list':
|
||||
return 'List all tools';
|
||||
case 'activate':
|
||||
return `Activate tool: ${params.name}`;
|
||||
case 'details':
|
||||
return `Get details: ${params.name}`;
|
||||
default:
|
||||
return `tools.${action}`;
|
||||
}
|
||||
}
|
||||
|
||||
getToolExplanation(): string {
|
||||
return `## Tool: tools
|
||||
Search for and manage available tools and experts.
|
||||
|
||||
### Actions:
|
||||
|
||||
**search** - Find tools by capability
|
||||
\`\`\`
|
||||
<tool_call>
|
||||
<tool>tools</tool>
|
||||
<action>search</action>
|
||||
<params>{"query": "database"}</params>
|
||||
</tool_call>
|
||||
\`\`\`
|
||||
|
||||
**list** - List all tools grouped by visibility
|
||||
\`\`\`
|
||||
<tool_call>
|
||||
<tool>tools</tool>
|
||||
<action>list</action>
|
||||
<params>{}</params>
|
||||
</tool_call>
|
||||
\`\`\`
|
||||
|
||||
**activate** - Activate an on-demand tool
|
||||
\`\`\`
|
||||
<tool_call>
|
||||
<tool>tools</tool>
|
||||
<action>activate</action>
|
||||
<params>{"name": "database_expert"}</params>
|
||||
</tool_call>
|
||||
\`\`\`
|
||||
|
||||
**details** - Get full information about a tool
|
||||
\`\`\`
|
||||
<tool_call>
|
||||
<tool>tools</tool>
|
||||
<action>details</action>
|
||||
<params>{"name": "filesystem"}</params>
|
||||
</tool_call>
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* Shell tool for executing commands securely
|
||||
* Wraps @push.rocks/smartshell with execSpawn for safety (no shell injection)
|
||||
*/
|
||||
export class ShellTool extends BaseToolWrapper {
|
||||
public name = 'shell';
|
||||
public description =
|
||||
'Execute shell commands securely. Uses execSpawn (shell:false) to prevent command injection.';
|
||||
|
||||
public actions: interfaces.IToolAction[] = [
|
||||
{
|
||||
name: 'execute',
|
||||
description:
|
||||
'Execute a command with arguments (secure, no shell injection possible). Command and args are passed separately.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
description: 'The command to execute (e.g., "ls", "cat", "grep", "node")',
|
||||
},
|
||||
args: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Array of arguments (each argument is properly escaped)',
|
||||
},
|
||||
cwd: { type: 'string', description: 'Working directory for the command' },
|
||||
timeout: { type: 'number', description: 'Timeout in milliseconds' },
|
||||
env: {
|
||||
type: 'object',
|
||||
description: 'Additional environment variables (key-value pairs)',
|
||||
},
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'which',
|
||||
description: 'Check if a command exists and get its path',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: 'Command name to look up (e.g., "node", "git")' },
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private smartshell!: plugins.smartshell.Smartshell;
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.smartshell = new plugins.smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
});
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
public async execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<interfaces.IToolExecutionResult> {
|
||||
this.validateAction(action);
|
||||
this.ensureInitialized();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'execute': {
|
||||
const command = params.command as string;
|
||||
const args = (params.args as string[]) || [];
|
||||
|
||||
// Build options
|
||||
const options: {
|
||||
timeout?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cwd?: string;
|
||||
} = {};
|
||||
|
||||
if (params.timeout) {
|
||||
options.timeout = params.timeout as number;
|
||||
}
|
||||
|
||||
if (params.env) {
|
||||
options.env = {
|
||||
...process.env,
|
||||
...(params.env as NodeJS.ProcessEnv),
|
||||
};
|
||||
}
|
||||
|
||||
// Use execSpawn for security - no shell injection possible
|
||||
const result = await this.smartshell.execSpawn(command, args, options);
|
||||
|
||||
return {
|
||||
success: result.exitCode === 0,
|
||||
result: {
|
||||
command,
|
||||
args,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
signal: result.signal,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'which': {
|
||||
try {
|
||||
const commandPath = await plugins.smartshell.which(params.command as string);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
command: params.command,
|
||||
path: commandPath,
|
||||
exists: true,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
command: params.command,
|
||||
path: null,
|
||||
exists: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown action: ${action}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getToolExplanation(): string {
|
||||
return `## Tool: shell
|
||||
Execute shell commands securely. Uses execSpawn (shell:false) to prevent command injection.
|
||||
|
||||
### Actions:
|
||||
|
||||
**execute** - Execute a command with arguments (secure, no shell injection possible)
|
||||
Parameters:
|
||||
- command (required): The command to execute (e.g., "ls", "cat", "grep", "node")
|
||||
- args (optional): Array of arguments (each argument is properly escaped)
|
||||
- cwd (optional): Working directory for the command
|
||||
- timeout (optional): Timeout in milliseconds
|
||||
- env (optional): Additional environment variables (key-value object)
|
||||
|
||||
Example - List files:
|
||||
<tool_call>
|
||||
<tool>shell</tool>
|
||||
<action>execute</action>
|
||||
<params>{"command": "ls", "args": ["-la", "/path/to/dir"]}</params>
|
||||
</tool_call>
|
||||
|
||||
Example - Run Node script:
|
||||
<tool_call>
|
||||
<tool>shell</tool>
|
||||
<action>execute</action>
|
||||
<params>{"command": "node", "args": ["script.js"], "cwd": "/path/to/project"}</params>
|
||||
</tool_call>
|
||||
|
||||
Example - Search in files:
|
||||
<tool_call>
|
||||
<tool>shell</tool>
|
||||
<action>execute</action>
|
||||
<params>{"command": "grep", "args": ["-r", "pattern", "src/"]}</params>
|
||||
</tool_call>
|
||||
|
||||
**which** - Check if a command exists and get its path
|
||||
Parameters:
|
||||
- command (required): Command name to look up (e.g., "node", "git")
|
||||
|
||||
Example:
|
||||
<tool_call>
|
||||
<tool>shell</tool>
|
||||
<action>which</action>
|
||||
<params>{"command": "node"}</params>
|
||||
</tool_call>
|
||||
`;
|
||||
}
|
||||
|
||||
public getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
switch (action) {
|
||||
case 'execute': {
|
||||
const command = params.command as string;
|
||||
const args = (params.args as string[]) || [];
|
||||
const fullCommand = [command, ...args].join(' ');
|
||||
let summary = `Execute: ${fullCommand}`;
|
||||
|
||||
if (params.cwd) {
|
||||
summary += ` (in ${params.cwd})`;
|
||||
}
|
||||
|
||||
if (params.timeout) {
|
||||
summary += ` [timeout: ${params.timeout}ms]`;
|
||||
}
|
||||
|
||||
if (params.env && Object.keys(params.env as object).length > 0) {
|
||||
const envKeys = Object.keys(params.env as object).join(', ');
|
||||
summary += ` [env: ${envKeys}]`;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
case 'which':
|
||||
return `Check if command "${params.command}" exists and get its path`;
|
||||
|
||||
default:
|
||||
return `Unknown action: ${action}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
ts/smartagent.utils.truncation.ts
Normal file
39
ts/smartagent.utils.truncation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Truncation logic derived from opencode (MIT) — https://github.com/sst/opencode
|
||||
|
||||
const MAX_LINES = 2000;
|
||||
const MAX_BYTES = 50 * 1024; // 50 KB
|
||||
|
||||
export interface ITruncateResult {
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
/** Set when truncated: describes what was dropped */
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export function truncateOutput(
|
||||
text: string,
|
||||
options?: { maxLines?: number; maxBytes?: number },
|
||||
): ITruncateResult {
|
||||
const maxLines = options?.maxLines ?? MAX_LINES;
|
||||
const maxBytes = options?.maxBytes ?? MAX_BYTES;
|
||||
const lines = text.split('\n');
|
||||
const totalBytes = Buffer.byteLength(text, 'utf-8');
|
||||
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||
return { content: text, truncated: false };
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
let bytes = 0;
|
||||
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const size = Buffer.byteLength(lines[i], 'utf-8') + (i > 0 ? 1 : 0);
|
||||
if (bytes + size > maxBytes) break;
|
||||
out.push(lines[i]);
|
||||
bytes += size;
|
||||
}
|
||||
|
||||
const kept = out.length;
|
||||
const dropped = lines.length - kept;
|
||||
const notice = `\n[Output truncated: showing ${kept}/${lines.length} lines. ${dropped} lines omitted.]`;
|
||||
return { content: out.join('\n') + notice, truncated: true, notice };
|
||||
}
|
||||
1
ts_compaction/index.ts
Normal file
1
ts_compaction/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { compactMessages } from './smartagent.compaction.js';
|
||||
6
ts_compaction/plugins.ts
Normal file
6
ts_compaction/plugins.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { generateText } from 'ai';
|
||||
|
||||
export { generateText };
|
||||
|
||||
export type { ModelMessage } from 'ai';
|
||||
export type { LanguageModelV3 } from '@push.rocks/smartai';
|
||||
51
ts_compaction/smartagent.compaction.ts
Normal file
51
ts_compaction/smartagent.compaction.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
const COMPACTION_PROMPT = `Provide a detailed prompt for continuing our conversation above.
|
||||
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
|
||||
The summary that you construct will be used so that another agent can read it and continue the work.
|
||||
|
||||
When constructing the summary, try to stick to this template:
|
||||
---
|
||||
## Goal
|
||||
[What goal(s) is the user trying to accomplish?]
|
||||
|
||||
## Instructions
|
||||
- [What important instructions did the user give you that are relevant]
|
||||
|
||||
## Discoveries
|
||||
[What notable things were learned during this conversation that would be useful for the next agent to know]
|
||||
|
||||
## Accomplished
|
||||
[What work has been completed, what work is still in progress, and what work is left?]
|
||||
|
||||
## Relevant files / directories
|
||||
[A structured list of relevant files that have been read, edited, or created]
|
||||
---`;
|
||||
|
||||
/**
|
||||
* Compacts a message history into a summary.
|
||||
* Pass this as the onContextOverflow handler in IAgentRunOptions.
|
||||
*
|
||||
* @param model The same model used by runAgent, or a cheaper small model
|
||||
* @param messages The full message history that overflowed
|
||||
* @returns A minimal ModelMessage[] containing the summary as context
|
||||
*/
|
||||
export async function compactMessages(
|
||||
model: plugins.LanguageModelV3,
|
||||
messages: plugins.ModelMessage[],
|
||||
): Promise<plugins.ModelMessage[]> {
|
||||
const result = await plugins.generateText({
|
||||
model,
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'user', content: COMPACTION_PROMPT },
|
||||
],
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
role: 'user',
|
||||
content: `[Previous conversation summary]\n\n${result.text}\n\n[End of summary. Continue from here.]`,
|
||||
},
|
||||
];
|
||||
}
|
||||
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