15 Commits

Author SHA1 Message Date
38556c8b12 v3.0.0
Some checks failed
Default (tags) / security (push) Successful in 31s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-06 11:39:01 +00:00
f9a9c9fb48 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. 2026-03-06 11:39:01 +00:00
903de44644 v1.8.0
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 14:39:34 +00:00
5aa69cc998 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 2026-01-20 14:39:34 +00:00
5ca0c80ea9 v1.7.0
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 12:01:07 +00:00
940bf3d3ef feat(docs): document native tool calling support and update README to clarify standard and additional tools 2026-01-20 12:01:07 +00:00
c1b269f301 v1.6.2
Some checks failed
Default (tags) / security (push) Successful in 34s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 03:56:44 +00:00
7cb970f9e2 fix(release): bump version to 1.6.2 2026-01-20 03:56:44 +00:00
1fbcf8bb8b fix(driveragent): save tool_calls in message history for native tool calling
When using native tool calling, the assistant's tool_calls must be saved
in message history. Without this, the model doesn't know it already called
a tool and loops indefinitely calling the same tool.

This fix saves tool_calls in both startTaskWithNativeTools and
continueWithNativeTools methods.

Also updates @push.rocks/smartai to v0.13.3 for tool_calls forwarding support.
2026-01-20 03:56:10 +00:00
4a8789019a v1.6.1
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 03:38:07 +00:00
0da85a5dcd fix(driveragent): include full message history for tool results and use a continuation prompt when invoking provider.collectStreamResponse 2026-01-20 03:38:07 +00:00
121e216eea v1.6.0
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 03:28:59 +00:00
eb1058bfb5 feat(smartagent): record native tool results in message history by adding optional toolName to continueWithNativeTools and passing tool identifier from DualAgent 2026-01-20 03:28:59 +00:00
ecdc125a43 v1.5.4
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 03:16:02 +00:00
73657be550 fix(driveragent): prevent duplicate thinking/output markers during token streaming and mark transitions 2026-01-20 03:16:02 +00:00
34 changed files with 3998 additions and 5608 deletions

View File

@@ -1,5 +1,64 @@
# 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
- Introduce ToolRegistry to manage tool registration, visibility (initial vs on-demand), activation, initialization, cleanup, and search
- Add ToolSearchTool providing search/list/activate/details actions for discovering and enabling on-demand tools
- Add ExpertTool to wrap a DualAgentOrchestrator as a sub-agent (expert) tool and the IExpertConfig interface
- Extend DualAgentOrchestrator API: registerTool(tool, options?), registerExpert(config), enableToolSearch(), getRegistry(); registerStandardTools/start now initialize visible tools via registry
- Add IToolRegistrationOptions and IToolMetadata/IExpertConfig types to smartagent.interfaces and export ToolRegistry/ToolSearchTool/ExpertTool in public entry points
- Documentation updates (readme) describing tool visibility, tool search, and expert/subagent system
## 2026-01-20 - 1.7.0 - feat(docs)
document native tool calling support and update README to clarify standard and additional tools
- Add 'Native Tool Calling' section documenting useNativeToolCalling option and behavior for providers (e.g., Ollama).
- Explain tool name mapping when native tool calling is enabled (toolName_actionName) and streaming markers ([THINKING], [OUTPUT]).
- Add example showing enabling useNativeToolCalling and note ollamaToken config option (Ollama endpoint).
- Clarify that registerStandardTools() registers five tools (Filesystem, HTTP, Shell, Browser, Deno) and that JsonValidatorTool must be registered manually as an additional tool.
- Documentation-only changes (README updates) — no code functionality changed in this diff.
## 2026-01-20 - 1.6.2 - fix(release)
bump version to 1.6.2
- No source changes detected in the diff
- Current package.json version is 1.6.1
- Recommend a patch bump to 1.6.2 for a release
## 2026-01-20 - 1.6.1 - fix(driveragent)
include full message history for tool results and use a continuation prompt when invoking provider.collectStreamResponse
- When toolName is provided, include the full messageHistory (do not slice off the last message) so tool result messages are preserved.
- Set userMessage to a continuation prompt ('Continue with the task. The tool result has been provided above.') when handling tool results to avoid repeating the tool output.
- Keeps existing maxHistoryMessages trimming and validates provider.collectStreamResponse is available before streaming.
## 2026-01-20 - 1.6.0 - feat(smartagent)
record native tool results in message history by adding optional toolName to continueWithNativeTools and passing tool identifier from DualAgent
- continueWithNativeTools(message, toolName?) now accepts an optional toolName; when provided the message is stored with role 'tool' and includes a toolName property (cast to ChatMessage)
- DualAgent constructs a toolNameForHistory as `${proposal.toolName}_${proposal.action}` and forwards it to continueWithNativeTools in both normal and error flows
- Preserves tool-origin information in the conversation history to support native tool calling and tracking
## 2026-01-20 - 1.5.4 - fix(driveragent)
prevent duplicate thinking/output markers during token streaming and mark transitions
- Add isInThinkingMode flag to track thinking vs output state
- Emit "\n[THINKING] " only when transitioning into thinking mode (avoids repeated thinking markers)
- Emit "\n[OUTPUT] " when transitioning out of thinking mode to mark content output
- Reset thinking state after response completes to ensure correct markers for subsequent responses
- Applied the same streaming marker logic to both response handling paths
## 2026-01-20 - 1.5.3 - fix(driveragent)
prefix thinking tokens with [THINKING] when forwarding streaming chunks to onToken

View File

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

View File

@@ -1,33 +1,47 @@
{
"name": "@push.rocks/smartagent",
"version": "1.5.3",
"version": "3.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.1",
"@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

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +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
- **GuardianAgent**: Evaluates tool calls against configured policies
- **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
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
6. **JsonValidatorTool** - JSON validation and formatting
## Source Layout
```
ts/ → core: runAgent, ToolRegistry, truncateOutput, interfaces
ts_tools/ → built-in tool factories (filesystem, shell, http, json)
ts_compaction/ → compactMessages helper for onContextOverflow
```
## 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
## 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`

854
readme.md
View File

@@ -1,12 +1,10 @@
# @push.rocks/smartagent
A dual-agent agentic framework with **Driver** and **Guardian** agents for safe, policy-controlled AI task execution. 🤖🛡️
A lightweight agentic loop built on **Vercel AI SDK v6** via `@push.rocks/smartai`. Register tools, get a model, call `runAgent()` — done. 🚀
## Install
```bash
npm install @push.rocks/smartagent
# or
pnpm install @push.rocks/smartagent
```
@@ -16,595 +14,363 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
## Overview
SmartAgent implements a **dual-agent architecture** where AI safety isn't just an afterthought—it's baked into the core design:
`@push.rocks/smartagent` wraps the AI SDK's `streamText` with `stopWhen: stepCountIs(n)` for **parallel multi-step tool execution**. No classes to instantiate, no lifecycle to manage — just one async function:
- **🎯 Driver Agent**: The executor. Reasons about goals, plans steps, and proposes tool calls
- **🛡️ Guardian Agent**: The gatekeeper. Evaluates every tool call against your policy, approving or rejecting with feedback
```typescript
import { runAgent, tool, z } from '@push.rocks/smartagent';
import { getModel } from '@push.rocks/smartai';
This design ensures safe tool use through **AI-based policy evaluation** rather than rigid programmatic rules. The Guardian can understand context, nuance, and intent—catching dangerous operations that simple regex or allowlists would miss.
const model = getModel({
provider: 'anthropic',
model: 'claude-sonnet-4-5-20250929',
apiKey: process.env.ANTHROPIC_TOKEN,
});
### Why Dual-Agent?
const result = await runAgent({
model,
prompt: 'What is 7 + 35?',
system: 'You are a helpful assistant. Use tools when asked.',
tools: {
calculator: tool({
description: 'Perform arithmetic',
inputSchema: z.object({
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
a: z.number(),
b: z.number(),
}),
execute: async ({ operation, a, b }) => {
const ops = { add: a + b, subtract: a - b, multiply: a * b, divide: a / b };
return String(ops[operation]);
},
}),
},
maxSteps: 10,
});
Traditional AI agents have a fundamental problem: they're given tools and expected to use them responsibly. SmartAgent adds a second AI specifically trained to evaluate whether each action is safe and appropriate. Think of it as separation of concerns, but for AI safety.
console.log(result.text); // "7 + 35 = 42"
console.log(result.steps); // number of agentic steps taken
console.log(result.usage); // { promptTokens, completionTokens, totalTokens }
```
## Architecture
```mermaid
flowchart TB
subgraph Input
Task["User Task"]
Policy["Guardian Policy Prompt"]
end
subgraph Orchestrator["DualAgentOrchestrator"]
Driver["Driver Agent<br/><i>Reason + Plan</i>"]
Guardian["Guardian Agent<br/><i>Evaluate against policy</i>"]
Driver -->|"tool call proposal"| Guardian
Guardian -->|"approve / reject + feedback"| Driver
end
subgraph Tools["Standard Tools"]
FS["Filesystem"]
HTTP["HTTP"]
Shell["Shell"]
Browser["Browser"]
Deno["Deno"]
JSON["JSON Validator"]
end
Task --> Orchestrator
Policy --> Guardian
Driver -->|"execute<br/>(if approved)"| Tools
Tools -->|"result"| Driver
```
┌─────────────────────────────────────────────────┐
runAgent({ model, prompt, tools, maxSteps }) │
│ │
┌───────────┐ ┌──────────┐ ┌───────────┐ │
│ Messages │──▶│ streamText│──▶│ Tools │ │
│ │ (history) │◀──│ (AI SDK) │◀──│ (ToolSet) │ │
└───────────┘ └──────────┘ └───────────┘ │
│ stopWhen: stepCountIs(maxSteps) │
│ + retry with backoff on 429/529/503 │
+ context overflow detection & recovery │
+ tool call repair (case-insensitive matching) │
└─────────────────────────────────────────────────┘
```
## Quick Start
**Key features:**
- 🔄 **Multi-step agentic loop** — the model calls tools, sees results, and continues reasoning until done
-**Parallel tool execution** — multiple tool calls in a single step are executed concurrently
- 🔧 **Auto-retry with backoff** — handles 429/529/503 errors with header-aware retry delays
- 🩹 **Tool call repair** — case-insensitive name matching + invalid tool sink prevents crashes
- 📊 **Token streaming**`onToken` and `onToolCall` callbacks for real-time progress
- 💥 **Context overflow handling** — detects overflow and invokes your `onContextOverflow` callback
## Core API
### `runAgent(options): Promise<IAgentRunResult>`
The single entry point. Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `model` | `LanguageModelV3` | *required* | Model from `@push.rocks/smartai`'s `getModel()` |
| `prompt` | `string` | *required* | The user's task/question |
| `system` | `string` | `undefined` | System prompt |
| `tools` | `ToolSet` | `{}` | Tools the agent can call |
| `maxSteps` | `number` | `20` | Max agentic steps before stopping |
| `messages` | `ModelMessage[]` | `[]` | Conversation history (for multi-turn) |
| `maxRetries` | `number` | `5` | Max retries on rate-limit/server errors |
| `onToken` | `(delta: string) => void` | — | Streaming token callback |
| `onToolCall` | `(name: string) => void` | — | Called when a tool is invoked |
| `onContextOverflow` | `(messages) => messages` | — | Handle context overflow (e.g., compact messages) |
### `IAgentRunResult`
```typescript
import { DualAgentOrchestrator } from '@push.rocks/smartagent';
// Create orchestrator with Guardian policy
const orchestrator = new DualAgentOrchestrator({
openaiToken: 'sk-...',
defaultProvider: 'openai',
guardianPolicyPrompt: `
FILE SYSTEM POLICY:
- ONLY allow reading/writing within /tmp or the current working directory
- REJECT operations on system directories or sensitive files
SHELL POLICY:
- Allow read-only commands (ls, cat, grep, echo)
- REJECT destructive commands (rm, mv, chmod) without explicit justification
FLAG any attempt to expose secrets or credentials.
`,
});
// Register standard tools
orchestrator.registerStandardTools();
// Start the orchestrator (initializes all tools)
await orchestrator.start();
// Run a task
const result = await orchestrator.run('List all TypeScript files in the current directory');
console.log('Success:', result.success);
console.log('Result:', result.result);
console.log('Iterations:', result.iterations);
// Cleanup
await orchestrator.stop();
interface IAgentRunResult {
text: string; // Final response text
finishReason: string; // 'stop', 'tool-calls', 'length', etc.
steps: number; // Number of agentic steps taken
messages: ModelMessage[]; // Full conversation for multi-turn
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
```
## Standard Tools
## Defining Tools 🛠️
SmartAgent comes with six battle-tested tools out of the box:
### 🗂️ FilesystemTool
File and directory operations powered by `@push.rocks/smartfs`.
**Actions**: `read`, `write`, `append`, `list`, `delete`, `exists`, `stat`, `copy`, `move`, `mkdir`
Tools use Vercel AI SDK's `tool()` helper with Zod schemas:
```typescript
// Example tool call by Driver
<tool_call>
<tool>filesystem</tool>
<action>read</action>
<params>{"path": "/tmp/config.json"}</params>
<reasoning>Need to read the configuration file to understand the settings</reasoning>
</tool_call>
```
import { tool, z } from '@push.rocks/smartagent';
**Scoped Filesystem**: Lock file operations to a specific directory with optional exclusion patterns:
```typescript
// Only allow access within a specific directory
orchestrator.registerScopedFilesystemTool('/home/user/workspace');
// With exclusion patterns (glob syntax)
orchestrator.registerScopedFilesystemTool('/home/user/workspace', [
'.nogit/**',
'node_modules/**',
'*.secret',
]);
```
**Line-range Reading**: Read specific portions of large files:
```typescript
<tool_call>
<tool>filesystem</tool>
<action>read</action>
<params>{"path": "/var/log/app.log", "startLine": 100, "endLine": 150}</params>
<reasoning>Reading only the relevant log section to avoid token overload</reasoning>
</tool_call>
```
### 🌐 HttpTool
HTTP requests using `@push.rocks/smartrequest`.
**Actions**: `get`, `post`, `put`, `patch`, `delete`
```typescript
<tool_call>
<tool>http</tool>
<action>get</action>
<params>{"url": "https://api.example.com/data", "headers": {"Authorization": "Bearer token"}}</params>
<reasoning>Fetching data from the API endpoint</reasoning>
</tool_call>
```
### 💻 ShellTool
Secure shell command execution using `@push.rocks/smartshell` with `execSpawn` (no shell injection possible).
**Actions**: `execute`, `which`
```typescript
<tool_call>
<tool>shell</tool>
<action>execute</action>
<params>{"command": "ls", "args": ["-la", "/tmp"]}</params>
<reasoning>Listing directory contents to find relevant files</reasoning>
</tool_call>
```
> 🔒 **Security Note**: The shell tool uses `execSpawn` with `shell: false`, meaning command and arguments are passed separately. This makes shell injection attacks impossible.
### 🌍 BrowserTool
Web page interaction using `@push.rocks/smartbrowser` (Puppeteer-based).
**Actions**: `screenshot`, `pdf`, `evaluate`, `getPageContent`
```typescript
<tool_call>
<tool>browser</tool>
<action>getPageContent</action>
<params>{"url": "https://example.com"}</params>
<reasoning>Extracting text content from the webpage</reasoning>
</tool_call>
```
### 🦕 DenoTool
Execute TypeScript/JavaScript code in a **sandboxed Deno environment** with fine-grained permission control.
**Actions**: `execute`, `executeWithResult`
**Permissions**: `all`, `env`, `ffi`, `hrtime`, `net`, `read`, `run`, `sys`, `write`
By default, code runs **fully sandboxed with no permissions**. Permissions must be explicitly requested and are subject to Guardian approval.
```typescript
// Simple code execution (sandboxed, no permissions)
<tool_call>
<tool>deno</tool>
<action>execute</action>
<params>{"code": "console.log('Hello from Deno!')"}</params>
<reasoning>Running a simple script to verify the environment</reasoning>
</tool_call>
// Code with network permission
<tool_call>
<tool>deno</tool>
<action>execute</action>
<params>{
"code": "const resp = await fetch('https://api.example.com/data'); console.log(await resp.json());",
"permissions": ["net"]
}</params>
<reasoning>Fetching data from API using Deno's fetch</reasoning>
</tool_call>
// Execute and parse JSON result
<tool_call>
<tool>deno</tool>
<action>executeWithResult</action>
<params>{
"code": "const result = { sum: 2 + 2, date: new Date().toISOString() }; console.log(JSON.stringify(result));"
}</params>
<reasoning>Computing values and returning structured data</reasoning>
</tool_call>
```
### 📋 JsonValidatorTool
Validate and format JSON data. Perfect for agents to self-check their JSON output before completing tasks.
**Actions**: `validate`, `format`
```typescript
// Validate JSON with required field checking
<tool_call>
<tool>json</tool>
<action>validate</action>
<params>{
"jsonString": "{\"name\": \"test\", \"version\": \"1.0.0\"}",
"requiredFields": ["name", "version", "description"]
}</params>
<reasoning>Ensuring the config has all required fields before saving</reasoning>
</tool_call>
// Pretty-print JSON
<tool_call>
<tool>json</tool>
<action>format</action>
<params>{"jsonString": "{\"compact\":true,\"data\":[1,2,3]}"}</params>
<reasoning>Formatting JSON for readable output</reasoning>
</tool_call>
```
## 🎥 Streaming Support
SmartAgent supports token-by-token streaming for real-time output during LLM generation:
```typescript
const orchestrator = new DualAgentOrchestrator({
openaiToken: 'sk-...',
defaultProvider: 'openai',
guardianPolicyPrompt: '...',
// Token streaming callback
onToken: (token, source) => {
// source is 'driver' or 'guardian'
process.stdout.write(token);
const myTool = tool({
description: 'Describe what this tool does',
inputSchema: z.object({
param1: z.string().describe('What this parameter is for'),
param2: z.number().optional(),
}),
execute: async ({ param1, param2 }) => {
// Do work, return a string
return `Result: ${param1}`;
},
});
```
This is perfect for CLI applications or UIs that need to show progress as the agent thinks.
## 🖼️ Vision Support
Pass images to vision-capable models for multimodal tasks:
Pass tools as a flat object to `runAgent()`:
```typescript
import { readFileSync } from 'fs';
// Load image as base64
const imageBase64 = readFileSync('screenshot.png').toString('base64');
// Run task with images
const result = await orchestrator.run(
'Analyze this UI screenshot and describe any usability issues',
{ images: [imageBase64] }
);
await runAgent({
model,
prompt: 'Do the thing',
tools: { myTool, anotherTool },
maxSteps: 10,
});
```
## 📊 Progress Events
## ToolRegistry
Get real-time feedback on task execution with the `onProgress` callback:
A lightweight helper for collecting tools:
```typescript
const orchestrator = new DualAgentOrchestrator({
openaiToken: 'sk-...',
guardianPolicyPrompt: '...',
logPrefix: '[MyAgent]', // Optional prefix for log messages
import { ToolRegistry, tool, z } from '@push.rocks/smartagent';
onProgress: (event) => {
// Pre-formatted log message ready for output
console.log(event.logMessage);
const registry = new ToolRegistry();
// Or handle specific event types
switch (event.type) {
case 'tool_proposed':
console.log(`Proposing: ${event.toolName}.${event.action}`);
break;
case 'tool_approved':
console.log(`✓ Approved`);
break;
case 'tool_rejected':
console.log(`✗ Rejected: ${event.reason}`);
break;
case 'task_completed':
console.log(`Done in ${event.iteration} iterations`);
break;
}
registry.register('random_number', tool({
description: 'Generate a random integer between min and max',
inputSchema: z.object({
min: z.number(),
max: z.number(),
}),
execute: async ({ min, max }) => {
return String(Math.floor(Math.random() * (max - min + 1)) + min);
},
}));
registry.register('is_even', tool({
description: 'Check if a number is even',
inputSchema: z.object({ number: z.number() }),
execute: async ({ number: n }) => n % 2 === 0 ? 'Yes' : 'No',
}));
const result = await runAgent({
model,
prompt: 'Generate a random number and tell me if it is even',
tools: registry.getTools(),
maxSteps: 10,
});
```
## Built-in Tool Factories 🧰
Import from the `@push.rocks/smartagent/tools` subpath:
```typescript
import { filesystemTool, shellTool, httpTool, jsonTool } from '@push.rocks/smartagent/tools';
```
### `filesystemTool(options?)`
Returns: `read_file`, `write_file`, `list_directory`, `delete_file`
```typescript
const tools = filesystemTool({ rootDir: '/home/user/workspace' });
await runAgent({
model,
prompt: 'Create a file called hello.txt with "Hello World"',
tools,
maxSteps: 5,
});
```
Options:
- `rootDir` — restrict all file operations to this directory. Paths outside it throw `Access denied`.
### `shellTool(options?)`
Returns: `run_command`
```typescript
const tools = shellTool({ cwd: '/tmp', allowedCommands: ['ls', 'echo', 'cat'] });
await runAgent({
model,
prompt: 'List all files in /tmp',
tools,
maxSteps: 5,
});
```
Options:
- `cwd` — working directory for commands
- `allowedCommands` — whitelist of allowed commands (if set, others are rejected)
### `httpTool()`
Returns: `http_get`, `http_post`
```typescript
const tools = httpTool();
await runAgent({
model,
prompt: 'Fetch the data from https://api.example.com/status',
tools,
maxSteps: 5,
});
```
### `jsonTool()`
Returns: `json_validate`, `json_transform`
```typescript
const tools = jsonTool();
// Direct usage:
const result = await tools.json_validate.execute({
jsonString: '{"name":"test","value":42}',
requiredFields: ['name', 'value'],
});
// → "Valid JSON: object with 2 keys"
```
## Streaming & Callbacks 🎥
Monitor the agent in real-time:
```typescript
const result = await runAgent({
model,
prompt: 'Analyze this data...',
tools,
maxSteps: 10,
// Token-by-token streaming
onToken: (delta) => process.stdout.write(delta),
// Tool call notifications
onToolCall: (toolName) => console.log(`\n🔧 Calling: ${toolName}`),
});
```
## Context Overflow Handling 💥
For long-running agents that might exceed the model's context window, use the compaction subpath:
```typescript
import { runAgent } from '@push.rocks/smartagent';
import { compactMessages } from '@push.rocks/smartagent/compaction';
const result = await runAgent({
model,
prompt: 'Process all 500 files...',
tools,
maxSteps: 100,
onContextOverflow: async (messages) => {
// Summarize the conversation to free up context space
return await compactMessages(model, messages);
},
});
```
**Event Types**: `task_started`, `iteration_started`, `tool_proposed`, `guardian_evaluating`, `tool_approved`, `tool_rejected`, `tool_executing`, `tool_completed`, `task_completed`, `clarification_needed`, `max_iterations`, `max_rejections`
## Output Truncation ✂️
## Guardian Policy Examples
The Guardian's power comes from your policy. Here are battle-tested examples:
### 🔐 Strict Security Policy
Prevent large tool outputs from consuming too much context:
```typescript
const securityPolicy = `
SECURITY POLICY:
1. REJECT any file operations outside /home/user/workspace
2. REJECT any shell commands that could modify system state
3. REJECT any HTTP requests to internal/private IP ranges
4. REJECT any attempts to read environment variables or credentials
5. FLAG and REJECT obfuscated code execution
import { truncateOutput } from '@push.rocks/smartagent';
When rejecting, always explain:
- What policy was violated
- What would be a safer alternative
`;
```
### 🛠️ Development Environment Policy
```typescript
const devPolicy = `
DEVELOPMENT POLICY:
- Allow file operations only within the project directory
- Allow npm/pnpm commands for package management
- Allow git commands for version control
- Allow HTTP requests to public APIs only
- REJECT direct database modifications
- REJECT commands that could affect other users
Always verify:
- File paths are relative or within project bounds
- Commands don't have dangerous flags (--force, -rf)
`;
```
### 🦕 Deno Code Execution Policy
```typescript
const denoPolicy = `
DENO CODE EXECUTION POLICY:
- ONLY allow 'read' permission for files within the workspace
- REJECT 'all' permission unless explicitly justified for the task
- REJECT 'run' permission (subprocess execution) without specific justification
- REJECT code that attempts to:
- Access credentials or environment secrets (even with 'env' permission)
- Make network requests to internal/private IP ranges
- Write to system directories
- FLAG obfuscated or encoded code (base64, eval with dynamic strings)
- Prefer sandboxed execution (no permissions) when possible
When evaluating code:
- Review the actual code content, not just permissions
- Consider what data the code could exfiltrate
- Verify network endpoints are legitimate public APIs
`;
```
## Configuration Options
```typescript
interface IDualAgentOptions {
// Provider tokens (from @push.rocks/smartai)
openaiToken?: string;
anthropicToken?: string;
perplexityToken?: string;
groqToken?: string;
xaiToken?: string;
// Use existing SmartAi instance (optional - avoids duplicate providers)
smartAiInstance?: SmartAi;
// Provider selection
defaultProvider?: TProvider; // For both Driver and Guardian
guardianProvider?: TProvider; // Optional: separate provider for Guardian
// Agent configuration
driverSystemMessage?: string; // Custom system message for Driver
guardianPolicyPrompt: string; // REQUIRED: Policy for Guardian to enforce
name?: string; // Agent system name
verbose?: boolean; // Enable verbose logging
// Limits
maxIterations?: number; // Max task iterations (default: 20)
maxConsecutiveRejections?: number; // Abort after N rejections (default: 3)
maxResultChars?: number; // Max chars for tool results before truncation (default: 15000)
maxHistoryMessages?: number; // Max history messages for API (default: 20)
// Callbacks
onProgress?: (event: IProgressEvent) => void; // Progress event callback
onToken?: (token: string, source: 'driver' | 'guardian') => void; // Streaming callback
logPrefix?: string; // Prefix for log messages
}
```
## Result Interface
```typescript
interface IDualAgentRunResult {
success: boolean; // Whether task completed successfully
completed: boolean; // Task completion status
result: string; // Final result or response
iterations: number; // Number of iterations taken
history: IAgentMessage[]; // Full conversation history
status: TDualAgentRunStatus; // 'completed' | 'max_iterations_reached' | etc.
toolCallCount?: number; // Number of tool calls made
rejectionCount?: number; // Number of Guardian rejections
toolLog?: IToolExecutionLog[]; // Detailed tool execution log
error?: string; // Error message if status is 'error'
}
type TDualAgentRunStatus =
| 'completed'
| 'in_progress'
| 'max_iterations_reached'
| 'max_rejections_reached'
| 'clarification_needed'
| 'error';
```
## Custom Tools
Create custom tools by extending `BaseToolWrapper`:
```typescript
import { BaseToolWrapper, IToolAction, IToolExecutionResult } from '@push.rocks/smartagent';
class MyCustomTool extends BaseToolWrapper {
public name = 'custom';
public description = 'My custom tool for specific operations';
public actions: IToolAction[] = [
{
name: 'myAction',
description: 'Performs a custom action',
parameters: {
type: 'object',
properties: {
input: { type: 'string', description: 'Input for the action' },
},
required: ['input'],
},
},
];
public async initialize(): Promise<void> {
// Setup your tool (called when orchestrator.start() runs)
this.isInitialized = true;
}
public async cleanup(): Promise<void> {
// Cleanup resources (called when orchestrator.stop() runs)
this.isInitialized = false;
}
public async execute(action: string, params: Record<string, unknown>): Promise<IToolExecutionResult> {
this.validateAction(action);
this.ensureInitialized();
if (action === 'myAction') {
return {
success: true,
result: { processed: params.input },
summary: `Processed input: ${params.input}`, // Optional human-readable summary
};
}
return { success: false, error: 'Unknown action' };
}
// Human-readable summary for Guardian evaluation
public getCallSummary(action: string, params: Record<string, unknown>): string {
return `Custom action "${action}" with input "${params.input}"`;
}
}
// Register custom tool
orchestrator.registerTool(new MyCustomTool());
```
## Reusing SmartAi Instances
If you already have a `@push.rocks/smartai` instance, you can share it:
```typescript
import { SmartAi } from '@push.rocks/smartai';
import { DualAgentOrchestrator } from '@push.rocks/smartagent';
const smartai = new SmartAi({ openaiToken: 'sk-...' });
await smartai.start();
const orchestrator = new DualAgentOrchestrator({
smartAiInstance: smartai, // Reuse existing instance
guardianPolicyPrompt: '...',
});
await orchestrator.start();
// ... use orchestrator ...
await orchestrator.stop();
// SmartAi instance lifecycle is managed separately
await smartai.stop();
```
## Supported Providers
SmartAgent supports all providers from `@push.rocks/smartai`:
| Provider | Driver | Guardian |
|----------|:------:|:--------:|
| OpenAI | ✅ | ✅ |
| Anthropic | ✅ | ✅ |
| Perplexity | ✅ | ✅ |
| Groq | ✅ | ✅ |
| Ollama | ✅ | ✅ |
| XAI | ✅ | ✅ |
| Exo | ✅ | ✅ |
**💡 Pro tip**: Use a faster/cheaper model for Guardian (like Groq) and a more capable model for Driver:
```typescript
const orchestrator = new DualAgentOrchestrator({
openaiToken: 'sk-...',
groqToken: 'gsk-...',
defaultProvider: 'openai', // Driver uses OpenAI
guardianProvider: 'groq', // Guardian uses Groq (faster, cheaper)
guardianPolicyPrompt: '...',
const { content, truncated, notice } = truncateOutput(hugeOutput, {
maxLines: 2000, // default
maxBytes: 50_000, // default
});
```
## API Reference
The built-in tool factories use `truncateOutput` internally.
### DualAgentOrchestrator
## Multi-Turn Conversations 💬
| Method | Description |
|--------|-------------|
| `start()` | Initialize all tools and AI providers |
| `stop()` | Cleanup all tools and resources |
| `run(task, options?)` | Execute a task with optional images for vision |
| `continueTask(input)` | Continue a task with user input |
| `registerTool(tool)` | Register a custom tool |
| `registerStandardTools()` | Register all built-in tools |
| `registerScopedFilesystemTool(basePath, excludePatterns?)` | Register filesystem tool with path restriction |
| `setGuardianPolicy(policy)` | Update Guardian policy at runtime |
| `getHistory()` | Get conversation history |
| `getToolNames()` | Get list of registered tool names |
| `isActive()` | Check if orchestrator is running |
### Exports
Pass the returned `messages` back for multi-turn interactions:
```typescript
// Main classes
export { DualAgentOrchestrator } from '@push.rocks/smartagent';
export { DriverAgent } from '@push.rocks/smartagent';
export { GuardianAgent } from '@push.rocks/smartagent';
// First turn
const turn1 = await runAgent({
model,
prompt: 'Create a project structure',
tools,
maxSteps: 10,
});
// Tools
export { BaseToolWrapper } from '@push.rocks/smartagent';
export { FilesystemTool, type IFilesystemToolOptions } from '@push.rocks/smartagent';
export { HttpTool } from '@push.rocks/smartagent';
export { ShellTool } from '@push.rocks/smartagent';
export { BrowserTool } from '@push.rocks/smartagent';
export { DenoTool, type TDenoPermission } from '@push.rocks/smartagent';
export { JsonValidatorTool } from '@push.rocks/smartagent';
// Types and interfaces
export * from '@push.rocks/smartagent'; // All interfaces
// Re-exported from @push.rocks/smartai
export { type ISmartAiOptions, type TProvider, type ChatMessage, type ChatOptions, type ChatResponse };
// Second turn — continues the conversation
const turn2 = await runAgent({
model,
prompt: 'Now add a README to the project',
tools,
maxSteps: 10,
messages: turn1.messages, // pass history
});
```
## Exports
### Main (`@push.rocks/smartagent`)
| Export | Type | Description |
|--------|------|-------------|
| `runAgent` | function | Core agentic loop |
| `ToolRegistry` | class | Tool collection helper |
| `truncateOutput` | function | Output truncation utility |
| `ContextOverflowError` | class | Error type for context overflow |
| `tool` | function | Re-exported from `@push.rocks/smartai` |
| `z` | object | Re-exported Zod for schema definitions |
| `stepCountIs` | function | Re-exported from AI SDK |
| `jsonSchema` | function | Re-exported from `@push.rocks/smartai` |
### Tools (`@push.rocks/smartagent/tools`)
| Export | Type | Description |
|--------|------|-------------|
| `filesystemTool` | factory | File operations (read, write, list, delete) |
| `shellTool` | factory | Shell command execution |
| `httpTool` | factory | HTTP GET/POST requests |
| `jsonTool` | factory | JSON validation and transformation |
### Compaction (`@push.rocks/smartagent/compaction`)
| Export | Type | Description |
|--------|------|-------------|
| `compactMessages` | function | Summarize message history to free context |
## Dependencies
- **[`@push.rocks/smartai`](https://code.foss.global/push.rocks/smartai)** — Provider registry, `getModel()`, re-exports `tool`/`jsonSchema`
- **[`ai`](https://www.npmjs.com/package/ai)** v6 — Vercel AI SDK (`streamText`, `stepCountIs`, `ModelMessage`)
- **[`zod`](https://www.npmjs.com/package/zod)** — Tool input schema definitions
- **[`@push.rocks/smartfs`](https://code.foss.global/push.rocks/smartfs)** — Filesystem tool implementation
- **[`@push.rocks/smartshell`](https://code.foss.global/push.rocks/smartshell)** — Shell tool implementation
- **[`@push.rocks/smartrequest`](https://code.foss.global/push.rocks/smartrequest)** — HTTP tool implementation
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.

239
test/test.agent-e2e.ts Normal file
View 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();

View File

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

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartagent',
version: '1.5.3',
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.'
}

View File

@@ -1,31 +1,11 @@
import * as plugins from './plugins.js';
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 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 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 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';

View File

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

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

View File

@@ -1,701 +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;
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) {
this.onToken(`[THINKING] ${chunk.thinking}`);
}
if (chunk.content && this.onToken) {
this.onToken(chunk.content);
}
} : undefined
);
// Add assistant response to history
const historyMessage: plugins.smartai.ChatMessage = {
role: 'assistant',
content: response.message || '',
reasoning: response.thinking || response.reasoning,
};
this.messageHistory.push(historyMessage);
// 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)
* @returns Response with content, reasoning, and any tool calls
*/
public async continueWithNativeTools(
message: string
): Promise<{ message: interfaces.IAgentMessage; toolCalls?: interfaces.INativeToolCall[] }> {
// Add the new message to history
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
let historyForChat: plugins.smartai.ChatMessage[];
const fullHistory = this.messageHistory.slice(0, -1);
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.');
}
// Use collectStreamResponse for streaming support with tools
const response = await provider.collectStreamResponse(
{
systemMessage: fullSystemMessage,
userMessage: message,
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) {
this.onToken(`[THINKING] ${chunk.thinking}`);
}
if (chunk.content && this.onToken) {
this.onToken(chunk.content);
}
} : undefined
);
// Add assistant response to history
this.messageHistory.push({
role: 'assistant',
content: response.message || '',
reasoning: response.thinking || response.reasoning,
});
// 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,
};
});
}
}

View File

@@ -1,654 +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';
/**
* 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 tools: Map<string, BaseToolWrapper> = new Map();
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
*/
public registerTool(tool: BaseToolWrapper): void {
this.tools.set(tool.name, tool);
// Register with agents if they exist (they're created in start())
if (this.driver) {
this.driver.registerTool(tool);
}
if (this.guardian) {
this.guardian.registerTool(tool);
}
}
/**
* 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 any tools that were added before start() with the agents
for (const tool of this.tools.values()) {
this.driver.registerTool(tool);
this.guardian.registerTool(tool);
}
// Initialize all tools
const initPromises: Promise<void>[] = [];
for (const tool of this.tools.values()) {
initPromises.push(tool.initialize());
}
await Promise.all(initPromises);
this.isRunning = true;
}
/**
* Cleanup all tools
*/
public async stop(): Promise<void> {
const cleanupPromises: Promise<void>[] = [];
for (const tool of this.tools.values()) {
cleanupPromises.push(tool.cleanup());
}
await Promise.all(cleanupPromises);
// 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.tools.get(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 continueResult = await this.driver.continueWithNativeTools(resultMessage);
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 continueResult = await this.driver.continueWithNativeTools(
`TOOL ERROR: ${errorMessage}\n\nPlease try a different approach.`
);
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 Array.from(this.tools.keys());
}
}

View File

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

View File

@@ -0,0 +1,20 @@
import type { ToolSet } from './plugins.js';
export class ToolRegistry {
private tools: ToolSet = {};
/**
* Register a tool.
* @param name Tool name (must be unique, snake_case recommended)
* @param def Tool definition created with ai-sdk's tool() helper
*/
public register(name: string, def: ToolSet[string]): this {
this.tools[name] = def;
return this;
}
/** Get the full ToolSet for passing to runAgent */
public getTools(): ToolSet {
return { ...this.tools };
}
}

View File

@@ -1,306 +1,54 @@
import * as plugins from './plugins.js';
import type { ToolSet, ModelMessage, LanguageModelV3 } from './plugins.js';
// ================================
// 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';
}
}

View File

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

View File

@@ -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}`;
}
}
}

View File

@@ -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}`;
}
}
}

View File

@@ -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}`;
}
}
}

View File

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

View File

@@ -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}`;
}
}
}

View File

@@ -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}`;
}
}
}

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

@@ -0,0 +1 @@
export { compactMessages } from './smartagent.compaction.js';

6
ts_compaction/plugins.ts Normal file
View File

@@ -0,0 +1,6 @@
import { generateText } from 'ai';
export { generateText };
export type { ModelMessage } from 'ai';
export type { LanguageModelV3 } from '@push.rocks/smartai';

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